← Commit history
README.md+98−10
@@ -18,6 +18,10 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Add rows.** The New button creates a task or a milestone: name, lane, owner, dates, status, notes and provenance. Created rows live in the sidecar, so the data file is never rewritten, and they appear in every export. +**Quick add** (0.79.0). Hover a section's header and a **+** appears beside its chevron: click it and a blank row opens at the end of that section with the cursor in it. Type a name and press Enter: the task is made and the row stays open for the next one, so adding a list of tasks is names and Enter. Esc, or Enter on an empty row, stops. The **+** on a task row does the same for its subtasks, indented under it. In the Table the **+** in a row's Section cell adds a task to that section, the blank row opening under that row whether or not the Table is sorted. There is no "+ Add task" line under every section: quick add is the **+** on hover. A quick-added task starts from what New remembers (owner, status, tags) and New's default dates (today, one week), then from the view's own filters, so under the Owner filter it is that person's and stays on screen. Everything else is edited afterwards. Each task is one Ctrl+Z and one changelog line, and quick add leaves Dependency mode first, as New does.++**Subtasks** (0.79.0). A task can hold subtasks, up to three levels under a top-level task (ClickUp's default). A subtask sits under its task in the chart's label column and in the Table, indented, and the task wears a chevron and a count. Subtasks start folded (ClickUp's default): a task shows its chevron and count, and its chevron opens them, for you only, remembered in your browser, while everybody else's stay as they had them; a folded task's bar shows its subtasks' span, earliest start to latest end, as a bracket under it. **Collapse all** and **Expand all** fold and open them too. To nest a row, drag it onto the middle of a task, or select it and press **Alt+Right** to put it under the row above it; **Alt+Left** takes it out to straight after its task, and dragging it to a gap with the pointer moved left does too (the drop line is indented to the level the row will land at). Moving a task moves its subtasks with it, into another section too. Subtasks are full tasks: their own dates, owner, status, effort, tags and dependencies, and Auto-schedule, Critical, the filters and Analytics treat them like any other task. A task's dates are its own, never forced to span its subtasks. In the Table a task's Effort shows its own hours and, after a dot, its hours with its subtasks'; Analytics counts every task's own hours once, so nothing is counted twice. Deleting a task deletes its subtasks, and one Ctrl+Z brings them all back. When a filter matches a subtask but not its task, the task stays on screen, dimmed, so the subtask is never shown out of context. Under a Table sort every row stands on its own, a subtask marked with an arrow.+ **Five statuses, in lifecycle order.** Not started, in progress, complete, then the two ways off that road: blocked and cancelled. Every surface takes that order from one list, so the panels, the create panel, the status filter, the inline pill list, the analytics status card, the per-person card's five columns and the table's status-column sort all read the same way, and sorting by status ranks a finished row ahead of a stuck one. Plans written against the older "live" and "planned" still read correctly without being changed, and so do the American "canceled" and a tracker's "won't do".  **Cancelled is work that will not happen**, which is a different thing from work that is finished. The row stays on the plan, because deleting it would lose why it was ever there and what used to depend on it, but its HOURS leave every total: the project total, the owner's load, the section's effort and every percentage computed from them. Its own numbers are untouched, so the bar, the tooltip, the export and Ctrl+Z all still say what they said. It still counts as an item and it still has a column on the status cards, because it is a decision somebody made and worth seeing. It is never overdue. On the chart the bar is dimmed and the row is muted, well short of the fade a filtered-out row gets, and the pill carries a line through it.@@ -52,9 +56,11 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Dependency arrows are coloured by where they come FROM.** Each arrow takes the phase colour of the task at its tail, so a chain's origin is readable at a glance instead of every arrow being the same teal. A lane colour chosen to work as a wide bar can be invisible as a thin line, so the stroke is derived rather than copied: it is lifted or deepened away from the page, holding its hue, until it clears a 3:1 contrast ratio, which means a lane painted navy gets a lighter blue arrow that still reads as that lane. Picking an arrow in dependency mode, arming one for deletion and lighting the critical path all still override the colour while they are active. An arrow into or out of cancelled work is still drawn, dimmed by the same fraction the cancelled bar takes, so a link the schedule no longer walks recedes exactly as far as the work it points at. -**The full item editor is the New panel.** Editing a task or a milestone opens the same layout, in the same order, with the same labels, as creating one: type and lane, name, owner and status, the dates, notes and provenance. Type is shown but not editable on an existing row, because a task has two dates and a bar and a milestone has one date and a diamond, and dropping one of a task's dates is not a conversion. The buttons are Apply, Cancel and Delete, with Delete kept away from the other two.+**One edit panel, one layout.** Every panel that edits a task looks and works the same: the item editor on a task or a milestone, opened from the chart or from the Table, the New panel and the batch panel. A title that reads as a title, a grid of labelled fields, the buttons along the bottom. It is 400px wide at 100% text and grows with the app's text size like everything else (480px at 120%), and it is never wider than the window: on a phone, fields that sit side by side on a laptop stack one to a row. A long name, notes, bar label, owner or provenance wraps, and the field grows to show all of it, as you type and when the panel opens. A panel taller than the window scrolls its fields inside itself with the buttons staying put, and it never reaches into the strip along the bottom edge where the selection chip, the toasts and the save indicator live, so nothing ever covers Apply. Enter applies and Escape cancels in all of them. -**The task editor remembers where you put it.** The panel that opens on a bar is a compact 256px square, draggable by the strip at its top. It is meant to be glanced at rather than filled in: the row's name across the top, the two dates, the note, the dependency list (the one part that flexes, and the one part that scrolls) and the buttons, at desktop density, so it sits over as little of the chart it is editing as it can. The full **Edit item** panel, with the name, owner, status and provenance fields, is the one you fill in and it is unchanged. Drag it once and every panel after it opens in that same spot on screen, whatever row it belongs to and however far the chart has been scrolled, until the page is reloaded. Nothing is saved to the plan; it is a property of the sitting, and a refresh restores the automatic placement beside the bar.+**The full item editor is the New panel.** Editing a task or a milestone opens the same layout, in the same order, with the same labels, as creating one: type and section, name, owner and status, the dates, effort, notes, provenance and tags. Type is shown but not editable on an existing row, because a task has two dates and a bar and a milestone has one date and a diamond, and dropping one of a task's dates is not a conversion. The buttons are Apply, Cancel and Delete, with Delete kept away from the other two.++**Every edit panel remembers where you put it.** A panel opens beside what you clicked (New under the New button, the batch panel in the middle of the window) and is dragged by its title. Drag it once and every panel after it opens in that same spot on screen, whatever row it belongs to and however far the chart has been scrolled, until the page is reloaded. Nothing is saved to the plan; it is a property of the sitting, and a refresh restores the automatic placement beside the bar.  **Select a row without opening anything.** Every task and milestone row has a handle on its left edge, six dots that show when you point at the row: in the chart at the start of the name, in the Table at the start of the ID. A click on the handle selects that row and nothing else happens: no rename, no picker, no editor. Ctrl+click on a handle adds the row or takes it out, Shift+click selects the rows in between (only the ones on screen), and Escape clears. Then press **Delete** twice to delete the selection, or use Ctrl+C, Alt+Up and Alt+Down, or Copy IDs. Drag the handle up or down to move the row. A selected row keeps its handle showing, in the accent colour. Your selection is this tab's own and is never saved. @@ -90,9 +96,9 @@ 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. 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.+**Import.** JSON (native format), CSV, MS Project XML, Jira CSV and ClickUp (see [Importing from ClickUp](#importing-from-clickup)). 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, a Jira issue of the type Milestone as a milestone, with Jira's Blocks links arriving as dependencies, and a ClickUp Milestone with the dependencies ClickUp's API gives it. A file with rows that carry no usable date is rejected whole, naming the rows, rather than having a date invented for it; a ClickUp task with no date at all comes in on the day of the import, tagged undated, instead. 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.+**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, and a note that runs over several lines comes back in whole.  **Auto-save.** Edits persist to a sidecar JSON file next to the data file, debounced while you work and flushed immediately if you close or hide the tab, so the changelog never claims an edit the sidecar did not get. Delete the sidecar to reset. Every row in it is keyed by its permanent `uid`, so renaming a row, reordering the plan, or inserting a row into the middle of the data file all leave every saved edit pointing exactly where it was. Sidecars written by older versions keyed rows by position; they are translated on the way in and keep working. The saved row order is applied all-or-nothing, so a key that no longer resolves can never drop a row. @@ -102,7 +108,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Brand phase colors.** A phase with no explicit `color` cycles the three Adom hues: teal, purple, blue. There is no background band behind a phase: the bar colour and the group header carry the section on their own, so colour on the chart means exactly one thing, which phase a bar belongs to. The hues are theme-aware, so the same plan is on-brand on screen and legible on paper. -**Text size.** A-minus and A-plus at the right end of the header step the whole app between 85% and 150% in seven steps, chart and table together. The percentage between them is the reset. Rows, bars and the axis grow with the type, so nothing is clipped, and the choice persists across a refresh, in the live app and in an exported HTML snapshot alike.+**Text size.** A-minus and A-plus at the right end of the header step the whole app between 85% and 150% in seven steps, the chart, the table and the edit panels together. The percentage between them is the reset. Rows, bars and the axis grow with the type, so nothing is clipped, and the choice persists across a refresh, in the live app and in an exported HTML snapshot alike.  **Fit to the pane.** One click on Fit picks the finest cell size that shows the whole plan without scrolling sideways, then stretches the cells to fill the width exactly. Click it again after resizing the window to re-fit. @@ -126,9 +132,9 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  ## In action -Click a bar and the inline editor opens over the chart: date pickers for start and end, plus the incoming ("needs") and outgoing ("blocks") dependencies, each removable, with a picker to add more.+Click a bar and the item editor opens beside it with every field of the row, among them the incoming ("needs") and outgoing ("blocks") dependencies, each removable, with a picker to add more. -![Inline bar editor showing date pickers and dependency management](screenshots/editor.png)+![The item editor beside its bar: every field of the row, the dependencies it waits on and the ones waiting on it, and the buttons](screenshots/editor.png)  The layout scales to real plans. This is the bundled SMT production line example: 5 phases, 43 items, 8 milestones, striped bars for ongoing work: @@ -219,6 +225,8 @@ section's colour. Colours are lowercase `#rrggbb`, names follow the tag rules ab plan that never uses the registry carries no `project.tags` key. Registry changes are logged as `tag.create`, `tag.rename`, `tag.color` and `tag.delete`. +**Subtasks** (0.79.0): `"parent": "00042"` puts a task or a milestone under the task with that uid (a task's `id` works too, and is read as that task's uid). A parent must be a task, a row cannot sit under its own subtask, and subtasks go three levels deep under a top-level task; a parent the plan cannot take is dropped and the row stays a top-level row. A subtask is always in its top-level task's section, whatever its own `phase` says. Each row also carries `pos`, its place among its siblings: a fractional index key such as `"a0"`, `"a1"`, `"a0V"`, generated by the app and compared as plain strings. Leave it out when writing a plan by hand or importing one: rows without it are placed in the order they are listed. The JSON and CSV exports carry `parent` (the CSV as a column after `uid`) and an import reads it back.+ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values.  ## Sections are phases@@ -255,7 +263,8 @@ It exists because every other name a row has is conditional. A task `id` is a sl 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,+`.pre-msid.bak`; the first start of 0.79.0 gives every row its place key the same way, keeping+the sidecar as `.pre-pos.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@@ -279,6 +288,71 @@ file and the sidecar. That is what makes it safe to insert a row into the middle a plan: nothing below it is identified by its position any more. Sidecars written by older versions are translated on the way in and keep working. +## Importing from ClickUp++A project kept in ClickUp comes across in one import. Like every import it replaces the+whole plan (backed up first), and every row gets a fresh uid. Each row's provenance links+back to its task in ClickUp.++**Not yet checked against a real ClickUp project:** compare a first import with ClickUp before relying on it.++**Import, then ClickUp CSV** takes either of ClickUp's CSV exports:++- **A List or Table view's export**: in the view, Export view, CSV, all columns. Export a+  Folder's List view to bring every List in that Folder. Show subtasks as separate tasks+  first, or ClickUp leaves them out of the file. Any of its date formats reads.+- **The workspace export**: Settings, Imports and Exports, Export. It holds the whole+  workspace, so `POST /import/clickup-csv?list=<name or id>` (or `?folder=`, `?space=`,+  comma separated) keeps only part of it.++**Import, then ClickUp API JSON** takes what ClickUp's API answers for a List's tasks,+saved to a file. ClickUp's CSV exports do not include dependencies; this does.++```bash+# the List id is the number after /li/ in the List's link; a page holds 100 tasks+curl -s -H "Authorization: $CLICKUP_TOKEN" \+  "https://api.clickup.com/api/v2/list/<list id>/task?subtasks=true&include_closed=true&page=0" > page0.json+jq -s . page0.json page1.json > tasks.json      # more than 100 tasks: pages in one array+```++Or the server asks ClickUp itself: `POST /import/clickup-api` with `{"list":"<id>"}`,+`{"folder":"<id>"}`, `{"space":"<id>"}` or `{"url":"<a ClickUp List link>"}`, and it follows+the pages. The token comes from `CLICKUP_TOKEN` in the server's environment, or from a file+only its owner can read: `--clickup-token-file <path>`, `CLICKUP_TOKEN_FILE`, or+`~/.config/adom-gantt/clickup-token` by default, with `chmod 600`. It goes to ClickUp in one+header and nowhere else: never into the plan, the changelog or an answer.+`GET /import/clickup-api` says whether a token is set up, never what it is.++How ClickUp maps onto the plan:++| ClickUp | adom-gantt |+|---|---|+| a List, and its Folder | a section, "Folder · List". The Space goes in front when the file spans more than one |+| tasks | rows, in ClickUp's order: the export's row order, or the API's `orderindex` |+| subtasks | rows right after their parent, depth first, each naming its parent row's uid in `parent` (the subtasks model). A milestone is never a parent: its subtasks go under the nearest task above it, or to the top level. Three levels under a task is the deepest; anything deeper sits at the third |+| start and due date | start and end. Only a due date: the bar starts on it. Only a start: it ends on it. A parent with neither spans its dated subtasks. A task with no date at all, subtask or milestone included, is a one-day bar on the day of the import, tagged `undated`, so a made-up date is easy to find and fix; the report and the changelog count them |+| assignees | owner, the first one. The others are named in the notes ("Also assigned in ClickUp: ...") |+| status | the five statuses. From the API, by ClickUp's status type (open, custom, done, closed). From a CSV, by name: to do, backlog and open are not started; in progress, review and testing in progress; complete, done and closed complete; blocked, on hold and waiting blocked; cancelled and won't do cancelled. A task with a done date is complete. Any other name reads as in progress, which is what ClickUp's middle statuses are, and the report marks it as a guess |+| time estimate | effort, in hours |+| tags | tags |+| description | notes |+| the Milestone task type | a milestone on its due date |+| dependencies (API) | deps, to and from milestones too. A ClickUp row's id here is `cu-` and its ClickUp id, a milestone's as well as a task's |++Dates are the days ClickUp shows. ClickUp keeps a date with no time of day at 04:00 in the+zone of whoever set it, and the importer reads it in that zone, so nobody east of UTC finds+every date a day early.++**The report.** Every ClickUp import answers with a `report`, and the page shows it once,+after the reload, in the tab that imported: how many rows and sections came across, what+changed on the way in (undated rows placed and tagged, subtasks kept under their parent,+starts taken from due dates, parents spanning their subtasks, repeated rows merged), how each+ClickUp status was read, and what did not come across. Not carried over, and counted: priority+(by level), each custom field by name, checklists, comments, attachments, time tracked,+sprint points, sprints, linked tasks, tasks that also sit in other Lists, team assignees,+custom task types, and dependencies with nowhere to go. The changelog's import line lists+what did not come across, so an agent reading the history later knows the plan never had it.+ ## Sharing a plan with your team  Every page load and every change requires a signed-in Adom account. An unauthenticated@@ -447,7 +521,8 @@ with the edits if you choose to discard them. | 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 |-| Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane, and a lane that is collapsed OPENS the moment the moving row lands in it, so you can see what you are moving among. Under a column sort the row stays inside the run of rows the sort considers tied with it. The pane follows the row it is moving, keeping five rows visible ahead of it in the direction of travel, and both columns stay locked. With several rows selected the whole set moves as a group; if the selection has gaps in it, the FIRST press closes it up into one block at the topmost selected row and every press after that moves the block |+| Alt+Right / Alt+Left | Nest the selected row (or the focused row in the table) under the row just above it, as that task's last subtask, or take it out to straight after the task it is under (0.79.0). Several selected rows nest and come out together, as one undo. Only with a row to act on, so with nothing selected the browser keeps its own Back and Forward |+| Alt+Up / Alt+Down | Move a row one slot, among its siblings since 0.79.0: a task steps past the next task with all of its subtasks, and a subtask stops at the first or the last of its task's subtasks (Alt+Left takes it out). The rest as before: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane, and a lane that is collapsed OPENS the moment the moving row lands in it, so you can see what you are moving among. Under a column sort the row stays inside the run of rows the sort considers tied with it. The pane follows the row it is moving, keeping five rows visible ahead of it in the direction of travel, and both columns stay locked. With several rows selected the whole set moves as a group; if the selection has gaps in it, the FIRST press closes it up into one block at the topmost selected row and every press after that moves the block | | Tab / Shift+Tab | In the table, walk the selected row's fields. With no editor open it opens the first editable cell (Shift+Tab the last); with one open it commits and moves to the next. Wraps across rows and around the ends, and follows the visible order under a sort. With no row selected, Tab walks the page's own controls as usual. In the edit panels it is one keypress per field the same way, including the date fields, which Tab leaves whole instead of walking their day, month and year segments | | Esc | Leave the field first: pressed with the caret in any text box it takes the focus back out of it, so the keyboard shortcuts work again on the very next keypress. It does not clear the box, and it does not do anything else in the same press. Fields that already have an Esc of their own keep it (a name editor cancels, a dropdown's type-in closes the list, a panel field closes the panel). With nothing focused it goes on as before: cancel the open field, then clear the row focus, then clear the selection. A row picked with its handle clears in one press | | Ctrl+click | Toggle item in selection (on a row's handle, a bar, a name or a Table row) |@@ -485,7 +560,10 @@ table, the item editor or the batch editor are taken exactly as typed. | Click a name | Rename it in place | | Drag bar edge | Resize start or end date | | Drag bar body | Move bar (preserves duration) |-| Drag vertically | Reorder rows |+| Drag vertically | Reorder rows. Between two rows, the drop line is indented to the level the row will land at; move the pointer right to nest it under the row above, left to take it out (0.79.0) |+| Drag onto the middle of a task | Make the dragged rows that task's last subtasks; the task is outlined while you hover it (0.79.0) |+| Click a task's chevron | Fold or unfold its subtasks. Your view only (0.79.0) |+| + on a section header, + on a task | Quick add: a blank row at the end of the section, or as the task's last subtask. Enter adds and keeps the row open, Esc stops (0.79.0) |  ## Server endpoints @@ -512,6 +590,9 @@ loopback are open. `/health` is public and leaks nothing but counts. | `/import/csv` | POST | Import CSV | | `/import/msproject-xml` | POST | Import MS Project XML | | `/import/jira-csv` | POST | Import Jira CSV |+| `/import/clickup-csv` | POST | Import a ClickUp CSV export (a view's or the workspace's). `?space=`, `?folder=`, `?list=` keep part of it; `?today=YYYY-MM-DD` is the day undated tasks land on (the page sends its own). Answers with a `report` |+| `/import/clickup-json` | POST | Import the ClickUp API's answer for a List's tasks, saved to a file. Answers with a `report` |+| `/import/clickup-api` | GET or POST | POST `{"list"|"folder"|"space"|"url": ...}` imports straight from ClickUp's API with the server's token; GET says whether one is set up | | `/compact` | POST | Bake saved edits into the data file and empty the sidecar | | `/export/json` | GET or POST | Export merged JSON. GET renders the plan on disk (the AI/loopback loop); POST renders the client model in the body, which is how the Export button reflects unsaved edits | | `/export/csv` | GET or POST | Export CSV, same GET/POST split |@@ -534,8 +615,15 @@ cd test && npm install        # puppeteer-core, once; Chrome from ~/.cache/puppe node test/ui_test.mjs         # exits non-zero on any failure node test/ui_test.mjs --keep --plan path/to/plan.json   # also a real plan (copied first); keep screenshots in test/out/ node test/ui_test.mjs --only "editor: item,text size"   # a subset of flows, for a fast inner loop+node test/server_test.mjs --only clickup                 # the server suite's ClickUp section alone ``` +The ClickUp import is covered by synthetic files in ClickUp's real shapes, in+`test/fixtures/`: a workspace export written from GMT+5:30, a Folder's view export with ISO+dates, and the API's answer for a List. The server suite also runs the live API against a+stand-in for ClickUp on loopback; the browser suite's `clickup import` flow imports through+the menu and reads the report.+ **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.  ```
SKILL.md+146−13
@@ -22,7 +22,8 @@ description: >   timeline, view schedule, project plan chart, milestone chart, task   table, who owns what, what is mine, owner, deadline, task list,   auto-schedule, align the dates, critical path, slack, float, analytics,-  hours per person, how many hours, capacity, overdue, what is late.+  hours per person, how many hours, capacity, overdue, what is late,+  clickup, import from clickup, move a project out of clickup.   Use when the user wants to see, build or edit a project plan on a   timeline, when they ask who owns what or what is due when, when they   want the dates aligned to the dependencies or want to know which chain@@ -182,6 +183,8 @@ items. | 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 | | effort | number | no | Estimated work in HOURS (0.63.0), e.g. `16`. 0 to 100000, kept to one decimal; a numeric string is read as a number, anything else is dropped. Absent means not estimated, and the task adds nothing to the Analytics hours. Independent of the dates: the bar is the window, this is the work |+| parent | string | no | Subtasks (0.79.0): the uid of the task this row sits under (a task `id` is read as that task's uid). Absent: a top-level row in its section. See Subtasks below |+| pos | string | no | The row's place among its siblings, a fractional index key (`"a0"`, `"a1"`, `"a0V"`). Generated by the app; leave it out and the rows are placed in the order they are listed. See Subtasks below |  **type: "milestone"**: diamond marker, or a dashed vertical rule if the user has switched milestone style @@ -197,6 +200,33 @@ items. | 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 |+| parent, pos | string | no | As a task's: a milestone can be a subtask, never a parent |++**Subtasks (0.79.0).** A row's `parent` is the uid of the task it sits under, and its `pos`+is its place among its siblings (the rows with the same parent, or for a top-level row the+same section). Siblings sort by `pos` as plain strings, then by uid. The rules:++- A parent is a live task (never a milestone), not the row itself and not one of its own+  subtasks, and a top-level task holds at most three levels under it (ClickUp's default).+- A subtask is always in its top-level task's section. The server keeps every subtask's+  stored `phase` equal to that task's, so a reader that ignores `parent` still groups rows+  right; moving a task to another section moves its subtree.+- `pos` keys are David Greenspan's fractional indexing (the `fractional-indexing` package's+  format): base 62 `0-9A-Za-z`, `a0` first, at most 200 characters. Do not invent one; leave it+  out and the server places the row after its siblings, in the order rows are listed.+- `state.order`, the flat list, is still written, derived: sections in list order, each+  top-level row by `pos` followed by its subtree, depth first. older pages read it.+- Deleting a task in the app deletes its subtasks too. A row whose parent is deleted any+  other way (by a script, or by an older page that knows nothing of subtasks) is kept, and takes+  the deleted row's place: its parent, its section, a key just after it.+- Folding a task is a person's view, never written to the plan. Subtasks start folded (0.79.0): the page keeps the tasks its person OPENED (`prefs.openRows`), and every other task with subtasks, a new one included, reads as folded.++To nest or move a row over the API, send its whole place in one op: `{"op":"row","uid":"00121",+"set":{"parent":"00120","pos":""}}` puts it last under 00120 (an empty `pos` asks the server for+one); `{"op":"row","uid":"00121","set":{"parent":"","pos":"","phase":2}}` makes it a top-level+row at the end of section 2. A place the tree cannot hold is refused whole and named in+`refused`. `/export/json` carries `parent` and `pos` on rows, `/export/csv` a `parent` column+after `uid`, and both imports read `parent` back (by uid or task id).  **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:@@ -218,7 +248,7 @@ around it was its date alone: when the work slipped, the gate stayed put. `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 back; a CSV without the column imports as before. A CSV is rows, so section tags are not in it. **So a CSV export and re-import loses the section tags, and with them every-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.+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, and so do a ClickUp task'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@@ -276,6 +306,7 @@ rather than a category. - **Collapse all / Expand all** leads the legend (0.57.0), in the chart and the table. One press collapses every section to its header; when every section with rows is already collapsed it expands them all. A mixed state collapses. It is the same view state as the per-lane chevrons (`prefs.collapsed`), so it persists across a refresh, follows the chevrons (open the last lane by hand and it reads Collapse all again), and the table agrees with the chart. With everything collapsed the table says so rather than "no items match" - **The row handle selects a row and opens nothing.** Every task and milestone row has a strip on its left edge (`.row-sel`, six dots, shown on hover and kept in the accent on a selected row): in the chart in the name column's left padding, in the Table at the start of the ID cell. A click selects that row only (never a toggle), Ctrl or Cmd+click adds or removes it, Shift+click takes the range from the anchor over the rows ON SCREEN (a folded section or a filter's hidden rows are skipped, in the chart as in the Table), and Escape clears it in one press. It opens no rename, picker, cell or panel (Ctrl and Shift on it do not open the batch panel), and it commits or closes whatever a click elsewhere would. Its press keeps the focus off it, so Delete twice, Ctrl+C, Alt+Arrow, Tab and the arrows answer on the next key. Dragged up or down it moves the row like the rest of the row; a sideways drag from it moves no dates. Hidden in Dependency mode. It is the same selection as every other, local to the tab (the presence highlight still shows it to others) and never saved - **Click a bar or a milestone** to open the **item editor** (0.65.0), a floating panel beside what you clicked with every field of the row: type, section, name, owner, status, start, deadline, effort, notes, provenance, tags, the label on the bar and the dependencies. It is the ONE editor for a task or a milestone: the pencil on a row, a double-click on a name, and the Table's Deps cell (which focuses "waits on") open the same panel. A single click on a name in the label column renames it in place instead (0.66.1). Everything in it, the dependencies included, is a draft that lands on Apply (or Enter) as one undo, and Cancel or Escape leaves the plan as it was. Through 0.64 a bar click opened a small dates-and-dependencies panel whose dependency changes landed at once, and a milestone click a name-date-description panel; both are gone+- **Every edit panel is one layout (0.79.0).** The item editor (on a task or a milestone, from the chart or the Table), the New panel and the batch panel are built by one function on one grid: a title in the heading face, labelled fields two to a row on a laptop and one to a row on a phone, the buttons at the foot. 400px wide at 100% text and in the app's text size (480px at 120%), never wider than the window. A long name, notes, bar label, owner or provenance wraps and its field grows to show it. A panel taller than the window scrolls its fields inside itself and keeps its buttons on screen, and no panel reaches into the strip along the bottom edge where the selection chip, the toasts and the save indicator live. Enter applies and Escape cancels in all three - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded - **Sign in with Adom is required** for every page load and every change. Unauthenticated visitors get a sign-in page and no plan data. Sessions are an HttpOnly cookie, 30 days, and survive a restart. `--no-auth` turns it off for single-user local use - **Direct-loopback GETs of `/data`, `/state`, `/changelog` and `/export/*` need no session**, so an AI agent's read loop keeps working; everything that changes the plan needs a signed-in person from any origin. A request is "direct loopback" only if it comes from 127.0.0.1 with no `x-forwarded-*`/`cf-*` headers and a loopback `Host`, because the workspace proxy also arrives from 127.0.0.1@@ -284,9 +315,14 @@ rather than a category. - **Delete, then Delete again** removes the selected rows, in the chart and the Table alike. The first press marks them in red and the toast asks (naming one row, counting a set); the second press within four seconds deletes them through `deleteRows` (or `deleteRow` for the Table's one row), one undo entry, and any other key, a click or Esc withdraws the question. In the Table with nothing selected it acts on the focused row, and the focus then moves to the row that slid up into its place, so a cleanup run is Delete twice per row (until the row handle the Table deleted at the first press, so a second press out of the chart's habit took the next row too). Only when no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound - **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.+- **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. Since 0.79.0+a page sends no `move`: a move, a nest or an un-nest is a change to the row's own place+(`parent`, `pos`, and `phase` for a top-level row), sent whole and kept or taken whole, and+the server derives the order from the places; a page lays its rows out from the places and+uses an announced order only as a check. The server still takes an older page's `move` ops and+turns them into places. - **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+- **The batch panel's Delete** removes the WHOLE selection, milestones included, in one press. It is every edit panel's danger button in every edit panel's 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 (anywhere on the row, or on its handle) 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 - **Arrows are coloured by where they come FROM.** Each dependency arrow, line and head together, takes the phase colour of the task at its TAIL, so the origin of a chain is readable without tracing it back and two arrows into the same row say which lane each came out of. A lane colour is chosen to work as a 20px bar and can vanish as a 1.5px line, so the stroke is derived rather than copied: hue and saturation are held and lightness is walked away from the page in 2% steps until the stroke clears a 3:1 contrast ratio against the background, which is the WCAG minimum for a graphical object. A lane painted navy therefore gets a lighter blue arrow on the dark theme and is left alone on the light one, and it still reads as that lane rather than reverting to a neutral. Recomputed on a theme change, a lane repaint and a row moving lane. The amber of a picked arrow, the red of one armed for deletion and the white of the critical path all still override it while they are active@@ -415,6 +451,7 @@ entered the same way: | Kind | One way, everywhere | |------|---------------------| | A task or milestone, all its fields | The **item editor**: a click on the bar or the milestone, the pencil, a double-click on the name, or the Deps cell. Apply or Enter commits; Cancel or Escape discards |+| Any edit panel | One layout (0.79.0) for the item editor, the New panel and the batch panel: the title, a grid of labelled fields (two to a row on a laptop, one on a phone), the buttons at the foot. 400px at 100% text and in the app's text size; Enter applies and Escape cancels in each (the batch panel too); a long name, notes, bar label, owner or provenance wraps and its field grows; a panel taller than the window scrolls its fields and keeps its buttons, and stays out of the bottom strip where the selection chip and the toasts live | | One field, quickly | In place: a Table cell, a click on a name in the chart's label column (rename), or the status and owner pills on the label row. Enter or a click away commits; Escape cancels | | A pick from a fixed list (section, status, type, "waits on") | The app's own list, in the editor, the New panel, the Table and the pills alike. The keyboard is the select's own: the arrows walk the value and the open list follows, Enter takes it and closes the list (without applying a panel), Space or Alt+Down opens it, Escape closes it. "waits on" adds only on a click or Enter, never on a step of the arrows | | An owner | One owner picker: a field with the plan's people listed under it, filtered as you type (matches first, "no owner" last); a new name is just typed. Down and Up walk the list, Enter takes the highlighted name, Tab takes it and moves on, Escape closes the list; with nothing highlighted Enter keeps what was typed. In the editor, the New panel, the Table's Owner cell and the owner pill (which opens the same field over the pill, since 0.66.1) |@@ -452,7 +489,7 @@ named in its own column.  **Label editing.** A click on a name in the chart's label column renames it in place, the caret where you clicked: Enter or a click away saves, Escape cancels, one undo (0.66.1; 0.65.0 opened the item editor here, which Drew did not want for a rename). A double-click upgrades the rename to the item editor, carrying what was typed. A lane header's name is still edited in place, as on the Setup page. Dragging a task across a group header changes its phase. -**The full item editor is the New panel.** Editing a task or a milestone opens exactly the layout creating one does, in the same order and with the same labels, hints and placeholders: type and lane on one row, name, owner and status, the two dates, notes, provenance. Three things differ, and each is a place where editing an existing row is not the same job as making a new one. TYPE is shown but disabled, with a tooltip saying why: a task has a start, an end and a bar while a milestone has one date and a diamond, so silently dropping one of a task's dates would not be a conversion, and being disabled it is skipped by Tab. STATUS keeps a "none" choice the create panel has no use for, so a status set by accident can be cleared. The BUTTONS are Apply, Cancel and Delete, with Delete pushed away from the other two. A milestone HIDES the start field rather than removing it, exactly as the create panel does when its type is switched to Milestone, so the two panels stay the same shape.+**The full item editor is the New panel.** Editing a task or a milestone opens exactly the layout creating one does (since 0.79.0 they are built by one function, with the batch panel), in the same order and with the same labels, hints and placeholders: type and section on one row, name, owner and status, the two dates, effort, notes, provenance, tags. The editor adds the label on the bar beside effort and the dependency list at the end. Three things differ, and each is a place where editing an existing row is not the same job as making a new one. TYPE is shown but disabled, with a tooltip saying why: a task has a start, an end and a bar while a milestone has one date and a diamond, so silently dropping one of a task's dates would not be a conversion, and being disabled it is skipped by Tab. STATUS keeps a "none" choice the create panel has no use for, so a status set by accident can be cleared. The BUTTONS are Apply, Cancel and Delete, with Delete pushed away from the other two. A milestone HIDES the start field rather than removing it, exactly as the create panel does when its type is switched to Milestone, so the two panels stay the same shape.  **Popup lists scroll.** The owner list, the status list and the table's select cells are the app's own lists rather than native popups. They can be scrolled by wheel, trackpad or scrollbar without closing, which matters as soon as a plan has more people than a list can show. Escape closes them, a click genuinely outside closes them, choosing an item commits and closes, and the plan scrolling out from under an open list still closes it, because a list left hanging over a cell that has moved is worse than one that went away. @@ -713,7 +750,7 @@ The rules, all of them:   the HTML snapshot). `/import/json` and `/import/csv` read it back, so a round   trip lands on the same handles. A duplicate or malformed uid in an imported file   is re-issued (the first occurrence keeps it) and the response says how many.-  MS Project and Jira imports number from scratch: their ids mean nothing here.+  MS Project, Jira and ClickUp imports number from scratch: their ids mean nothing here.  The user's batch-edit workflow depends on this. They select rows, press **Copy IDs**, and paste you lines of `uid`, a tab, and the current name. Quote the uid@@ -836,7 +873,7 @@ curl -s -X POST localhost:8901/log -H "Authorization: Bearer $TOK" \        "to":{"note":"Venue confirmed: Hart SH-216"},"via":"agent"}]}' ``` -`/ops`, `/save`, `/log`, `/uid`, `/uid/backfill`, `/compact` and the four imports all take it.+`/ops`, `/save`, `/log`, `/uid`, `/uid/backfill`, `/compact` and every import all take it.  **Change the plan with `/ops` (0.77.0), the same ops a tab sends, carrying only the fields you mean to change.** It lands on whatever the plan is by now, field by field, never@@ -850,7 +887,8 @@ curl -s -X POST localhost:8901/ops -H "Authorization: Bearer $TOK" \ ```  Fields use the sidecar's names (`s`, `e`, `date`, `deps`, `note`, `owner`, `status`, `desc`,-`prov`, `tags`, `effort`, `name`, `phase`). The answer carries `stateRev`, the ops as stored,+`prov`, `tags`, `effort`, `name`, `phase`, and since 0.79.0 `parent` and `pos`, which go+together, see Subtasks). The answer carries `stateRev`, the ops as stored, and `refused` naming anything skipped (an unknown uid, a malformed date, the project dates or calendar from anyone but the owner). Post the matching changelog line to `/log` after it. @@ -878,7 +916,7 @@ that moved under you comes back 409 instead of erasing somebody.) Then re-read  **Your write is a revision every open tab hears about.** Ops land on every tab at once and leave everyone's own changes alone; a whole-state `/save` makes every tab pull the plan.-Prefer `/ops` for created rows too: a whole-state `/save` that changed a created row's own+Since 0.79.0 a whole-state `/save` that changes a created row's own fields in `added` also updates the row's override in `items` where the override still held the field's previous value, so an override written by an earlier edit no longer hides the change (on 2026-11-03 an agent's dependency and date change on a created row were lost that way); an override that says something else on purpose still wins. Prefer `/ops` for created rows too: a whole-state `/save` that changed a created row's own fields (its section, on 2026-11-03) was invisible to tabs older than 0.77.1, whose catch-up read only the overrides; from 0.77.1 a catch-up compares every field of every row. The server keeps the stored order grouped by section (0.77.1): after any batch that touches the@@ -888,13 +926,108 @@ revision. Either way, a plan somebody is editing right now is a plan you should write to once, in one batch, rather than a dozen times across a long pass. +## Importing from ClickUp++When someone wants a ClickUp project in a plan, use the ClickUp imports; do not translate+the file into the plan's JSON yourself. They know ClickUp's columns, its date rules and its+subtasks, and they answer with a report of what did not come across, which you relay.++**Not yet checked against a real ClickUp project:** say so when you import one, and suggest comparing the first import with ClickUp.++| Door | Body | Use when |+|------|------|----------|+| `POST /import/clickup-csv` | a ClickUp CSV export, as sent | the person exported a List or Table view (Export view, CSV, all columns) or the workspace (Settings, Imports and Exports) |+| `POST /import/clickup-json` | the API's answer for a List's tasks: `{"tasks":[...]}`, an array of those pages, or a bare array of tasks | dependencies matter: a CSV export never carries them |+| `POST /import/clickup-api` | `{"list":"<id>"}`, `{"lists":[...]}`, `{"folder":"<id>"}`, `{"space":"<id>"}` or `{"url":"<a ClickUp List link>"}` | the server has a ClickUp token; it fetches every page itself |++All three replace the whole plan, back it up first, log one `import` event (its `notCarried`+lists what did not come across) and take the agent token. On `clickup-csv` and+`clickup-json`, `?space=`, `?folder=` and `?list=` (names or ids, comma separated, any case)+keep only those containers; a workspace export holds the whole workspace, so ask which+List or Folder is the project before importing one. On all three doors `?today=YYYY-MM-DD` is+the day undated tasks land on; the page sends the person's own date, and without it the+server uses its own.++The token for `clickup-api` is read on every call from `CLICKUP_TOKEN` in the server's+environment, else the first line of `--clickup-token-file`, `CLICKUP_TOKEN_FILE` or+`~/.config/adom-gantt/clickup-token`, which must be `chmod 600` or it is refused. Never put+a token in a request body, the plan or a message. `GET /import/clickup-api` answers+`{configured, from, tokenFile}` and never the token. A List id is the number after `/li/` in+its link.++The mapping, in one place:++- **Sections:** one per List, named `Folder · List`, with the Space in front only when the+  file spans more than one. A List with no Folder is just its name.+- **Rows:** ClickUp's order (the export's rows; the API's `orderindex` within each List). A+  subtask comes right after its parent, depth first, in its top-level task's section, and+  carries `parent`: its parent row's uid, the subtasks model's field. `pos` is left out, so+  the server places each sibling group in the listed order. A milestone is never a parent (its+  subtasks go under the nearest task above it, or to the top level) and three levels under a+  task is the deepest (a deeper subtask sits at the third level); the report counts both.+  Task and milestone ids become `cu-<ClickUp id>`; provenance is+  `{"label":"ClickUp <id>","url":"https://app.clickup.com/t/<id>"}`.+- **Dates:** start and due become start and end. Only a due date: start on it. Only a start:+  end on it. A parent with neither: span its dated subtasks. Neither and no dated subtask+  (subtasks and milestones too): a one-day bar on the import's day, tagged `undated` (Drew,+  2026-11-03), counted as `undated` in the report and named on the changelog's import line, so+  nobody mistakes the date for a real one.+  A date with no time of day is ClickUp's 04:00 in the setter's zone and is read in that+  zone, so the day is the one ClickUp shows.+- **Owner:** the first assignee; the others are appended to the notes as "Also assigned in+  ClickUp: ...". An assignee with no display name is their email.+- **Status:** by ClickUp's status type where the source has it (the API): open is not+  started, done and closed are complete, a closed status named cancelled (or won't do) is+  cancelled, and the custom ones in between go by name, else in progress (a middle status+  named like "approved" stays in progress: ClickUp says it is not done). From a CSV, by+  name, then by a done date; any name the importer does not know reads as in progress and+  the report marks it as a guess. Fix a wrong guess with one `/ops` batch after the import.+- **Effort:** the time estimate, in hours. **Tags:** tags. **Notes:** the description.+- **Milestones:** the Milestone task type (`custom_item_id` 1 in the API) is a milestone on+  its due date. Other custom task types come across as tasks and are counted.+- **Deps:** from the API's `dependencies` (`task_id` waits on `depends_on`; the same entry+  sits on both tasks), or from a CSV's `Waiting On` and `Blocking` columns where someone+  added them. Both ends may be milestones (0.78.0); a link to a task that did not come across+  has nothing to point at and is counted.++The answer carries `report`:++```json+{ "from": "ClickUp workspace export (CSV)", "read": 20, "rows": 19, "tasks": 17,+  "milestones": 2, "subtasks": 5, "sections": 3, "deps": 0, "effort": 12,+  "statuses": [{ "name": "spec", "to": "in-progress", "by": "guess", "count": 1 }],+  "changed": ["5 subtasks keep their parent task, each right after it."],+  "dropped": [{ "what": "Priority", "count": 6, "detail": "urgent 1, high 3, normal 1, low 1" },+              { "what": "Custom field Story Points (number)", "count": 2 }],+  "undated": 1, "undatedOn": "2026-11-03",+  "unchecked": "Not yet checked against a real ClickUp project. Compare this plan with ClickUp before relying on it." }+```++`by` is `type`, `name`, `date` or `guess`. `dropped` lists only what the file had: priority by+level, each custom field by name, checklists, comments, attachments, time tracked, sprint+points, sprints, linked tasks, linked docs, watchers, tasks also in other Lists, team+assignees, custom task types, dependencies with nowhere to go, columns the importer does not+read, dates it could not read, and, for a CSV with no dependency columns, a `Dependencies`+entry with count 0 saying where to get them. `unchecked` is there until an import has been+compared with its project in ClickUp. Tell the person how many rows are undated (they carry the+`undated` tag, so the legend finds them) and what did not come across, in plain words; the page+shows the same report once to whoever imported through the menu.+ ## Dates are required  A task needs a `start` and an `end`; a milestone needs a `date`, each looking like-`2026-08-27`. All four import endpoints reject the whole file with a 400 naming the-offending rows rather than defaulting a date, because an invented date is+`2026-08-27`. The JSON, CSV, MS Project and Jira imports reject the whole file with a 400+naming the offending rows rather than defaulting a date, because an invented date is indistinguishable from a real one once it is in the file. +The ClickUp imports are the exception, by Drew's decision (2026-11-03), since nearly every+ClickUp project has undated tasks and some have no dates at all: a task with no date of its+own and no dated subtask comes in as a one-day bar on the day of the import, tagged+`undated`, and the report and the changelog's import line count those rows, which is what+keeps the made-up date from passing for a real one. A missing start or due is taken from the+other date the task does have, and a parent with neither spans its dated subtasks; the+report counts each.+ A hand-edited data file never passes through an importer, so the viewer is tolerant too: a row it cannot date is left out of the chart and named in a warning, rather than taking the other 72 rows down with it. Fix the data file and reload.@@ -917,8 +1050,8 @@ 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`. Both carry `deps` (a milestone's since 0.78.0) |-| order | a list of uids, the plan's row order |+| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase, tags, effort, parent, pos}`, 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). `parent` and `pos` are the row's place (0.79.0, see Subtasks): `parent: ""` makes a row top-level over the data file's. A row the server places that had no entry gets a whole one, as an edit makes it |+| order | a list of uids, the plan's row order. Since 0.79.0 it is derived from the rows' places and written for older readers: never edit it to move a row, change the row's place | | 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` | | phases | `{i, start, end, tags}`, keyed by phase index, because a phase is not a row. A current client writes the whole section list (`name`, `uid`, `lead`, `color` too) |
client-vs-server/README.md+9−5
@@ -9,8 +9,9 @@ teammate sees a change to it within about a second.  ## The short answer -Pressing **Collapse all**, folding a section, filtering to **Unassigned**, turning on-**Current** or **Overdue**, or switching to the Table changes only your screen. Your+Pressing **Collapse all**, folding a section or a task's subtasks, filtering to+**Unassigned**, turning on **Current** or **Overdue**, or switching to the Table changes+only your screen. Your teammates keep whatever they had.  Dragging a bar, changing an owner or a status, renaming a section, pressing@@ -26,6 +27,7 @@ Gone when you reload, and not shared even with your other tabs: | The section, Milestone and Dependencies chips in the legend | Hiding or dimming a section is a quick look, not a setting | | Your selection, the open editor, your scroll position | However you picked the rows (a row's handle, Ctrl+click, Shift+click), the selection is this tab's own. It is shown to others as a highlight (see below), but it is never saved | | Undo history | Ctrl+Z undoes this tab's own edits only, and never a teammate's later change to the same field |+| A quick-add row you are typing in | The task is saved when you press Enter; the blank row itself is yours alone (0.79.0) | | "See what the team sees" | The owner's preview of the team view lasts until the tab closes |  ## This browser@@ -35,7 +37,7 @@ tab of the same plan in the same browser shares them; another computer starts fr  | What | Notes | |---|---|-| Collapse all, each section's fold | The table follows the chart's folds |+| Collapse all, each section's fold, each task's subtasks folded (0.79.0) | The table follows the chart's folds | | Owner, Current, Overdue and tag filters | One Owner filter for the chart and the table, in the same place (0.77.1) | | The lens: Gantt, Table, Analytics, Setup | Analytics and the owner cards on Setup are hidden for teammates | | Days, weeks or months; text size; light or dark; milestone style; Today line | |@@ -55,7 +57,7 @@ Saved by the adom-gantt server and shared with everyone the plan is shared with. |---|---| | Every task and milestone | Name, dates, owner, status, notes, tags, effort, provenance link, dependencies | | Sections | Names, colours, leads, tags and order |-| Row order and tag colours | |+| Row order and tag colours | Since 0.79.0 each row holds its own place: the task it sits under (`parent`) and its key among its siblings (`pos`) | | Added, deleted, pasted and imported rows | A delete can be undone by the tab that made it | | Changelog | Every change, who made it and when, in an append-only file the server checks for tampering | | Backups | Daily, and before every import or compaction; the last 30 are kept |@@ -70,7 +72,9 @@ Edits save about half a second after you stop, as the fields you changed and not keep their change; the same field at the same moment goes to the later one, in every tab, and the person whose value was replaced is told. The server keeps the row order grouped by section, the way every screen shows it, and a tab that falls behind or finds it disagrees-with the server fetches the whole plan and puts itself right (0.77.1).+with the server fetches the whole plan and puts itself right (0.77.1). Since 0.79.0 a move,+a nest or an un-nest is a change to that row's own place, sent whole, so two people moving+rows never edit a shared list; the flat order is derived from the places on the server.  ## Live, never saved 
client-vs-server/where-it-lives.html+3−3
@@ -78,7 +78,7 @@       <h2>Remembered, never shared</h2>       <p class="sub">Your view only. Kept for this plan in this browser, across reloads.</p>       <ul>-        <li><b>Collapse all</b> and each section's fold</li>+        <li><b>Collapse all</b>, each section's fold, and which tasks' subtasks you opened (folded until you do)</li>         <li><b>Owner</b> (one filter for the chart and the table), Current, Overdue and tag filters</li>         <li>Lens: Gantt, Table, Analytics, Setup</li>         <li>Days / weeks / months, text size, light or dark, milestone style, Today line</li>@@ -99,8 +99,8 @@       <h2>The plan: same for everyone</h2>       <p class="sub">Saved by the adom-gantt server, on every teammate's screen within a second.</p>       <ul>-        <li><b>Every task and milestone:</b> name, dates, owner, status, notes, tags, effort, provenance link, dependencies</li>-        <li><b>Sections:</b> names, colours, leads, order; the row order, kept grouped by section; tag colours</li>+        <li><b>Every task and milestone:</b> name, dates, owner, status, notes, tags, effort, provenance link, dependencies, the task it sits under</li>+        <li><b>Sections:</b> names, colours, leads, order; each row's place, under its task or in its section; tag colours</li>         <li>Anything that moves dates: dragging a bar, editing a row, <b>Auto-schedule</b> (owner only)</li>         <li><b>Project dates and the working calendar</b> on Setup (owner only)</li>         <li>Adding, deleting, pasting and importing rows</li>
client-vs-server/where-it-lives.png
⋯ 1 unchanged line ⋯
docs/BUILD-NOTES.md+405
@@ -3,6 +3,411 @@ 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-05 (0.79.0, one release: subtasks and quick add, the one edit layout, the ClickUp importer, and a lost-edit fix)++Drew approved one combined release of the branches that were ready, merged onto 0.78.2 in this+order: `editor-compact` (one layout for every panel that edits a task), `subtasks` (subtasks and+quick add, issues #5 and #6) and `clickup-import` (undated tasks come in as one-day bars tagged+"undated", and the importer says it has not been checked against a real ClickUp project). Their+own notes follow. The conflicts were in the build notes, the version lines, the 0.78.2 deps fix+(both sides had made the same copy; main's comment and regression check kept) and the server+test's imports.++**The subtasks defaults, as Drew chose them**, where they differ from what the branch built:++- *Folded by default, each person's own.* The branch opened every task's subtasks until somebody+  folded them. Now a page keeps the tasks its person OPENED (`prefs.openRows`, in their browser)+  and every other task with subtasks reads as folded, a new one included, so a teammate's new+  subtasks never spread out under somebody else's work. Opening a task to add or nest under it+  opens it for that person. `ui_test` subtasks: they start folded, an opened task stays open+  across a reload, and stays folded for somebody else.+- *Quick add is the + on hover only.* The branch put a "+ Add task" line under every section in+  the Table in plan order; it is gone. The Table keeps the + in a row's Section cell and on a+  task row, both on hover; the chart keeps the + on a section's header and on a task row.+- Unchanged and as chosen: three levels deep, and a parent's dates are its own, never synced+  with its subtasks.++**Fixed: a whole-state save could lose a created row's change (the second lost-edit bug).** The+sidecar keeps a created row twice, its entry in `added` and, once anybody has edited it, a full+override in `items` that the merge applies on top. On 2026-11-03 the AI session using the plan's+key changed created row 00524 in `added` (a dependency on 00551 and its dates) with a whole-state+`/save` that sent the overrides back as it had read them; an override written by an earlier owner+edit held the old values and hid both. A `/save` now carries a changed field of a created row+into its override where the override still held the field's previous value and the save did not+change the override itself (`carryAddedEdits`). An override that says something else is a+deliberate edit and stands. `server_test` section H: the case from that day, the deliberate+override kept, a save that changes the override itself, and a save sent back unchanged; on the+0.78.2 server it fails the two checks that matter.++**Tests on main after the merges**, under the suite lock: server 338, collaboration 121 (its fuzz+included), the fuzz with seeds 11 and 101 (six people) 5 each, UI 1076 with the standing 7+controls untested, nothing failed; the deploy rehearsal below 10/10.++**Deploying it** (rehearsed on a fresh copy of the live plan, 566 rows, with 0.78.1 tabs open and+the server swapped for 0.79.0 on the same files). The first start migrates the sidecar only:+every row gets its place key (`pos`), and a row that had no override (75 on the live plan,+mostly created rows) gets a full one written from its current values, with an empty note or+dependency list written out as empty; no row's content changes, no section, no order, no row+gets a parent, the data file and the changelog are untouched. The sidecar as it was is kept+first as `<plan>-state.json.pre-pos.bak` (new in this release; the uid and milestone id+migrations keep theirs the same way). One revision, so open pages fetch the plan once.+0.78.1 tabs keep working with it both ways and go quiet; everybody should still reload once to+get the release. A server restart is needed (`server.js` changed).++## Build notes, 2026-11-03 (0.79.0, subtasks and quick add, issues #5 and #6)++Stephen asked for subtasks (#5) and quick add (#6), with ClickUp as the reference. Drew:+follow the proven designs rather than reinvent them, "no need to reinvent the wheel when+that is so seamless and performant with dozens of people working in sync".++**The model** (also in `.gantt-gate/MODEL-subtasks.md` for the ClickUp importer). Each row+gets `parent` (the uid of the task it sits under) and `pos`, its own place among its+siblings: a fractional index key (David Greenspan's algorithm, the `fractional-indexing`+package's format, `a0`, `a1`, `a0V`). This is the design Figma's multiplayer uses for its+layer tree (a parent link and a position on each child) and the reason ClickUp keeps an order+index per task: a move, a nest or an un-nest is a change to one row's own fields, the last+writer wins them, and there is no shared list for two tabs to disagree about, which is what+fought on 2026-11-03. A row's place (parent, key and, for a top-level row, section) is one+value: a page sends it whole and takes or keeps it whole (Figma stores parent and position as+one property for the same reason). The flat `state.order` is still written, derived, for pages+from before 0.79.0 and for a rollback.++**ClickUp's answers, as adopted** (sources below):++- Depth: ClickUp's Nested Subtasks allow up to 7 levels, default 3. Adopted: 3 levels under+  a top-level task. The label column gets narrow past that.+- Parent dates: ClickUp keeps a parent's own dates; "sync dates with subtasks" is an opt-in+  that rewrites them, and editing the parent turns it off. Adopted: a parent keeps its own+  dates, never forced; a folded parent shows its subtasks' span under its bar (the issue's+  ask). The opt-in sync is left for Drew: it is a derived write, and derived writes are what+  collaboration has to be careful with.+- Hours: ClickUp shows a parent's estimate as its own plus its subtasks'. Adopted: Analytics+  counts every task's own effort once (nothing counted twice, as before); the Table shows a+  parent's own hours and, after a dot, its total with its subtasks.+- Deleting a parent: ClickUp sends the subtasks to the trash with it. Adopted, one undo for+  all. A row nested by somebody else meanwhile (or deleted by an older page or a script that+  knows nothing of subtasks) is promoted to the deleted row's place, never deleted.+- Filters: ClickUp hides a matching subtask whose parent does not match (unless subtasks are+  "separate"; a long-standing complaint), but its assignee filter brings the parent up.+  Adopted: the parent stays, dimmed, for context, in both lenses; it is not counted.+- Sorting: ClickUp needs subtasks shown as separate tasks to sort them. Adopted: under a Table+  sort every row stands alone, a subtask marked with an arrow and its task's name on hover.+- CSV: ClickUp exports Parent ID. Adopted: a `parent` column (uid) after `uid`, read back by+  the CSV import (uid or task id). JSON carries `parent` and `pos`.+- Quick add: ClickUp's "+ Add Task" under a List group, Enter saves, values from the group and+  the view's filters; the Gantt sidebar's + on a list or a task. Adopted: + on a section header+  and on a task in the chart, a "+ Add task" line under each section in the Table, Enter keeps+  the row open, New's remembered owner, status and tags and New's dates, then the view's owner,+  status and tag filters.++Sources (checked 2026-11-03): ClickUp Help Center articles 6304431740055 (nested+subtasks, levels), 6309825777943 (subtask view options), 34108351520407 (sync dates with+subtasks), 7253583724695 (time estimate rollup), 6311742742423 (deleting), 6308875427223 and+the feedback board's "show subtasks when filters are applied" (filters), 6310551109527 (CSV+export columns), 31463619842711 and 6310382044567 (quick add); the ClickUp API's Get Task+(`parent`, `orderindex`); Figma's "How Figma's multiplayer technology works" (2019) and+"Realtime editing of ordered sequences" (2017).++**Mixed versions.** An older page knows nothing of parents or keys. Its moves ("after that row")+and section changes are turned into places by the server (`legacyPlace`): the fewest rows+that turn the derived order into the one it asked for are re-placed, a row keeps its parent+while it lands among that parent's subtasks and otherwise becomes the sibling of the row it+lands above. A subtask it moves to another section leaves its task. It sees subtasks as plain+rows straight under their task, and adopts every order the server sends.++**What changed.**++- Server: one tree function, `treeOf`, the page's copy line for line, derives the flat order,+  each row's effective parent and depth and a subtask's section. `/ops` takes `parent` and+  `pos` and refuses a place the tree cannot hold, whole. After every batch that touches places,+  `normalizeTree` keeps the tree whole (orphans take their deleted parent's place, cut links,+  depth, a subtask's section, a key for every row, no two siblings on one key) and the server+  announces what it changed as the rows' own fields. A whole-state `/save` keeps the stored+  places and reads its order as moves. At start every row without a key gets one, in the+  order it was shown, so a plan with no sidecar gets one. Imports place rows in the data file;+  the CSV has a `parent` column; the SVG indents subtasks.+- Page: rows are laid out from the tree (`placeHeaders` runs `syncTree`). A tab sends a row's+  place whole and never a `move`; it keeps or takes a place whole; an announced `order` is+  only a check, and a tab with nothing of its own on the way that lays rows out otherwise+  fetches the plan. Undo has a `place` entry. Every way to move a row now sets places:+  Alt+Up/Down (among siblings, a top-level row still steps into the next section), group+  moves and grouping a scattered selection, drags in both lenses (with nest and level zones),+  paste (a subtask copied with its task is pasted under the copy), the editor's and the+  Table's Section field (a subtask leaves its task), removing a section.+- Quick add: the + on a section header and on a task in the chart, the Table's Section-cell ++  (sorted or not) and its "+ Add task" lines in plan order. The blank row is not one of the+  plan's rows: it follows its anchor through every reflow and keeps the typing and the caret+  through somebody else's change. It owns its Escape (the page's capture-phase Escape took the+  focus out first; found by the suite).++**Found by the suites while building, all fixed.** The start gave every row a key in a small+`{uid, pos}` sidecar entry, and a dependency or tag op on such a row merged onto that entry's+empty list, dropping the data file's own items (the 0.77 `docs` and `fuzz` scenarios caught+it). A server from before 0.79.0 would have done the same after a rollback. An entry made to place a row is+now a whole one, made from the row as the plan has it, as an entry made by an edit always was,+and an op fills out any small entry it finds first. Esc in the blank row only took the focus+out of it (the page's capture-phase Escape ran first). The Table opens sorted by deadline, so+its "+ Add task" lines (plan order) were not where people would look first: the Section+cell's + works sorted or not.++**Found after the merge of milestone dependencies, in 0.77 and 0.78 too.** A link made in+Dependency mode to a row that already had a dependency list never reached the server, and the+page said saved. The page's copy of what the server holds took the row's own list rather than a+copy of it, and the mode adds to that list in place, so the page found nothing to send. Every+row has a list once the server has placed it, so here every milestone link made in the mode was+lost (the `milestone deps` flow caught it). 0.77 and 0.78 do the same to a task that already+waits on something: in a lab on 0.78.0, a second dependency added in the mode stayed on the page+and never reached the server, and a reload loses it. The copy now takes a list of its own, as+it always did for tags (`rowOv`), and `milestone deps` checks a second link on a task.++**Merged with 0.78.1 (touch only what changed).** A move from somebody else is a row's own key+now, so 0.78.1's order-only path would never have run: a batch whose rows change nothing but+their keys (`keyOnlyOp`: the same parent, the same section) is applied as an order-only merge,+so only the moved rows' nodes move, no header, colour or pinned header is touched, and the+arrows keep every measurement outside the span that moved. A nest, an un-nest or a change of+section is still structural. The Table's quick-add lines are keyed into `reconcileTable` like+its rows (patch and render share `tableQaLines`), the blank row's field keeps its focus when its+row is moved, and `placeInOrder` steps over the chart's blank row, which follows the row it was+opened from. Measured with 0.78.1's perf lab (a copy of the team plan, 506 rows, changes at 2.6+a second, 30 s windows, three watching tabs): a row moved by somebody else costs a watching tab+16.4 ms in the Gantt (0.78.1: 16 ms; 0.77.1: 225 ms) and 22 ms in the Table (0.78.1: 17 ms),+a status edit 12.6 ms and 31 ms, with no long task; both panes in the server's order and no+page error in every window. The two orders the 2026-11-03 fight flipped between are the same+order once grouped by section, so replaying that fight changes the plan once and then nothing.++**Merged with 0.78.2 (the row handle).** The Table's ID cell carries the handle, apart from the+tree in the name cell, and the chevron starts where the handle's strip ends (0.78.2's CSS for+it). A delete names its one row, or a task and how many subtasks go with it; the armed mark+covers the subtree in both lenses, while the second press still checks the rows named. UI+`subtasks`, `quick add`, `row select`, `live: delete`, `coverage`, `milestone deps`, `table+filters`, `row links` and `editor: batch` after it: 259/0.++**Tests.** server_test section G (subtasks: keys at start, nest, refused milestone parent, loop+and fourth level, a moved parent's subtree, a deleted parent's subtask and a nest under a+deleted row taking its place, older pages' moves and section changes, two rows on one key, a+whole-state save keeping places and reading its moves, a list op on a placed row, CSV export+and import of parents, and 0.77.1's own server, from git, run on a sidecar this one wrote). collab_test `subtasks` (21 checks, three people and a 0.77.1 tab on+the new server: nest and un-nest at once, one row under two tasks at once, a loop at once, a+nest under a row being deleted in both orders, a parent deleted with its subtasks and undone,+undo of a nest and of a nest a teammate moved since, nobody sending anything afterwards, the+0.77 tab's order, moves, section change and delete). ui_test `subtasks` (21) and `quick add`+(18), coverage entries for the chevron, both +s, the Section-cell + and the blank row's field.+Runs, under the suite lock, after the merge of 0.78.1: server 247/0; collaboration `docs`,+`fuzz` (six people, 931 actions), `sections`, `subtasks`, `milestones`, `join` and `merge`+89/0; the whole UI suite 955/0 (the standing 7 controls untested). Before it, on 0.78.0, the+dependency fix: UI `milestone deps`, `quick add` and the editors 154/0, collaboration `docs`,+`fuzz` and `milestones` 34/0. Rendering stays incremental: a move from somebody else takes+0.78.1's order-only path (above), a field edit the same patch path as before, and the Table's+quick-add lines are keyed like its rows, so a live change still patches it. In the `merge`+scenario (six people, the generated 490-row plan) one person's save cost every other tab at+most 173 ms of main thread (the Table lens; Gantt 120 ms; 457 ms before 0.78.1), p95 156 ms,+at most 45 forced layouts, and showed in every tab within 0.8 s; tabs opened in 0.7 s median+(`join`).++**Left for Drew.** The depth limit (3, ClickUp's default; ClickUp allows up to 7). ClickUp's+opt-in "sync dates with subtasks". A "+ Add task" line under every section in the chart as+well (ClickUp's Gantt uses the + on hover, which is what this does; a line per section adds a+row per section to the chart). Folded or open by default (open here; ClickUp's List opens+folded). MS Project outline levels and Jira's parent field are not mapped yet.+++## Build notes, 2026-11-03 (0.79.0, the ClickUp importer)++Stephen keeps his projects in ClickUp. The ask was an importer so he can move one in.++**What ClickUp's files are, measured rather than assumed.** ClickUp's help pages refuse+automated reads, so the shapes come from real exports people have committed to public+repositories: a 2,689-row workspace export (2026, scrubbed by its owner), and three view+exports (2023 and 2025). They were read in a scratch folder and are not in this repo; the+fixtures in `test/fixtures/` are synthetic files in the same shapes.++- The **workspace export** (Settings, Imports and Exports) has 34 fixed columns. Dates are+  Unix milliseconds, each with a text twin that ends in the exporter's zone+  (`11/3/2025, 4:00:00 AM GMT+5:30`). A date with no time of day is stored at 04:00 in the+  setter's zone, which is the evening before in UTC for anyone east of UTC+4, so reading the+  epoch as UTC puts every date a day early in India. Missing values are the letters `null`.+  One task can sit on two rows that differ only in assignees and subtask ids. Folders are a+  JSON array (`["A/B"]`). Priority is a number, 1 urgent to 4 low. No dependency column and+  no custom fields.+- A **view export** (Export view, CSV) has the view's columns: `Assignee` singular, `tags`+  lower case, dates in the format chosen (Normal `Friday, March 10th 2023`, ISO, or POSIX),+  a folderless List's Folder written as `hidden`, custom fields as `Name (type)`, and+  `Parent ID` only when that column is shown (the parent's `Subtask ID's` always is).+- **Neither CSV carries dependencies.** ClickUp's own feedback board has an open request for+  it, with comments as recent as February 2026. The API carries them:+  `dependencies[]` entries `{task_id, depends_on}` mean `task_id` waits on `depends_on`, and+  the same entry sits on both tasks.+- The API marks a Milestone with `custom_item_id` 1 and gives each status a `type` (open,+  custom, done, closed). `orderindex` is ClickUp's manual order within a List.++**Decisions.**++- *Undated tasks come in on the day of the import, tagged `undated`* (Drew, 2026-11-03). The+  first build left them out and named them, after the house rule that an invented date reads+  exactly like a real one; but in the real workspace export 1,315 of 2,423 tasks have no date,+  and two of the three real view exports have none at all, so a project would arrive with+  holes or not at all. Now each is a one-day bar on the importing person's day (the page sends+  `?today=`; the server's date otherwise), subtasks and milestones too, and the tag, the+  report's `undated` count and the changelog's import line keep the made-up date from passing+  for a real one. A parent with no dates of its own spans its dated subtasks; a placeholder+  date never counts toward that span. A missing start is the due date and a missing due is+  the start, both derived from a real date and counted.+- *Subtasks follow MODEL-subtasks.md* (it landed mid-build): `parent` is the parent row's+  uid, written after the uids are stamped, and `pos` is left to the server, the rows listed in+  sibling order. A milestone is never a parent and three levels is the deepest; the report+  counts the rows moved for either. Until the subtasks branch merges they show as plain rows+  right under their parent.+- *Milestone dependencies (0.78.0) landed on main mid-build.* After the merge a ClickUp+  milestone gets an id like a task's (`cu-<ClickUp id>`), and links to and from it come+  across instead of being dropped and counted.+- *Statuses:* by type where the API gives it (a middle status called "approved" is still in+  progress, because ClickUp says it is not done); by name in a CSV, then by a done date; an+  unknown name reads as in progress, ClickUp's own meaning for a middle status, and is+  marked as a guess in the report.+- *Owner* is the first assignee and the rest go into the notes, since an owner here is one+  person. *Priority* has no home and is counted by level.+- *The report.* Every ClickUp import answers with `report` (what came across, what changed,+  each status mapping, what was not carried over, what was left out). The page holds it in+  session storage across the import's reload and shows it once, in the tab that imported.+  The changelog's import event carries `notCarried`, read into the markdown line.+- *The API door* reads its token from the environment or a 0600 file on every call, and the+  suite proves it reaches no file, answer or log line. `CLICKUP_API_BASE` exists so the suite+  can point it at a stand-in on loopback; a request cannot change it.+- *`parseCSVTable`* is a new whole-text reader: ClickUp descriptions have line breaks inside+  quoted cells, which the line-first `parseCSV` cut in two. `parseCSV` (the plain CSV and Jira+  imports) now reads through it too, which fixes an older bug: the app's own CSV export of a+  row whose notes ran over two lines could not be imported back. The plain CSV import also+  names a ClickUp file sent to it instead of failing on its dates.++**Gates** (targeted, under the suite lock, on the branch merged with main at 0.78.0): server+303 passed (the whole suite: the ClickUp section's 80, and a new check that a Jira description+over two lines does not move its Blocks links), ui `coverage,clickup import` 48 passed, and+before the 0.78.0 merge ui `import menu,row tags,clickup import` 141 passed; after Drew's two+decisions (undated rows, the unchecked note) server `--only clickup` 83 passed and ui+`clickup import` 31 passed; merged with main at 0.78.1, server 306 passed and ui+`import menu,clickup import` 48 passed; merged at 0.78.2, ui `coverage,clickup import` 49+passed; nothing failed.+Also read against the real exports above, in a scratch folder: the 2,689-row workspace export+imports in 182 ms (35 sections, 266 repeated rows merged, 450 subtasks, 1,315 undated tasks+left out and named) and the 2023 view export brings 40 of its 109 tasks.++**Not yet checked against a real ClickUp project** (Drew, 2026-11-03: a real export from+Stephen may never come). The public sample exports confirmed the file shapes and that the+importer reads them, but nobody has compared an import with the same project as ClickUp shows+it, and none of Adom's own ClickUp files has been through it. So the importer says so, calmly:+one line in the README and SKILL.md ClickUp sections, and the report card's note, which is the+server's `CU_UNCHECKED` text (sent as the report's `unchecked`). When a real export arrives,+import it, compare it with ClickUp, fix what differs, then take the flag off: `CU_UNCHECKED`+becomes `''`, the two doc lines go, and so do the checks for them in `server_test` and the+`clickup import` flow.++**Known gaps.** The API path names a+custom task type by number (`custom type 1001`); its name needs one more call. A workspace+export over the 8 MB body cap must be cut down to the List's view export.++## Build notes, 2026-11-03 (0.79.0, one layout for every panel that edits a task)++Drew: at the app's 120% text size the item editor was about 915px wide on a 1366px window,+its name and notes were cut to one line, its title ("Edit task") was the size of its field+labels, and none of its type followed the text size. He asked for a narrower panel, long text+that wraps and grows, a real title, every font in the app's text size, and one look for every+box that edits a task. Measured on the build before this one, with a plan of long names at+1366x768: 613px wide at 100% (855px for a milestone), the name and notes cut, and the panel+8px past the bottom of the window with Delete under the edge.++**Which boxes edit a task.** Three, all rendered into `#editor`: the item editor (a task or a+milestone, opened from a bar, a diamond or cap, the pencil, a double-click on a name, or the+Table's Deps cell), the New panel, and the batch panel (Ctrl+click). Everything else that+changes a row edits one field in place (a Table cell, the rename in the label column, the+status and owner pills, row-select's handle) and is not a box. The square bar panel and the+compact milestone panel that `.editor.square` and `.editor.compact` styled were retired in+0.65.0; their CSS, the `--ed-sq` variable and `edSquareSide` go with this change.++**One frame.** `edShell({title, sub, body, actions, err})` builds every panel: the grab strip+and the title (the head, which drags the panel), a body that is a six-column grid of fields+(`edF(label, control, {w: 'half'|'third'|'twothirds', ...})`), and a foot with the error line+and the buttons. Each field is a two-row subgrid, its label on the first row and its control+on the second, so two fields in one row start their controls on one line however their labels+wrap. A container query on the body (`max-width: 24em`, which is 288px at 100% because `em`+there is the body's own type) puts every field on a row of its own when two dates side by+side would no longer fit their fields: on a 390px phone at 120% and up. The rows: type and+section (a third and two thirds, since a section's name is the long one), name, owner and+status, start and deadline, effort and the bar label, notes, provenance and tags, then the+dependency list; the New panel the same without the bar label and the list. The batch panel:+the two dates, the three shifts as thirds, then tags and effort, set apart because they act at+once rather than on Apply.++**Size.** `width: min(400px * --fs, 100vw - 16px)`. The old panel had no width: it took the+width of its widest content, which on a real plan is the section select's longest name or the+"waits on" list's longest task name. Every font, gap, control height and padding in it is a+multiple of `--fs`, and so are the pick lists it opens (`.cell-list`, shared with the Table's+cells and the status pill, which now follow the text size too, wrap a long entry and never run+past the window; a list opened from a panel field is at least as wide as the field). The title+is 16px over 11px labels, in the heading face. Controls are 28px high at 100%, as the old ones+were, and provenance shares its row with tags: at 1366x768 and 120% the window leaves a panel+700px, and with 30px controls and a row each an ordinary task editor was about 840px.++**Height.** The panel's height is capped at the window less 8px at the top and a band at the+bottom (`--ed-bottom`, 60px, 120px while the save-blocked banner is up, more if a data warning+stands taller), and when its content is taller the body scrolls between the head and the foot,+so the buttons are always on screen. The band is where the "N selected, Copy IDs" chip, the+undo toast and the save indicator sit (above the panel), which is the trap that once put the+chip over Apply on a tall editor: no panel can be placed, dragged or grown into it. A+ResizeObserver moves the panel back inside the window whenever its size changes (a field grew,+a list filled, a field was hidden); it only ever moves the panel, never resizes it, so it+cannot feed itself.++**Wrapping fields.** The name, owner, notes, bar label and a plain provenance are textareas+(`edTa`) that grow (`edGrow`: height auto, then the scroll height). They stay one line of+text: Enter applies the panel as before, and a line break that arrives in a paste becomes a+space (`edOneLine`), so what is saved is what the one-line field saved. They are fitted when+the panel opens (before it is placed, so the placement measures the real panel), on every+input (in the capture phase, so the owner list measures the grown field), when the window or+the text size changes, when the web fonts arrive, and after a pick from the owner list. Tab+onto one selects its text, as it did an input's. A linked provenance and the dependency names+wrap too; a section select cannot, so its tooltip is the whole name.++**Placement.** `showEditor` takes a point or a function of the panel's measured size. The item+editor goes beside the bar or the Deps cell, flipped to the left when the right has no room;+New hangs under the New button, right edges together; the batch panel is centred in the room+above the band. A panel dragged by its head still sets the place every later panel opens at.++**Things found on the way.**++1. *A drag that ended past the clamp closed the panel.* Pushed against an edge, the panel stops+   while the pointer goes on, so the release comes up outside it, and the click the browser+   makes of the press and the release landed on the page, which read it as a click away. Older+   than this branch, but the bottom band makes it easy to hit. The end of a panel drag now sets+   `dragEndedAt`, which every click handler already ignores.+2. *The batch panel had no keys of its own:* Enter in a shift did nothing. Enter applies it now+   and Escape cancels, as in the other two; its tag and effort boxes keep their own Enter.+   Copy IDs moved from the title into the button row, where the item editor keeps Copy link,+   and "N milestones unchanged" into the line under the title.+3. *A pick from the owner list set the field without an input event,* so a long name picked+   from the list would not have regrown the field. It is refitted by hand.++**For the subtasks branch.** A row's parent goes in as one more `edF`, full width, after the+section: `edF('parent', '<select id="imParent">...', {for: 'imParent'})`. It needs no CSS.++**Tests.** `ui_test` flow "editor layout" (39 checks: the width at 120% on 1366, inside the+window and clear of the band, nothing past the sides, the body scrolling with the buttons on+top, the title's size against the labels, a long name, long notes and a linked provenance+opening whole, rows lined up, fields growing a line at a time as they are typed and shrinking+back, a pasted line break, Enter and Escape, every element's type size at each of the seven+text sizes against its 100% size, the pick lists, one width, title, label and button size for+all six kinds of panel, the panel dragged into the selection chip's corner and the save+indicator's, and the phone). The coverage walk opens the item editor from the Table for a+task and a milestone. Targeted run (live, editor, row select, pick by mouse, type ahead, row+tags, effort, provenance, row links, milestone deps, text size, coverage, leak, collab):+549/0; `collab_test --only docs` 20/0. The lab (`editor-lab.mjs`, outside the repo) took 258+screenshots: five windows (390x844 as a phone, 1366x768, 1600x950, 1920x1080, 2560x1440), all+seven text sizes at 1366 dark and four elsewhere, both themes, six kinds, with a 150-character+name, long notes, 14 dependencies and a linked provenance, and checked each one in the page+(the same list as the flow, plus no hidden text in any field and no overlapping fields): 0+failures, 0 page errors. Its probe (Tab order, Enter, Escape, the four pickers by mouse and+keys, undo and redo, the batch keys, New, a Table cell, the text size changing under an open+panel): 28/0.+ ## Build notes, 2026-11-03 (0.78.2, a handle that selects a row)  **Fixed first: links made in Dependency mode were lost (0.77.0 to 0.78.1).** Adding a
gantt.html+1588−600
@@ -101,27 +101,6 @@      Used when a phase declares no explicit colour. */   --ph1: #00b8b1; --ph2: #8c6bf7; --ph3: #64abff;   --bar-opacity: 0.82;-  /* The side of the square task editor. One number, used for both the width and the-     height in CSS and for the placement maths in JS, so the two can never disagree-     about how big the panel is when it clamps itself to the viewport.--     344 in 0.36.0, 370 in 0.40.0, 256 since 0.47.0.--     0.40.0's 370 was 344 plus the 26px the header that names the row cost the panel: the-     dependency list is the only part that flexes, so paying for the header out of the-     list would have taken it from 60px to 34px, half a row shorter than 0.36.0 sized it,-     and growing the side instead gave the list back exactly what the header took.--     0.47.0 shrinks the whole thing. This panel is GLANCED at (two dates, a note, the-     links, three buttons) and it sits on top of the chart it is editing, which is the one-     thing the reader needs to keep seeing; at 370 it was the size of a dialog and covered-     a quarter of the pane. The .editor.compact block in the stylesheet is the other half-     of the change: type down a step, gaps down about a third, floors of 24px on every-     control so a date stays a target. Measured at 256, the fixed chrome costs 225 and the-     list is left 31px, which is one row of the smaller list showing with the next one-     starting under it, so a task with more links than that says so and scrolls. Nothing-     was removed to get here: the panel is the same object at desktop density. */-  --ed-sq: 256px;   color-scheme: dark; } :root[data-theme="light"] {@@ -726,106 +705,102 @@ body.dep-mode .bar .drag-handle { display: none; } .tip .tip-body { color: var(--text2); } .tip .tip-prov { margin-top: 6px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); } -/* ── Editor panel (single + batch) ── */-.editor { position: fixed; z-index: 99998; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 0 14px 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); font-family: 'Satoshi', sans-serif; display: none; min-width: 280px; }-.editor .drag-bar { height: 28px; cursor: grab; display: flex; align-items: center; justify-content: center; margin: 0 -14px 8px; padding: 0 14px; border-radius: 8px 8px 0 0; flex: none; user-select: none; -webkit-user-select: none; }-.editor .drag-bar:active { cursor: grabbing; }-.editor .drag-bar::after { content: ''; width: 32px; height: 3px; border-radius: 2px; background: var(--border); }-/* The compact task panel has no name FIELD on purpose: the full Edit item modal owns the-   name and one editable name per row is enough. But until 0.40.0 that left it the one-   panel that never said WHICH task it was on, so the only way to be sure was to close it-   and hover the bar. The name goes at the top, read-only, in the same type the tooltip-   and the batch panel already use for the name of a thing.--   One line, ellipsised rather than wrapped, because the panel is a fixed square: a name-   that wrapped to two lines would shorten the dependency list under it by a row and the-   panel would change shape with the length of a name, which is the exact thing the square-   was for. The full name is on the title attribute, so the hover still has it.--   It sits directly under the grab strip and is PART of it: wireEditorDrag takes a-   mousedown here the same way it takes one on the strip, hence the grab cursor. It is a-   div, not an input, and carries no other handler, so a plain click on it does nothing. */-.editor .de-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--accent); margin: -2px 0 10px; flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: grab; user-select: none; -webkit-user-select: none; }-.editor .de-title:active { cursor: grabbing; }-.editor.show { display: block; }-/* The task editor is SQUARE: one side, the same both ways, so the panel reads as one-   deliberate object wherever it has been dragged to instead of a box that changes-   shape with the length of its dependency list. Only that list can grow, so it is the-   only part that scrolls, and the actions are pinned to the bottom edge so Apply is-   in the same place on every task. The other panels on this element (the milestone-   editor, the batch panel, the full item modal, the new-row card) are deliberately-   NOT square: they have different content and some of them are taller than they are-   wide, so forcing the aspect on them would only add a scrollbar. */-.editor.square.show { display: flex; }-.editor.square { width: var(--ed-sq); height: var(--ed-sq); box-sizing: border-box; min-width: 0; flex-direction: column; }-.editor.square .de-dep-section { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }-.editor.square .de-dep-section .dep-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }-.editor.square .de-dep-section > select { flex: none; }-.editor.square .de-actions { flex: none; margin-top: 10px; }-/* ── The compact metrics, 0.47.0 ──-   The square panel and the milestone panel are GLANCED at: two dates, a note, a list of-   links, three buttons. At 370 they were the size of a dialog and sat over a quarter of-   the chart they are editing, which is the one thing the reader needs to keep seeing.-   Everything below is the same panel at desktop density: the type comes down a step,-   every gap comes down about a third, and nothing is removed, so the panel reads as the-   same object shrunk rather than as a different one.--   Scoped to .compact and never to .editor, because the full item modal and the create-   card render into this same element and are NOT compact: they carry a name, an owner, a-   status and a provenance field and are filled in rather than glanced at.--   Floors, deliberately: 24px on every date, number and text input and on every button,-   which is what keeps a date field a target rather than a hairline, and 9.5px on the-   labels, which is the smallest type already in this app (the dropdown's dd-label). */-.editor.compact { padding: 0 10px 8px; min-width: var(--ed-sq); }-.editor.compact .drag-bar { height: 16px; margin: 0 -10px 4px; padding: 0 10px; }-.editor.compact .drag-bar::after { width: 24px; height: 2px; }-.editor.compact .de-title { font-size: 11px; margin: -1px 0 6px; }-.editor.compact label { font-size: 9.5px; line-height: 1.3; margin-bottom: 3px; }-.editor.compact input, .editor.compact textarea, .editor.compact select,-.editor.compact .de-note-row input { font-size: 11px; padding: 3px 6px; border-radius: 5px; min-height: 24px; }-.editor.compact input[type="date"], .editor.compact input[type="number"] { width: 104px; }-.editor.compact .de-row { gap: 7px; margin-bottom: 4px; }-.editor.compact .de-note-row { margin-top: 5px; }-.editor.compact .de-hint { font-size: 9px; }-.editor.compact .de-dep-section { margin-top: 5px; padding-top: 5px; }-.editor.compact .de-actions { margin-top: 5px; gap: 6px; }-.editor.compact .de-btn { padding: 4px 10px; font-size: 11px; border-radius: 5px; min-height: 24px; }-.editor.compact .dep-list { margin-top: 3px; }-.editor.compact .dep-list li { font-size: 10px; padding: 2px 0; }-.editor.compact .dep-list .dep-rm { font-size: 12px; }-.editor.compact .batch-title { font-size: 11px; margin-bottom: 6px; }-.editor.compact .batch-hint { font-size: 10px; margin-bottom: 6px; }-.editor.compact .batch-row { gap: 6px; margin-bottom: 5px; }-.editor.compact .batch-row label { min-width: 54px; }-.editor.compact .batch-row input[type="number"] { width: 58px; }-.editor.compact .batch-row span { font-size: 10px; }-/* The square sets its own margin on this row a few rules up, at the same specificity as-   the compact one, so which of the two wins would be a question of source order. Said-   once here at a specificity neither can reach, so it cannot become one. The pinning-   itself is the flex:none up there and is untouched. */-.editor.square.compact .de-actions { margin-top: 5px; }+/* ── The edit panel: ONE layout for every box that edits a task (0.79.0) ──+   The item editor (a task or a milestone, opened from the chart or from the Table), the+   New panel and the batch panel all render into #editor, and they are all built by one+   function (edShell), so they read as one object and cannot drift apart again:++     the HEAD: the grab strip and the TITLE, and the title is a title: a step larger than+       anything else in the panel, in the heading face. It used to be 13px over 11px+       labels, which at a glance read as one more label;+     the BODY: a six-column grid of fields. A field is full width, a half or a third, its+       label on the first row of its cell and its control on the second (a subgrid), so+       two fields side by side start their controls on one line however their labels+       wrap. On a panel too narrow for two fields side by side (a phone at a large text+       size) every field takes the full width;+     the FOOT: the buttons. They never scroll away and nothing sits on them: when the+       panel is taller than the window the BODY scrolls inside it.++   Every size is a multiple of --fs, the app's own text size (the A 120% A control in the+   header), the way the chart and the Table are, so the panel grows with the app instead+   of staying at 100% beside it.++   WIDTH: 400px at 100%, and never wider than the window less 8px a side. It used to take+   the width of its widest content, and the "waits on" list's longest task name made it+   915px wide on a 1366px window at 120%. HEIGHT: never past the window, and never into+   the band along the bottom edge where the selection chip ("N selected, Copy IDs"), the+   toasts and the save indicator live (--ed-bottom), so none of them can cover a button,+   which the chip once did to Apply on a tall editor. */+.editor {+  --ed-pad: calc(14px * var(--fs));+  --ed-gap: calc(10px * var(--fs));+  --ed-row: calc(8px * var(--fs));+  --ed-ctl-h: calc(28px * var(--fs));+  --ed-bottom: 60px;+  position: fixed; z-index: 99998; display: none; flex-direction: column;+  width: min(calc(400px * var(--fs)), calc(100vw - 16px));+  max-height: calc(100vh - 8px - var(--ed-bottom));+  max-height: calc(100dvh - 8px - var(--ed-bottom));+  background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;+  box-shadow: 0 8px 24px rgba(0,0,0,0.5);+  font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); color: var(--text);+}+.editor.show { display: flex; }+/* While the save-blocked banner is up the toasts step up out of its way (to 74px), so the+   band the panel keeps clear of steps up with them. */+:root[data-save="blocked"] .editor { --ed-bottom: 120px; }+.editor .drag-bar { flex: none; height: calc(14px * var(--fs)); cursor: grab; display: flex; align-items: center; justify-content: center; border-radius: 8px 8px 0 0; user-select: none; -webkit-user-select: none; }+.editor .drag-bar::after { content: ''; width: calc(32px * var(--fs)); height: 3px; border-radius: 2px; background: var(--border); }+/* The head is part of the grab strip: wireEditorDrag takes a press on either, so a panel is+   moved by its title the way a window is. */+.editor .ed-head { flex: none; padding: calc(2px * var(--fs)) var(--ed-pad) calc(8px * var(--fs)); cursor: grab; user-select: none; -webkit-user-select: none; }+.editor .drag-bar:active, .editor .ed-head:active { cursor: grabbing; }+.editor .ed-title { font-family: 'Familjen Grotesk', sans-serif; font-size: calc(16px * var(--fs)); line-height: 1.25; font-weight: 600; color: var(--accent); overflow-wrap: anywhere; }+.editor .ed-sub { margin-top: calc(3px * var(--fs)); font-size: calc(11px * var(--fs)); line-height: 1.4; color: var(--text2); }+.editor .ed-body { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--ed-pad); container-type: inline-size; }+.editor .ed-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); column-gap: var(--ed-gap); row-gap: 0; align-items: start; }+.editor .ed-f { grid-column: 1 / -1; grid-row: span 2; display: grid; grid-template-rows: subgrid; min-width: 0; margin-bottom: var(--ed-row); }+.editor .ed-f.half { grid-column: span 3; }+.editor .ed-f.third { grid-column: span 2; }+.editor .ed-f.twothirds { grid-column: span 4; }+/* A group that behaves differently from the fields above it (the dependency list; the+   batch panel's shifts, and its tags and effort, which act at once rather than on Apply). */+.editor .ed-f.sep { border-top: 1px solid var(--border-muted); padding-top: var(--ed-row); }+.editor .ed-f > label { align-self: end; }+.editor .ed-c { min-width: 0; }+.editor .ed-note { grid-column: 1 / -1; margin: calc(-3px * var(--fs)) 0 var(--ed-row); font-size: calc(10.5px * var(--fs)); line-height: 1.4; color: var(--text2); }+/* 24em of the body's own type is 288px at 100%: under it, two dates side by side would no+   longer fit their fields, so every field takes the whole row. */+@container (max-width: 24em) {+  .editor .ed-f.half, .editor .ed-f.third, .editor .ed-f.twothirds { grid-column: 1 / -1; }+} /* While the panel is being dragged nothing on the page should be selecting text: the    pointer is moving a window, not sweeping a paragraph. Scoped to the drag so the    inputs inside the panel keep normal selection the rest of the time. */ body.ed-dragging { user-select: none; -webkit-user-select: none; }-.editor label { display: block; font-size: 11px; color: var(--text2); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; }-.editor input, .editor textarea, .editor select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; outline: none; }-.editor input[type="date"], .editor input[type="number"] { width: 130px; }-.editor input.eff-in { width: 92px; }-.editor input[type="text"] { width: 100%; }-.editor input:focus, .editor textarea:focus, .editor select:focus { border-color: var(--accent); }-.editor input:focus { border-color: var(--accent); }-.editor .de-row { display: flex; gap: 10px; margin-bottom: 8px; }-.editor .de-note-row { margin-top: 10px; }-.editor .de-note-row input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; }-.editor .de-note-row input:focus { border-color: var(--accent); }-.batch-tags { border-top: 1px solid var(--border-muted); padding-top: 8px; margin: 6px 0 4px; }-.batch-tags .bt-row input[type="text"] { flex: 1 1 90px; min-width: 0; }-.batch-tags .bt-row .de-btn { padding: 2px 9px; font-size: 11px; white-space: nowrap; }-.bt-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }-.bt-chips .sec-tag small { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 9px; }-.bt-none { font-size: 10px; color: var(--text3); margin-top: 4px; }+.editor label { display: block; font-family: 'JetBrains Mono', monospace; font-size: calc(11px * var(--fs)); line-height: 1.35; color: var(--text2); margin-bottom: calc(3px * var(--fs)); }+.editor .de-hint { font-family: 'Satoshi', sans-serif; font-size: calc(10px * var(--fs)); color: var(--text2); }+/* Every control one height, so a row of them lines up, and the width of its cell. */+.editor input, .editor select, .editor textarea { display: block; width: 100%; min-width: 0; height: var(--ed-ctl-h); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: calc(12px * var(--fs)); padding: 0 calc(8px * var(--fs)); outline: none; }+.editor select { font-family: 'Satoshi', sans-serif; }+.editor select:disabled { opacity: 0.7; cursor: not-allowed; }+/* A text that can run long wraps (0.79.0): one line high when it fits, as many as it+   needs when it does not, grown by edGrow as it is typed and when the panel opens. */+.editor textarea { height: auto; min-height: var(--ed-ctl-h); padding: calc(5px * var(--fs)) calc(8px * var(--fs)); line-height: calc(16px * var(--fs)); resize: none; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; }+.editor input:focus, .editor select:focus, .editor textarea:focus { border-color: var(--accent); }+.editor .ed-err { margin-bottom: calc(8px * var(--fs)); color: var(--alarm); font-size: calc(11px * var(--fs)); line-height: 1.4; }+.editor .ed-foot { flex: none; padding: calc(10px * var(--fs)) var(--ed-pad) calc(12px * var(--fs)); border-top: 1px solid var(--border-muted); }+.editor .de-actions { display: flex; flex-wrap: wrap; align-items: center; gap: calc(8px * var(--fs)); }+.editor .de-btn { height: var(--ed-ctl-h); padding: 0 calc(14px * var(--fs)); font-size: calc(12px * var(--fs)); white-space: nowrap; }+/* A field and a button on one line: the batch panel's tag and effort. */+.editor .ed-inline { display: flex; align-items: center; gap: calc(8px * var(--fs)); }+.editor .ed-inline > input { flex: 1 1 auto; width: auto; }+.editor .ed-inline > .de-btn { flex: none; }+.editor .bt-chips { display: flex; flex-wrap: wrap; gap: calc(4px * var(--fs)); margin-top: calc(6px * var(--fs)); }+.editor .bt-chips .sec-tag small { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); }+.editor .bt-none { margin-top: calc(4px * var(--fs)); font-size: calc(10.5px * var(--fs)); line-height: 1.4; color: var(--text3); }+.editor .sec-tag { font-size: calc(11px * var(--fs)); }+.editor .tb-x, .editor .bt-x { font-size: calc(13px * var(--fs)); } .bt-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; } .bt-x:hover { color: #f85149; background: var(--row-hover); } /* A row's tag box (0.60.0): the Setup page's chip editor in a field's clothes. The chips@@ -834,8 +809,10 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .tag-box:focus-within { border-color: var(--accent); } .tag-box .tb-chips { display: contents; } .tag-box .sec-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }-.editor .de-note-row .tag-box input.tb-in, .tag-box input.tb-in { flex: 1 1 80px; min-width: 60px; width: auto; border: none; background: transparent; padding: 2px; outline: none; box-shadow: none; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; min-height: 0; }+.tag-box input.tb-in { flex: 1 1 80px; min-width: 60px; width: auto; border: none; background: transparent; padding: 2px; outline: none; box-shadow: none; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; min-height: 0; } .tag-box input.tb-in::placeholder { color: var(--text3); }+.editor .tag-box { min-height: var(--ed-ctl-h); gap: calc(4px * var(--fs)); padding: calc(2px * var(--fs)) calc(5px * var(--fs)); }+.editor .tag-box input.tb-in { display: block; flex: 1 1 calc(80px * var(--fs)); width: auto; min-width: calc(60px * var(--fs)); height: auto; padding: 2px; font-size: calc(11px * var(--fs)); } .tb-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; } .tb-x:hover { color: #f85149; background: var(--row-hover); } /* In a table cell the box is laid over the cell like every cell editor, and may grow DOWN@@ -851,10 +828,6 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .data-table td.c-tags .tags-trunc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .data-table .row-tag { display: inline-block; font-family: 'Satoshi', sans-serif; font-size: calc(10.5px * var(--fs)); line-height: 1.4; padding: 0 5px; margin-right: 3px; border: 1px solid var(--border); border-radius: 4px; color: var(--text2); vertical-align: middle; } .data-table .row-tag.inh { border-style: dashed; color: var(--text3); }-.editor .de-hint { color: var(--text2); font-size: 10px; font-family: 'Satoshi', sans-serif; }-.editor .de-actions { display: flex; gap: 8px; margin-top: 10px; }-.editor .de-dep-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); }-.editor select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; } .de-btn { border: none; border-radius: 6px; padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; font-weight: 500; cursor: pointer; transition: background 0.15s; } .de-btn.primary { background: var(--accent); color: var(--bg); } .de-btn.primary:hover { background: var(--accent-bright); }@@ -871,21 +844,21 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; }    of what is about to go. */ .label-row.del-armed, .bar-row.del-armed { background: rgba(248,81,73,0.13); box-shadow: inset 2px 0 0 #f85149; } .ms-line.del-armed .ms-cap { border-color: #f85149; color: #f85149; }-.dep-list { list-style: none; margin: 4px 0 0; }-.dep-list li { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text2); padding: 3px 0; }-.dep-list .dep-rm { color: var(--text2); cursor: pointer; font-size: 13px; padding: 0 4px; border-radius: 4px; transition: color 0.15s; }-.dep-list .dep-rm:hover { color: #f85149; }-.dep-list .dep-dir { font-family: 'JetBrains Mono', monospace; font-size: 9px; margin-right: 6px; min-width: 48px; flex-shrink: 0; color: var(--text2); }-.dep-list .dep-dir.in { color: var(--accent); }-.dep-list .dep-nm { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-.dep-list .dep-none { color: var(--text3); font-size: 11px; }-.editor #imDepsWrap .dep-list { max-height: 132px; overflow-y: auto; }-.batch-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--accent); margin-bottom: 10px; }-.batch-hint { font-size: 11px; color: var(--text2); margin-bottom: 8px; }-.batch-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }-.batch-row label { margin: 0; min-width: 60px; }-.batch-row input[type="number"] { width: 70px; }-.batch-row span { font-size: 11px; color: var(--text2); }+/* The dependency list in the item editor: what this row waits on (needs) and what waits+   on it (blocks). A long name wraps rather than being cut, and a long list is part of the+   body, which scrolls, rather than a second scroller inside it. */+.editor .dep-list { list-style: none; }+.editor .dep-list:not(:empty) { margin-bottom: calc(6px * var(--fs)); }+.editor .dep-list li { display: flex; align-items: baseline; gap: calc(6px * var(--fs)); padding: calc(2px * var(--fs)) 0; font-size: calc(11px * var(--fs)); line-height: 1.4; color: var(--text2); }+.editor .dep-list .dep-dir { flex: none; min-width: calc(54px * var(--fs)); font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); color: var(--text2); }+.editor .dep-list .dep-dir.in { color: var(--accent); }+.editor .dep-list .dep-nm { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }+.editor .dep-list .dep-rm { flex: none; align-self: flex-start; font-size: calc(14px * var(--fs)); line-height: 1.2; padding: 0 calc(4px * var(--fs)); border-radius: 4px; color: var(--text2); cursor: pointer; transition: color 0.15s; }+.editor .dep-list .dep-rm:hover, .editor .dep-list .dep-rm:focus-visible { color: #f85149; }+.editor .dep-list .dep-none { color: var(--text3); }+/* A linked provenance is the link, not a field (0.71.0). It wraps like the fields do. */+.editor .prov-fixed { display: inline-block; max-width: 100%; vertical-align: top; white-space: normal; overflow-wrap: anywhere; font-family: 'JetBrains Mono', monospace; font-size: calc(11px * var(--fs)); line-height: 1.5; padding: calc(3px * var(--fs)) calc(10px * var(--fs)); border-radius: calc(12px * var(--fs)); border: 1px solid rgba(0,184,177,0.35); color: var(--accent); text-decoration: none; }+.editor .prov-fixed:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; }  /* ── Dropdown menu ── */ .dropdown { position: fixed; z-index: 99997; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; min-width: 180px; }@@ -914,6 +887,26 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .data-warn .dw-close { position: absolute; top: 6px; right: 10px; background: none; border: none; color: var(--text2); font-size: 14px; cursor: pointer; line-height: 1; } .data-warn .dw-close:hover { color: var(--text); } @media print { .data-warn { display: none !important; } }+/* What a ClickUp import changed and what it could not bring across. Same place and the+   same make as the undated-rows card; taller, so its body scrolls under a fixed head. */+.import-report { display: none; flex-direction: column; position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); width: min(640px, calc(100vw - 32px)); max-height: min(70vh, 560px); box-sizing: border-box; background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.45; color: var(--text-body); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99999; overflow: hidden; }+.import-report.show { display: flex; }+.import-report .ir-head { display: flex; align-items: center; gap: 8px; padding: 10px 10px 4px 14px; flex: 0 0 auto; }+.import-report .ir-title { flex: 1 1 auto; font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--text); }+.import-report .ir-close { flex: 0 0 auto; background: none; border: none; color: var(--text2); font-size: 16px; cursor: pointer; line-height: 1; padding: 2px 4px; }+.import-report .ir-close:hover { color: var(--text); }+.import-report .ir-body { overflow-y: auto; padding: 0 14px 12px 14px; min-height: 0; }+.import-report h4 { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text2); margin: 10px 0 3px; }+.import-report ul { margin: 0; padding-left: 16px; }+.import-report li { margin: 2px 0; overflow-wrap: anywhere; }+.import-report .ir-dim { color: var(--text2); }+.import-report .ir-cu { font-family: 'JetBrains Mono', monospace; font-size: 11px; }+/* The importer has not been compared with a real ClickUp project yet: said once, quietly. */+.import-report .ir-note { margin-top: 6px; color: var(--text2); font-style: italic; }+/* The status mapping is short entries, so they wrap side by side rather than a line each. */+.import-report ul.ir-st { display: flex; flex-wrap: wrap; gap: 2px 18px; list-style: none; padding-left: 0; }+.import-report ul.ir-st li { margin: 0; }+@media print { .import-report { display: none !important; } } /* Was bare 10px --text2 text on the page ground, which is most of why these went    unnoticed. It is a pill now, with the same teal left edge as the toast. */ .save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42), 0 0 0 1px rgba(0,184,177,0.14); max-width: 44vw; z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; }@@ -946,7 +939,8 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; }    thing on the screen for as long as it is true. */ :root[data-save="blocked"] .undo-toast, :root[data-save="blocked"] .save-indicator,-:root[data-save="blocked"] .data-warn { bottom: 74px; }+:root[data-save="blocked"] .data-warn,+:root[data-save="blocked"] .import-report { bottom: 74px; } /* The version is chrome: present for orientation, never competing with the plan. */ header .brand .app-ver { color: var(--text3); } .sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42); z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; cursor: pointer; }@@ -1188,8 +1182,6 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 /* A LINKED provenance is fixed (0.71.0): the link opens its source and its text is never    retyped, in the table or in the item editor. A plain one stays editable. */ .data-table td.c-prov.prov-locked { cursor: default; }-.de-note-row .prov-fixed { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.6; padding: 2px 9px; border-radius: 999px; border: 1px solid rgba(0,184,177,0.35); color: var(--accent); text-decoration: none; }-.de-note-row .prov-fixed:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; } /* Delete: quiet until you are on the row, destructive-red only on the button    itself, and never wider than the glyph so it cannot push the other columns. */ .data-table th.th-del { width: 30px; }@@ -1458,6 +1450,66 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .lane-toggle.collapsed svg { transform: rotate(-90deg); } .lane-toggle .lane-count { color: var(--text3); } .lane-toggle.collapsed .lane-count { color: var(--accent); }+/* ── Subtasks (0.79.0, issue #5) ──+   A subtask is indented by its depth. Once a plan has any, every row keeps a slot for the+   chevron that folds a task's subtasks, so names line up; a plan without them looks as it did. */+.label-row.is-sub { padding-left: calc(12px + var(--depth, 0) * 18px * var(--fs)); }+.sub-tog { display: none; flex: none; align-items: center; justify-content: center; width: 16px; height: 16px; margin: 0 3px 0 -5px; padding: 0; background: none; border: none; border-radius: 3px; color: var(--text3); cursor: pointer; visibility: hidden; }+#labels.has-nest .label-row:not(.group) .sub-tog { display: inline-flex; }+.label-row.has-kids .sub-tog { visibility: visible; }+.sub-tog:hover { background: var(--row-hover); color: var(--text); }+.sub-tog svg { width: 11px; height: 11px; transition: transform 0.15s; }+.label-row.kids-folded .sub-tog { color: var(--accent); }+.label-row.kids-folded .sub-tog svg { transform: rotate(-90deg); }+.sub-count { display: inline-flex; align-items: center; gap: 2px; flex: none; margin: 0 2px 0 6px; font-family: 'JetBrains Mono', monospace; font-size: calc(9.5px * var(--fs)); color: var(--text3); }+.sub-count svg { width: 10px; height: 10px; }+.label-row.ctx-dim, .bar-row.ctx-dim, .data-table tr.ctx-dim td { opacity: 0.42; }+/* A folded task's subtasks, earliest start to latest end, under its own bar. */+.sub-span { position: absolute; bottom: 2px; height: 5px; border: 1.5px solid var(--text2); border-top: none; border-radius: 0 0 3px 3px; opacity: 0.75; pointer-events: none; }+/* Quick add (0.79.0, issue #6): a + beside a section's chevron, and on a task for a subtask. */+.lane-add, .row-add { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text2); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.12s, color 0.12s, border-color 0.12s; flex: none; }+.lane-add svg, .row-add svg { width: 12px; height: 12px; }+.label-row.group .lane-add { margin-right: 2px; }+.label-row.group:hover .lane-add, .lane-add:focus-visible { opacity: 1; pointer-events: auto; }+.label-row .row-add { position: absolute; right: 52px; top: 50%; transform: translateY(-50%); z-index: 12; }+.label-row:hover .row-add, .row-add:focus-visible { opacity: 1; pointer-events: auto; }+.label-row:hover .peer-chips { right: 76px; }+.label-row.depth-max .row-add { display: none; }+/* A drag over the middle of a task: the rows go under it. */+.label-row.nest-target, .bar-row.nest-target { box-shadow: inset 0 0 0 2px var(--accent); background: color-mix(in srgb, var(--accent) 9%, transparent); }+.data-table tr.nest-target td { background: color-mix(in srgb, var(--accent) 12%, transparent); box-shadow: inset 0 2px 0 var(--accent), inset 0 -2px 0 var(--accent); }+.lane-add:hover, .row-add:hover { color: var(--accent); border-color: var(--accent); }+/* The blank row a quick add opens: as tall as a row, in both panes, so the chart stays aligned. */+.qa-row { height: var(--row-h); display: flex; align-items: center; gap: 6px; padding: 0 12px 0 calc(12px + var(--depth, 0) * 18px * var(--fs)); border-bottom: 1px solid rgba(48,54,61,0.15); background: color-mix(in srgb, var(--accent) 6%, transparent); }+.qa-bar { height: var(--row-h); border-bottom: 1px solid rgba(48,54,61,0.15); background: color-mix(in srgb, var(--accent) 4%, transparent); }+.qa-plus { display: inline-flex; color: var(--accent); flex: none; }+.qa-plus svg { width: 12px; height: 12px; }+.qa-in { flex: 1; min-width: 0; height: calc(var(--row-h) - 10px); background: var(--bg); color: var(--text); border: 1px solid var(--accent); border-radius: 4px; padding: 0 8px; font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); outline: none; }+.qa-in::placeholder { color: var(--text3); }+.data-table tr.qa-line td { padding: 0 8px; height: 22px; border-bottom: none; }+.data-table .qa-add { background: none; border: none; color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: calc(11px * var(--fs)); cursor: pointer; padding: 2px 6px; border-radius: 4px; }+.data-table .qa-add:hover { color: var(--accent); background: var(--row-hover); }+.data-table tr.qa-entry td { padding: 3px 8px; }+.data-table tr.qa-entry .qa-in { width: 100%; max-width: 520px; height: 24px; }+/* The Table's tree, in plan order: the chevron, the indent, the + for a subtask on hover. */+.data-table td.c-name { position: relative; }+.data-table .t-ind { display: inline-block; width: calc(var(--depth, 0) * 16px * var(--fs)); flex: none; }+.data-table .t-name { display: flex; align-items: center; min-width: 0; }+.data-table .sub-tog { display: inline-flex; visibility: visible; margin: 0 3px 0 0; }+.data-table .sub-tog.sub-none { visibility: hidden; }+.data-table tr.kids-folded .sub-tog svg { transform: rotate(-90deg); }+.data-table tr.kids-folded .sub-tog { color: var(--accent); }+.data-table .row-add { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); }+.data-table tr:hover .row-add, .data-table .row-add:focus-visible { opacity: 1; pointer-events: auto; }+/* The Section cell's + adds a task to that section, from any row, sorted or not. */+.data-table td.c-phase { position: relative; }+.data-table .sec-add { display: flex; align-items: center; justify-content: center; position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text2); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.12s; }+.data-table .sec-add svg { width: 11px; height: 11px; }+.data-table tr:hover .sec-add, .data-table .sec-add:focus-visible { opacity: 1; pointer-events: auto; }+.data-table .sec-add:hover { color: var(--accent); border-color: var(--accent); }+.data-table .sub-of { color: var(--text3); margin-right: 4px; }+.data-table .eff-roll { color: var(--text3); }+ /* Editing a name straight in the label column. Fixed to the row's own box so the    name column never resizes, same rule the table cells follow. */ .label-edit { position: absolute; inset: 3px 6px; width: auto; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); padding: 0 6px; outline: none; z-index: 12; box-sizing: border-box; }@@ -1566,10 +1618,13 @@ body.col-sizing { cursor: col-resize; user-select: none; }    Tab to close itself, so leaving a dropdown cost a keypress that leaving a text    field did not. Fixed position and appended to the body, because the cells clip    their own overflow under the fixed table layout. */-.cell-list { position: fixed; z-index: 100001; min-width: 130px; max-height: 232px; overflow-y: auto;+.cell-list { position: fixed; z-index: 100001; min-width: 130px; max-height: min(calc(232px * var(--fs)), 60vh); overflow-y: auto;   background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: 4px;-  box-shadow: 0 8px 22px rgba(0,0,0,0.34); font-family: 'Satoshi', sans-serif; font-size: 12px; }-.cell-list .cl-opt { padding: 5px 9px; border-radius: 5px; color: var(--text-body); cursor: pointer; white-space: nowrap; }+  box-shadow: 0 8px 22px rgba(0,0,0,0.34); font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs));+  max-width: calc(100vw - 12px); }+/* The type follows the app's text size like the fields the list belongs to, and a long+   entry (a task name in "waits on") wraps inside the window rather than running past it. */+.cell-list .cl-opt { padding: calc(5px * var(--fs)) calc(9px * var(--fs)); border-radius: 5px; color: var(--text-body); cursor: pointer; white-space: normal; overflow-wrap: anywhere; } .cell-list .cl-opt:hover, .cell-list .cl-opt.active { background: var(--surface2); color: var(--text); } .cell-list .cl-opt.active { box-shadow: inset 2px 0 0 var(--accent); } /* The owner pill's field (0.66.1): THE owner picker, laid over the pill it edits. */@@ -1860,6 +1915,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="undo-toast" id="undoToast">Undid change &middot; <kbd>Ctrl+Z</kbd></div> <div class="conflict-bar" id="conflictBar" role="alert" aria-live="assertive"></div> <div class="data-warn" id="dataWarn"></div>+<div class="import-report" id="importReport" role="dialog" aria-label="What the import changed"></div> <div class="save-indicator" id="saveInd">saved</div> <div class="sel-count" id="selCount"></div> <input type="file" id="importFile" accept=".json,.csv,.xml">@@ -1887,6 +1943,11 @@ const importFileEl = $('importFile'); // ── Load data ── const projData = await fetch('data').then(r => r.json()); const { project, phases, items } = projData;+// A data file may name a row's parent by task id; the page holds uids, as the server's merge+// does (0.79.0). Before baseRowOf, so a catch-up rebuilds the same value.+{ const uidSet=new Set(), byId=new Map();+  items.forEach(r=>{ if(r&&r.uid)uidSet.add(r.uid); if(r&&r.id&&r.uid&&!byId.has(r.id))byId.set(r.id,r.uid); });+  items.forEach(r=>{ if(r&&typeof r.parent==='string'&&r.parent&&!uidSet.has(r.parent)&&byId.has(r.parent))r.parent=byId.get(r.parent); }); } // Every data-file row as the file has it, before the sidecar touches it (0.77.1): a catch-up // rebuilds each row as the server has it from this and the server's overrides. const baseRowOf = new Map();@@ -1929,6 +1990,168 @@ function tidyTags(o){ } phases.forEach(tidyTags); items.forEach(tidyTags);+// ── Subtasks (0.79.0): a row's parent, and its place among its siblings ──+//+// Two fields on a row. `parent` is the uid of the task it sits under ('' or absent: top level+// in its section). `pos` is its place among its siblings: a fractional index key, a string+// that sorts between its two neighbours' keys. A move, a nest or an un-nest is then a change+// to ONE row's own fields, the last writer wins them, and there is no shared list for two+// tabs to disagree about. This is how Figma's multiplayer keeps its layer tree (a parent link+// and a position on each child), and ClickUp keeps an order index on each task. The flat+// `order` is derived from these and still written, for pages and servers that predate them.+//+// The keys are David Greenspan's fractional indexing (public domain, as published in the+// `fractional-indexing` package): base 62, an integer part whose first character says how+// long it is, then a fraction with no trailing zero. "a0" is the first key; appending at the+// end only increments the integer part, so keys stay short. Kept line for line with the+// page's copy, like normalizeTags.+const POS_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';+const POS_MAX = 200;+// ClickUp's default for nested subtasks: a top-level task, then up to three levels under it.+const TREE_DEPTH_MAX = 3;+function fiMidpoint(a, b) {+  const D = POS_DIGITS, zero = D[0];+  if (b !== null && a >= b) throw new Error(a + ' >= ' + b);+  if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) throw new Error('trailing zero');+  if (b) {+    let n = 0;+    while ((a[n] || zero) === b[n]) n++;+    if (n > 0) return b.slice(0, n) + fiMidpoint(a.slice(n), b.slice(n));+  }+  const da = a ? D.indexOf(a[0]) : 0, db = b !== null ? D.indexOf(b[0]) : D.length;+  if (db - da > 1) return D[Math.round(0.5 * (da + db))];+  if (b && b.length > 1) return b.slice(0, 1);+  return D[da] + fiMidpoint(a.slice(1), null);+}+function fiIntLen(h) {+  if (h >= 'a' && h <= 'z') return h.charCodeAt(0) - 97 + 2;+  if (h >= 'A' && h <= 'Z') return 90 - h.charCodeAt(0) + 2;+  throw new Error('invalid key head ' + h);+}+function fiIntPart(k) { const n = fiIntLen(k[0]); if (n > k.length) throw new Error('invalid key ' + k); return k.slice(0, n); }+function fiCheck(k) {+  if (k === 'A' + POS_DIGITS[0].repeat(26)) throw new Error('invalid key ' + k);+  const i = fiIntPart(k);+  if (k.slice(i.length).slice(-1) === POS_DIGITS[0]) throw new Error('invalid key ' + k);+}+function fiStep(x, up) {+  const D = POS_DIGITS;+  if (x.length !== fiIntLen(x[0])) throw new Error('invalid integer ' + x);+  const head = x[0], digs = x.slice(1).split('');+  let carry = true;+  for (let i = digs.length - 1; carry && i >= 0; i--) {+    const d = D.indexOf(digs[i]) + (up ? 1 : -1);+    if (up ? d === D.length : d === -1) digs[i] = up ? D[0] : D.slice(-1);+    else { digs[i] = D[d]; carry = false; }+  }+  if (!carry) return head + digs.join('');+  if (up) {+    if (head === 'Z') return 'a' + D[0];+    if (head === 'z') return null;+    const h = String.fromCharCode(head.charCodeAt(0) + 1);+    if (h > 'a') digs.push(D[0]); else digs.pop();+    return h + digs.join('');+  }+  if (head === 'a') return 'Z' + D.slice(-1);+  if (head === 'A') return null;+  const h = String.fromCharCode(head.charCodeAt(0) - 1);+  if (h < 'Z') digs.push(D.slice(-1)); else digs.pop();+  return h + digs.join('');+}+function fiBetween(a, b) {+  if (a !== null) fiCheck(a);+  if (b !== null) fiCheck(b);+  if (a !== null && b !== null && a >= b) throw new Error(a + ' >= ' + b);+  if (a === null) {+    if (b === null) return 'a' + POS_DIGITS[0];+    const ib = fiIntPart(b), fb = b.slice(ib.length);+    if (ib === 'A' + POS_DIGITS[0].repeat(26)) return ib + fiMidpoint('', fb);+    if (ib < b) return ib;+    const res = fiStep(ib, false);+    if (res === null) throw new Error('cannot decrement any more');+    return res;+  }+  if (b === null) {+    const ia = fiIntPart(a), fa = a.slice(ia.length), i = fiStep(ia, true);+    return i === null ? ia + fiMidpoint(fa, null) : i;+  }+  const ia = fiIntPart(a), fa = a.slice(ia.length), ib = fiIntPart(b), fb = b.slice(ib.length);+  if (ia === ib) return ia + fiMidpoint(fa, fb);+  const i = fiStep(ia, true);+  if (i === null) throw new Error('cannot increment any more');+  return i < b ? i : ia + fiMidpoint(fa, null);+}+function posOk(k) {+  if (typeof k !== 'string' || !k || k.length > POS_MAX || !/^[A-Za-z][0-9A-Za-z]*$/.test(k)) return false;+  try { fiCheck(k); return true; } catch (e) { return false; }+}+// A key between two others, either of which may be missing (the start, the end) or not a+// key at all. Two neighbours in the wrong order (two tabs gave rows the same place a moment+// ago, and the server has not separated them yet) give a key just after the first.+function keyBetween(a, b) {+  a = posOk(a) ? a : null; b = posOk(b) ? b : null;+  if (a !== null && b !== null && a >= b) b = null;+  try { return fiBetween(a, b); } catch (e) { try { return fiBetween(a, null); } catch (e2) { return fiBetween(null, null); } }+}+// n keys in order between two others, spread so none grows longer than it has to.+function keysBetween(a, b, n) {+  a = posOk(a) ? a : null; b = posOk(b) ? b : null;+  if (a !== null && b !== null && a >= b) b = null;+  if (n <= 0) return [];+  if (n === 1) return [keyBetween(a, b)];+  const out = [];+  if (b === null) { let c = a; for (let i = 0; i < n; i++) { c = keyBetween(c, null); out.push(c); } return out; }+  if (a === null) { let c = b; for (let i = 0; i < n; i++) { c = keyBetween(null, c); out.push(c); } return out.reverse(); }+  const mid = Math.floor(n / 2), c = keyBetween(a, b);+  return keysBetween(a, c, mid).concat([c], keysBetween(c, b, n - mid - 1));+}+// Siblings in their order: by key, a row with no key after every row with one (in the order+// it was given), and two equal keys by uid, so every tab and the server agree.+function sibCmp(a, b) {+  const x = posOk(a.pos) ? a.pos : '', y = posOk(b.pos) ? b.pos : '';+  if (x && y && x !== y) return x < y ? -1 : 1;+  if (x && !y) return -1;+  if (y && !x) return 1;+  if (!x && !y && a._i !== b._i) return a._i - b._i;+  return a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0;+}+// The tree, from the rows' own fields. `rows`: {uid, type, phase, parent, pos}, in the order to+// fall back on for rows with no key. A parent that is missing, a milestone, the row itself or+// part of a loop is ignored (a loop is cut at its smallest uid), so any input gives a tree.+// Returns the flat order (sections in list order; each top-level row followed by its subtree,+// depth first), each row's effective parent, depth and section (its top-level ancestor's).+function treeOf(rows, nSections) {+  const by = new Map(), list = [];+  rows.forEach((r, i) => { if (r && r.uid && !by.has(r.uid)) { const o = { uid: r.uid, type: r.type, phase: r.phase, parent: r.parent, pos: r.pos, _i: i }; by.set(r.uid, o); list.push(o); } });+  const par = new Map();+  list.forEach(o => { const p = o.parent; par.set(o.uid, typeof p === 'string' && p && p !== o.uid && by.has(p) && by.get(p).type !== 'milestone' ? p : ''); });+  const seen = new Map();+  list.map(o => o.uid).sort().forEach(u => {+    if (seen.get(u) === 2) return;+    const path = [];+    let x = u;+    while (x && seen.get(x) !== 2) {+      if (seen.get(x) === 1) { const loop = path.slice(path.indexOf(x)); par.set(loop.slice().sort()[0], ''); break; }+      seen.set(x, 1); path.push(x); x = par.get(x);+    }+    path.forEach(y => seen.set(y, 2));+  });+  const kids = new Map(), tops = [];+  list.forEach(o => { const p = par.get(o.uid); if (p) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(o); } else tops.push(o); });+  kids.forEach(l => l.sort(sibCmp));+  const depth = new Map(), phaseOf = new Map(), order = [];+  const walk = (o, d, ph) => { depth.set(o.uid, d); phaseOf.set(o.uid, ph); order.push(o.uid); (kids.get(o.uid) || []).forEach(k => walk(k, d + 1, ph)); };+  const buckets = Array.from({ length: Math.max(0, nSections) }, () => []), rest = [];+  tops.forEach(o => (Number.isInteger(o.phase) && o.phase >= 0 && o.phase < buckets.length ? buckets[o.phase] : rest).push(o));+  buckets.forEach(b => b.sort(sibCmp).forEach(o => walk(o, 0, o.phase)));+  rest.sort(sibCmp).forEach(o => walk(o, 0, o.phase));+  const kidsOut = new Map(); kids.forEach((l, p) => kidsOut.set(p, l.map(o => o.uid)));+  return { order, parentOf: par, depth, phaseOf, kids: kidsOut };+}+// How many levels sit under a row (0 for a row with no subtasks).+function treeHeight(t, u) { let h = 0; (t.kids.get(u) || []).forEach(k => { h = Math.max(h, 1 + treeHeight(t, k)); }); return h; }+// Is `u` inside the subtree of `anc` (anc itself excluded)?+function treeUnder(t, u, anc) { let x = t.parentOf.get(u), n = 0; while (x && n++ < 10000) { if (x === anc) return true; x = t.parentOf.get(x); } return false; } // Effort (0.63.0): a task's estimated work in HOURS, the server's normalizeEffort line for // line. A bar is the window the work happens in, not what it costs: through 0.62 Analytics // counted every bar as one person full time for each working day of it, and a plan of 154@@ -2403,6 +2626,13 @@ function isCollapsed(ph){ return collapsed.has(ph); } function laneRows(ph){ return items.filter(r=>r.type!=='group'&&r.phase===ph); } function applyCollapse(){   const ks=tagFilterKeys();+  // Subtasks (0.79.0). A folded task hides everything under it. Under a filter, a task that+  // does not match but has a subtask that does stays on screen, dimmed, so the subtask is not+  // shown out of context; it is not counted (ClickUp's assignee filter brings the parent up+  // the same way).+  const foldedBy=new Set(), below=new Set(), vf=viewFilterOn();+  if(collapsedRows.size)tree.order.forEach(k=>{ const p=tree.parentOf.get(k); if(p&&(collapsedRows.has(p)||foldedBy.has(p)))foldedBy.add(k); });+  if(vf)for(let i=tree.order.length-1;i>=0;i--){ const k=tree.order[i], x=treeRows.get(k), p=tree.parentOf.get(k); if(x&&p&&(below.has(k)||rowShown(x,ks)))below.add(p); }   items.forEach(r=>{     if(r.type==='group'){       // A lane the tag filter leaves out goes whole, header included, through the same@@ -2425,8 +2655,11 @@ function applyCollapse(){       }       return;     }-    const hide=isCollapsed(r.phase)||!rowShown(r,ks);-    r._collapsed=hide;+    const k=rowTreeKey(r), shown=rowShown(r,ks), ctx=!shown&&below.has(k);+    const hide=isCollapsed(r.phase)||foldedBy.has(k)||(!shown&&!ctx);+    r._collapsed=hide; r._ctx=ctx;+    if(r._lDiv)r._lDiv.classList.toggle('ctx-dim',ctx);+    if(r._bDiv)r._bDiv.classList.toggle('ctx-dim',ctx);     const od=isOverdue(r);     if(r._lDiv)r._lDiv.classList.toggle('is-overdue',od);     if(r._bDiv)r._bDiv.classList.toggle('is-overdue',od);@@ -2436,6 +2669,7 @@ function applyCollapse(){     if(r._msLine)r._msLine.classList.toggle('row-collapsed',hide);   });   syncCollapseAllBtn();+  paintSpans(); } // ── Collapse or expand every lane at once (0.57.0) ── //@@ -2472,7 +2706,11 @@ function setAllCollapsed(on){   // showed them.   if(viewFilterOn())phases.forEach((p,i)=>{ if(phaseShown(i)){ if(on)collapsed.add(i); else collapsed.delete(i); } });   else { collapsed.clear(); if(on)phases.forEach((p,i)=>collapsed.add(i)); }-  prefs.collapsed=[...collapsed]; savePrefs();+  prefs.collapsed=[...collapsed];+  // Tasks with subtasks fold and unfold with them (0.79.0), so Expand all shows every row.+  collapsedRows.clear(); if(on)items.forEach(r=>{ if(!isDerivedGroup(r)&&treeHasKids(r))collapsedRows.add(rowTreeKey(r)); });+  saveFolds();+  refreshTreeDom();   applyCollapse();   layoutMsCaps();sizeTailroom();drawDeps();   keepPlace(mark);@@ -2495,8 +2733,72 @@ function setCollapsed(ph,on,persist){   layoutMsCaps();sizeTailroom();drawDeps();   if(prefs.view==='gantt')updatePinnedHeader(); }+// Fold or unfold one task's subtasks (0.79.0), holding the task where it is drawn, the way a+// section's chevron does.+function toggleRowFold(r){+  if(Math.abs(labelsEl.scrollTop-timelineEl.scrollTop)>0.5)timelineEl.scrollTop=labelsEl.scrollTop;+  const y=prefs.view==='gantt'&&r._lDiv?r._lDiv.getBoundingClientRect().top:null;+  setRowFolded(r,!collapsedRows.has(rowTreeKey(r)));+  refreshTreeDom(); applyCollapse(); layoutMsCaps(); sizeTailroom(); drawDeps();+  if(prefs.view==='gantt')updatePinnedHeader();+  if(y!==null)keepPlace({r,i:items.indexOf(r),y:y-timelineEl.getBoundingClientRect().top});+  if(prefs.view==='table')renderTable();+  depHoverResync();+}+// Alt+Right puts a row under the row just above it among its siblings, as that task's last+// subtask; Alt+Left takes it out, to straight after the task it was under (0.79.0). ClickUp's+// Tab and Shift+Tab; here Tab walks fields, so it is Alt with the arrows, beside Alt+Up and+// Alt+Down. Returns why it could not, or ''.+function indentPlace(r,dir){+  if(!r||isDerivedGroup(r))return 'no row';+  if(dir>0){+    const p=sibNext(r,-1);+    if(!p)return 'nothing above it to go under';+    if(p.type==='milestone')return 'a milestone cannot hold subtasks';+    if(!canNestUnder([r],p))return 'subtasks go '+TREE_DEPTH_MAX+' levels deep at most';+    placeRowsAt([r],p,p.phase,lastKid(p,new Set([r])));+    if(collapsedRows.has(rowTreeKey(p)))setRowFolded(p,false);+    return '';+  }+  const pk=treeParent(r); if(!pk)return 'already a top-level row';+  const p=treeRows.get(pk), gp=p&&treeParent(p)?treeRows.get(treeParent(p)):null;+  if(!p)return 'no row';+  placeRowsAt([r],gp,p.phase,p);+  return '';+}+function indentRow(r,dir){ return indentRows([r],dir); }+function indentRows(rows,dir){+  const snap=placeSnap(rows);+  // Into the row above: top to bottom, so each lands after the one before it. Out: bottom to+  // top, so each lands straight after the parent and above the ones that went before it.+  const seq=dir>0?rows.slice():rows.slice().reverse();+  let why='', done=0;+  for(const r of seq){ const w=indentPlace(r,dir); if(w){ why=why||w; continue; } done++; placeHeaders(); }+  if(!done){ flash(saveInd,why,3200); return false; }+  pushUndo(snap);+  layoutRows(); renderTable();+  if(prefs.view==='gantt')refreshSelVisuals();+  moveHint=rows.length===1?'"'+(rows[0].name||'a row')+'"':rows.length+' rows';+  scheduleSave(dir>0?'indent':'outdent');+  return true;+}+function indentFocusedRow(dir){+  const table=prefs.view==='table';+  if(table&&!manualOrder()){ flash(saveInd,'sorted, so rows cannot be nested here. Back to plan order to nest them',3200); return; }+  const rows=selected.size>1?treeRoots(selectedRows()):[table?rowItem(focusedKey):soleSelectedRow()].filter(Boolean);+  if(!rows.length){ flash(saveInd,table?'click a cell first, then Alt+Right and Alt+Left nest that row':'select a task first'); return; }+  indentRows(rows,dir);+  if(table&&rows.length===1)setFocusedRow(rowKey(rows[0]));+} function wireLaneToggle(){ labelsEl.addEventListener('click',e=>{+  // Subtasks (0.79.0): the chevron that folds a task's subtasks, and the two quick adds.+  const tog=e.target.closest('button.sub-tog');+  if(tog){ e.stopPropagation(); const r=items.find(x=>x._lDiv===tog.closest('.label-row')); if(r&&treeHasKids(r))toggleRowFold(r); return; }+  const ra=e.target.closest('.row-add');+  if(ra){ e.stopPropagation(); const r=items.find(x=>x._lDiv===ra.closest('.label-row')); if(r)openQuickAdd({parent:r}); return; }+  const la=e.target.closest('.lane-add');+  if(la){ e.stopPropagation(); const g=items.find(x=>x._lDiv===la.closest('.label-row')); if(g)openQuickAdd({phase:g.phase}); return; }   const btn=e.target.closest('.lane-toggle');   if(!btn)return;   e.stopPropagation();@@ -3057,7 +3359,7 @@ $('hdr').innerHTML = `     <button class="act-btn" id="btnMsStyle" title="Milestone style, cycled: dashed lines with tags, diamonds on their own row, or both at once">       <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button>     <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>-    <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML or Jira CSV"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button>+    <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML, Jira CSV or ClickUp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button>     <button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button>     <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>     <button class="new-btn" id="btnNew" title="New task &middot; Shift+N">@@ -3226,24 +3528,118 @@ function buildDerivedHeaders(){     if(h._bDiv&&h._bDiv.parentNode)h._bDiv.remove();   }}); }-// Put each header immediately above the first row of its lane, and an empty lane's-// header after the previous lane's block, so the chart reads as one section after-// another whatever order the rows themselves are in.+// ── The row tree (0.79.0) ──+//+// Derived from the rows' own fields whenever the rows are laid out: the parent links the tree+// can hold (treeOf drops a milestone parent, a loop, a parent that is not in the plan), each+// row's depth, every task's subtasks in order, and a subtask's section, which is always its+// top-level ancestor's. A subtask's own `phase` is written from it here, so everything that+// groups rows by section (the headers, the legend, the filters, the colours) needs nothing new.+let tree={order:[],parentOf:new Map(),depth:new Map(),phaseOf:new Map(),kids:new Map()}, treeRows=new Map();+function rowTreeKey(r){ return r.uid||rowOrderKey(r); }+function syncTree(){+  const rows=items.filter(r=>r&&r.type!=='group');+  treeRows=new Map(rows.map(r=>[rowTreeKey(r),r]));+  tree=treeOf(rows.map(r=>({uid:rowTreeKey(r),type:r.type,phase:r.phase,parent:r.parent,pos:r.pos})),phases.length);+  rows.forEach(r=>{ const k=rowTreeKey(r);+    if(tree.parentOf.get(k)){ const ph=tree.phaseOf.get(k); if(Number.isInteger(ph)&&ph>=0&&ph<phases.length&&r.phase!==ph)r.phase=ph; } });+  return tree;+}+const treeParent=r=>r?tree.parentOf.get(rowTreeKey(r))||'':'';+const treeDepth=r=>r?tree.depth.get(rowTreeKey(r))||0:0;+const treeKids=r=>r?(tree.kids.get(rowTreeKey(r))||[]).map(k=>treeRows.get(k)).filter(Boolean):[];+const treeHasKids=r=>!!r&&(tree.kids.get(rowTreeKey(r))||[]).length>0;+// A task can take a subtask while its own subtree leaves room under the depth limit.+const canHoldKids=r=>!!r&&r.type!=='milestone'&&!isDerivedGroup(r)&&treeDepth(r)<TREE_DEPTH_MAX;+// Every row under `r`, depth first, in plan order.+function treeDesc(r){ const out=[]; const walk=x=>treeKids(x).forEach(k=>{ out.push(k); walk(k); }); walk(r); return out; }+function treeUnderRow(r,anc){ return !!r&&!!anc&&treeUnder(tree,rowTreeKey(r),rowTreeKey(anc)); }+// Each header above its section's rows, an empty section's header after the section before+// it, and the rows in tree order: every top-level row by its key, its subtasks under it.+// A row with no key keeps the order it had (rows are listed to treeOf in their current order). function placeHeaders(){   const hdrs=items.filter(isDerivedGroup);   const rest=items.filter(r=>!isDerivedGroup(r));-  const hdrOf=i=>hdrs.find(h=>h.phase===i);+  syncTree();+  const byKey=new Map(); rest.forEach(r=>byKey.set(rowTreeKey(r),r));+  const bySec=phases.map(()=>[]), loose=[];+  tree.order.forEach(k=>{ const r=byKey.get(k); if(!r)return; const ph=tree.phaseOf.get(k);+    if(Number.isInteger(ph)&&ph>=0&&ph<bySec.length)bySec[ph].push(r); else loose.push(r); });   const out=[];-  phases.forEach((p,i)=>{-    const h=hdrOf(i); if(h)out.push(h);-    rest.forEach(r=>{ if(r.phase===i)out.push(r); });   // saved row order stands within a section-  });-  // a row whose phase index is out of range would otherwise vanish, which is the-  // one way this could lose work: keep it at the end rather than drop it+  phases.forEach((p,i)=>{ const h=hdrs.find(x=>x.phase===i); if(h)out.push(h); bySec[i].forEach(r=>out.push(r)); });+  // a row whose section is out of range, or that the tree could not list, would otherwise+  // vanish, which is the one way this could lose work: keep it at the end rather than drop it   const seen=new Set(out);-  rest.forEach(r=>{ if(!seen.has(r))out.push(r); });+  loose.concat(rest).forEach(r=>{ if(!seen.has(r)){ out.push(r); seen.add(r); } });   items.length=0; items.push(...out); }+// The tree on the rows (0.79.0): each row indented by its depth, a task with subtasks wearing+// the chevron that folds them and how many it has, a folded task its subtasks' span. Writes+// only what changed, so a layout that moved nothing costs nothing. The label column keeps a+// slot for the chevron on every row only once the plan has any subtasks at all, so a plan+// without them looks exactly as it did.+function refreshTreeDom(){+  if(!rowsBuilt)return;+  let nest=false;+  items.forEach(r=>{+    if(isDerivedGroup(r)||!r._lDiv)return;+    const d=treeDepth(r), kids=treeHasKids(r);+    if(d||kids)nest=true;+    if(r._lDiv.__depth!==d){ r._lDiv.__depth=d; r._lDiv.style.setProperty('--depth',String(d)); r._lDiv.classList.toggle('is-sub',d>0); if(r._bDiv)r._bDiv.classList.toggle('is-sub',d>0); }+    r._lDiv.classList.toggle('has-kids',kids);+    r._lDiv.classList.toggle('depth-max',d>=TREE_DEPTH_MAX);+    r._lDiv.classList.toggle('kids-folded',kids&&collapsedRows.has(rowTreeKey(r)));+    const n=kids?treeDesc(r).length:0;+    if(r._subTog){ const f=collapsedRows.has(rowTreeKey(r));+      const t=kids?(f?'Show':'Hide')+' its '+plural(n,'subtask')+'. This is your view only, it does not change the plan.':'';+      if(r._subTog.title!==t)r._subTog.title=t; r._subTog.setAttribute('aria-expanded',f?'false':'true'); }+    // The count after the name is part of the name's markup: restated only when it changed.+    if(r.id&&r._lDiv.__kids!==n){ r._lDiv.__kids=n; refreshLabelRow(r); }+  });+  labelsEl.classList.toggle('has-nest',nest);+}+// Folded tasks, per person, like a folded section: how one person is looking at the plan.+// Subtasks start folded (0.79.0, ClickUp's default): what this browser keeps is the tasks its+// person OPENED, and every other task with subtasks reads as folded, a new one included, so a+// teammate's new subtasks never spread out under somebody else's work. The class answers as+// the set of folded tasks the rest of the page asks about.+const openRows=new Set((prefs.openRows||[]).filter(u=>typeof u==='string'));+class FoldedRows {+  has(k){ return !openRows.has(k); }+  add(k){ openRows.delete(k); return this; }+  delete(k){ const was=!openRows.has(k); openRows.add(k); return was; }+  // "Nothing folded": every task that has subtasks now is opened.+  clear(){ items.forEach(r=>{ if(!isDerivedGroup(r)&&treeHasKids(r))openRows.add(rowTreeKey(r)); }); }+  // Never empty while folding is the default; callers use it only to skip work.+  get size(){ return 1; }+  *[Symbol.iterator](){ for(const r of items){ if(!isDerivedGroup(r)&&treeHasKids(r)){ const k=rowTreeKey(r); if(!openRows.has(k))yield k; } } }+}+const collapsedRows=new FoldedRows();+function saveFolds(){ prefs.openRows=[...openRows]; delete prefs.collapsedRows; savePrefs(); }+function setRowFolded(r,on,persist){+  const k=rowTreeKey(r); if(!k)return;+  if(on)collapsedRows.add(k); else collapsedRows.delete(k);+  if(persist!==false)saveFolds();+}+// The span of a folded task's subtasks, earliest start to latest end, drawn under its own+// bar, so the chart still reads right with the detail put away (issue #5).+function paintSpans(){+  if(!rowsBuilt||prefs.view!=='gantt')return;+  items.forEach(r=>{+    if(!r._bDiv||isDerivedGroup(r))return;+    const on=collapsedRows.has(rowTreeKey(r))&&treeHasKids(r)&&!r._collapsed;+    let el=r._spanEl;+    if(!on){ if(el&&el.style.display!=='none')el.style.display='none'; return; }+    let a=null,b=null;+    treeDesc(r).forEach(x=>{ const s0=x.type==='milestone'?x.date:x.start, e0=x.type==='milestone'?x.date:x.end;+      if(isDateStr(s0)&&(!a||s0<a))a=s0; if(isDateStr(e0)&&(!b||e0>b))b=e0; });+    if(!a||!b){ if(el)el.style.display='none'; return; }+    if(!el){ el=document.createElement('div'); el.className='sub-span'; r._bDiv.appendChild(el); r._spanEl=el; }+    const x1=toX(a),x2=toX(b);+    setStyleIfChanged(el,'display',''); setStyleIfChanged(el,'left',x1+'px'); setStyleIfChanged(el,'width',Math.max(x2-x1,6)+'px');+    el.dataset.title='Subtasks of '+(r.name||'this task'); el.dataset.dates=fmt(a)+' → '+fmt(b);+  });+}  // ── State ── const phaseVis = phases.map(()=>true);@@ -3309,6 +3705,7 @@ const RAW={   desc:[r=>r.description,(r,v)=>setOpt(r,'description',v)], prov:[r=>r.provenance,(r,v)=>setProv(r,v)],   name:[r=>r.name,(r,v)=>{r.name=v;}], tags:[r=>tagsSnap(r),(r,v)=>setRowTags(r,v)],   effort:[r=>effortOf(r),(r,v)=>setEffort(r,v)], phase:[r=>r.phase,(r,v)=>{r.phase=v;}],+  parent:[r=>r.parent||'',(r,v)=>{ if(v)r.parent=v; else delete r.parent; }], pos:[r=>r.pos||'',(r,v)=>{ if(posOk(v))r.pos=v; else delete r.pos; }], }; const rawEq=(f,a,b)=>ROW_SETS[f]?ovEq(f,a,b)&&listOf(a).join('\u0001')===listOf(b).join('\u0001'):ovEq(f,a,b); // Put one field back to `before`, unless somebody else changed it after `seq`. `after` is the@@ -3371,7 +3768,6 @@ function snapshotGroup(ids){   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}))}); }-function snapshotOrder(){pushUndo({type:'order',order:items.map(rowOrderKey)});} // Legacy label and name keys are still accepted, so an order saved before row // keys became stable still restores. function orderKeyMap(){@@ -3398,8 +3794,8 @@ function reflowRows(){     // already in order stay put; each other one goes in front of its new neighbour. A pane     // that is not in the shape this expects is rebuilt the old way, which always works.     const rules=items.filter(r=>r.type==='milestone'&&r._msLine).map(r=>r._msLine);-    const ok=placeInOrder(labelsEl,items.map(r=>r._lDiv),el=>el.classList.contains('label-row'))>=0-      &&placeInOrder(bars,items.map(r=>r._bDiv),el=>el.classList.contains('bar-row'))>=0+    const ok=placeInOrder(labelsEl,items.map(r=>r._lDiv),el=>el.classList.contains('label-row'),isQaEl)>=0+      &&placeInOrder(bars,items.map(r=>r._bDiv),el=>el.classList.contains('bar-row'),isQaEl)>=0       &&placeInOrder(bars,rules,el=>el.classList.contains('ms-line'))>=0       &&paneOrderMatches();     if(!ok){@@ -3409,6 +3805,8 @@ function reflowRows(){   }   // An order-only merge changes no row's section, so no colour (0.78.1).   if(!orderOnlyMerge)recolorByPhase();+  refreshTreeDom();+  placeQuickAdd();   if(same)return;   keepTailLast();   // No header moved in an order-only merge, so which one is pinned cannot have changed; asking@@ -3420,11 +3818,14 @@ function reflowRows(){ // new neighbour, so one row moved by somebody else is one node moved here. The rows must sit // together in the pane (the tail room, the rules or the arrow layer before or after them, not // between); -1 when they do not, or when the pane holds a row the list does not, and the-// caller rebuilds the pane instead. Returns how many nodes it moved.-function placeInOrder(parent,want,isRow){+// caller rebuilds the pane instead. Returns how many nodes it moved. `skip` names nodes that+// may sit anywhere among the rows and are placed by their owner afterwards (0.79.0: the+// quick-add row, which follows the row it was opened from).+function placeInOrder(parent,want,isRow,skip){   const pos=new Map(); want.forEach((el,i)=>{ if(el)pos.set(el,i); });   const seq=[]; let started=false, ended=false, endRef=null;   for(const el of parent.children){+    if(skip&&skip(el))continue;     if(isRow(el)){       if(ended)return -1;       const p=pos.get(el); if(p===undefined)return -1;@@ -3488,7 +3889,7 @@ function recolorByPhase(){ } function refreshChartGeometry(){   if(prefs.view!=='gantt')return;-  layoutMsCaps(); sizeTailroom(); drawDeps();+  paintSpans(); layoutMsCaps(); sizeTailroom(); drawDeps(); } // The saved order lists the plan's ROWS. Headers are derived, so they are not in // it and are re-placed from the phases afterwards; that is what guarantees legend@@ -3506,16 +3907,19 @@ function applyOrder(order){   // The same order the panes already show (every live merge that only changed fields):   // nothing to re-append, no layout to force (0.75.1).   if(before.length===items.length&&before.every((r,i)=>r===items[i]))return true;-  if(orderOnlyMerge){-    let lo=0, hi=items.length-1;-    while(lo<items.length&&before[lo]===items[lo])lo++;-    while(hi>=0&&before[hi]===items[hi])hi--;-    depSpan=depSpan?[Math.min(depSpan[0],lo),Math.max(depSpan[1],hi)]:[lo,hi];-  }+  if(orderOnlyMerge)noteDepSpan(before);   reflowRows();   drawDeps();   return true; }+// In an order-only merge, the rows between the first and the last place that changed: every+// row outside them has the same rows above it, so the arrows' measurements of it still hold.+function noteDepSpan(before){+  let lo=0, hi=items.length-1;+  while(lo<items.length&&before[lo]===items[lo])lo++;+  while(hi>=0&&before[hi]===items[hi])hi--;+  depSpan=depSpan?[Math.min(depSpan[0],lo),Math.max(depSpan[1],hi)]:[lo,hi];+}  // Deleting is a data change, not a hide: the row leaves items, its DOM goes, its // bar entry goes, and every dependency pointing at it goes too, or the arrows,@@ -3557,7 +3961,8 @@ function restoreRow(r,idx,droppedDeps){   (droppedDeps||[]).forEach(d=>{const o=items.find(x=>x.id===d.id);     if(o){o.deps=o.deps||[];if(!o.deps.includes(d.dep))o.deps.push(d.dep);}});   if(r.id&&r._bDiv){const b=r._bDiv.querySelector('.bar[data-id]');if(b)barEls[r.id]={bar:b,idx,row:r._bDiv};}-  reflowRows();applyFilter();refreshChartGeometry();+  if(rowBatch){ rowBatchDirty=true; return; }+  placeHeaders();reflowRows();applyFilter();refreshChartGeometry(); } // Put a fully-formed item into the plan: give it a key if it has none, build the // same DOM the data-file rows got, and drop it into its lane.@@ -3577,6 +3982,8 @@ function insertRow(r,silent){ // index for barEls) and then moved, which is exactly what homeRow does too, so the // two paths differ in one line and not in how a row comes into existence. function insertRowAt(r,idx,silent){+  // Since 0.79.0 a row's place is its own (parent and key), set by the caller; `idx` only+  // seeds where a row with no key yet sits until it gets one.   if(!r.uid&&!r._key&&!r.id)r._key=freshKey();   items.push(r);   buildRowDom(r,items.length-1);@@ -3584,23 +3991,66 @@ function insertRowAt(r,idx,silent){   const cur=items.indexOf(r);   items.splice(cur,1);   items.splice(Math.max(0,Math.min(idx,items.length)),0,r);-  reflowRows();+  homeRow(r);   if(!silent){applyFilter();refreshChartGeometry();}   return r; } // Place a row at the end of its phase's block. Shared by creation and by a phase // move, so a new row lands exactly where a moved one would. function homeRow(r){-  const gi=items.findIndex(g=>g.type==='group'&&g.phase===r.phase);-  if(gi>=0){-    let end=gi+1;-    while(end<items.length&&items[end].type!=='group')end++;-    const cur=items.indexOf(r);-    if(cur>=0){items.splice(cur,1);items.splice(cur<end?end-1:end,0,r);}+  // A row with no key yet goes after its siblings (0.79.0): the end of its section, or the+  // last subtask of its parent. One with a key already has its place.+  if(!posOk(r.pos)&&!isDerivedGroup(r)){+    const par=r.parent?(treeRows.get(r.parent)||rowByUid(r.parent)):null;+    const sib=siblingsIn(par,r.phase,new Set([r])).filter(x=>posOk(x.pos));+    r.pos=keyBetween(sib.length?sib[sib.length-1].pos:null,null);   }   if(rowBatch){ rowBatchDirty=true; return; }+  placeHeaders();   reflowRows(); }+// ── Placing rows (0.79.0) ──+// The rows that sit beside each other under `parent` (a row, or null for the top level of+// section `phase`), in plan order, less the ones in `skip`.+function siblingsIn(parent,phase,skip){+  const pk=parent?rowTreeKey(parent):'';+  return items.filter(x=>!isDerivedGroup(x)&&x.type!=='group'&&!(skip&&skip.has(x))&&(pk?treeParent(x)===pk:(!treeParent(x)&&x.phase===phase)));+}+// `rows`, in this order, put straight after `after` (one of the siblings, or null: first) under+// `parent` in section `phase`. Each gets a key between that sibling's and the next one's, so+// the move is a change to these rows' own fields and nothing else. A subtask's section is its+// parent's; the layout writes it.+function placeRowsAt(rows,parent,phase,after){+  const set=new Set(rows), sib=siblingsIn(parent,phase,set);+  let i=after?sib.indexOf(after):-1;+  if(after&&i<0)i=sib.length-1;+  const keys=keysBetween(i>=0?sib[i].pos:null,sib[i+1]?sib[i+1].pos:null,rows.length);+  rows.forEach((r,j)=>{ if(parent){ r.parent=rowTreeKey(parent); r.phase=parent.phase; } else { delete r.parent; r.phase=phase; } r.pos=keys[j]; });+}+// The places of some rows, for one undo entry: putting them back is putting these back.+function placeSnap(rows){ return {type:'place',steps:rows.filter(Boolean).map(r=>({r,parent:r.parent||'',pos:r.pos||'',phase:r.phase}))}; }+// The rows of a selection that move as a unit: each one with no selected row above it in the+// tree, in plan order. Its subtasks go with it.+function treeRoots(rows){+  const set=new Set(rows);+  return rows.filter(r=>{ let p=treeParent(r); while(p){ const x=treeRows.get(p); if(x&&set.has(x))return false; p=tree.parentOf.get(p); } return true; })+    .sort((a,b)=>items.indexOf(a)-items.indexOf(b));+}+// The sibling just before or after `r` in its group, or null.+function sibNext(r,dir){+  const par=treeParent(r)?treeRows.get(treeParent(r)):null, sib=siblingsIn(par,r.phase,null), i=sib.indexOf(r);+  return i<0?null:sib[i+dir]||null;+}+function lastKid(r,skip){ const k=treeKids(r).filter(x=>!(skip&&skip.has(x))); return k.length?k[k.length-1]:null; }+// Can `rows` (and their subtasks) go under `parent`? Not a milestone, not into their own+// subtree, and no deeper than the limit.+function canNestUnder(rows,parent){+  if(!parent)return true;+  if(parent.type==='milestone'||isDerivedGroup(parent))return false;+  return rows.every(r=>r!==parent&&!treeUnderRow(parent,r)&&treeDepth(parent)+1+treeHeight(tree,rowTreeKey(r))<=TREE_DEPTH_MAX);+}+// After a change of places: lay the rows out again, and everything that hangs off them.+function layoutRows(){ placeHeaders(); reflowRows(); applyFilter(); refreshChartGeometry(); } // Many rows placed at once (the saved rows at startup, a live merge's new rows) reflow the // two panes ONCE at the end (0.75.1). Each one used to re-append all ~500 rows and read // every header's position, a full layout per row: 23 s of a 32 s startup on a real plan.@@ -3608,14 +4058,17 @@ let rowBatch=0, rowBatchDirty=false; function inRowBatch(fn){   rowBatch++;   try{ fn(); }-  finally{ rowBatch--; if(!rowBatch&&rowBatchDirty){ rowBatchDirty=false; reflowRows(); } }+  finally{ rowBatch--; if(!rowBatch&&rowBatchDirty){ rowBatchDirty=false; const before=orderOnlyMerge?items.slice():null; placeHeaders(); if(before)noteDepSpan(before); reflowRows(); } } }  // A group header is a row like any other, so moving a task to another phase has // to move it under that phase's header. Changing only item.phase would leave it // sitting visually in the lane it came from, which is worse than not moving it. function moveRowToPhase(r,np){-  r.phase=np;+  // To the end of that section as a top-level row (0.79.0), its subtasks with it. A subtask+  // moved this way leaves its parent: a subtask always sits in its parent's section.+  const sib=siblingsIn(null,np,new Set([r]));+  delete r.parent; r.phase=np; r.pos=keyBetween(sib.length?sib[sib.length-1].pos:null,null);   homeRow(r);   if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);   if(r._msLine)r._msLine.style.color=phaseColor(np);@@ -3629,15 +4082,6 @@ function moveRowToPhase(r,np){ // whatever the user is typing into, and because nothing else in the app or the // browser chrome claims that chord here. -// The phase a row's position now implies. The group header above a row IS its-// lane, everywhere else in this app, and a keyboard move is not allowed to be-// the one gesture that breaks that: a row that ends up under another header has-// moved lanes, and its phase, its bar colour and its dashed rule go with it.-function phaseAtPosition(r){-  const i=items.indexOf(r);-  for(let k=i-1;k>=0;k--) if(items[k].type==='group') return items[k].phase;-  return r.phase;-} // A collapsed lane hides its rows, so a row moved into one used to vanish in the // middle of a press and every press after that was a guess at where it had got to. // The lane OPENS at the moment the moving row actually crosses into it.@@ -3661,24 +4105,25 @@ function openLandingLanes(rows){   applyCollapse();   return true; }-function moveItemAdjacent(r,target,after){-  const ri=items.indexOf(r); if(ri<0)return false;-  items.splice(ri,1);-  const ti=items.indexOf(target);-  if(ti<0){ items.splice(ri,0,r); return false; }   // put it back rather than lose it-  items.splice(after?ti+1:ti,0,r);-  return true;-}-// Plan order: free movement. Group headers are not slots of their own, so the row-// steps over them, and stepping over one means it has changed lane.+// Plan order: a row steps past its neighbour, taking its subtasks with it, and the+// neighbour's subtasks are stepped over whole (0.79.0). A subtask moves among its own+// siblings and stops at the first and the last (Alt+Left takes it out). A top-level row at+// the edge of its section steps into the next section that has rows, as it always has:+// going down it lands at the top of that section, going up at the bottom. function planNeighbourMove(r,dir){-  const idx=items.indexOf(r); if(idx<0)return false;-  let j=idx+dir, crossed=false;-  while(j>=0&&j<items.length&&items[j].type==='group'){ crossed=true; j+=dir; }-  if(j<0||j>=items.length)return false;             // the top or the bottom of the plan-  // Going down past a header lands at the top of the next lane; going up past one-  // lands at the bottom of the previous lane. Otherwise it is a plain swap.-  return moveItemAdjacent(r,items[j],(dir>0)!==crossed);+  const par=treeParent(r)?treeRows.get(treeParent(r)):null;+  const sib=siblingsIn(par,r.phase,null), i=sib.indexOf(r);+  if(i<0)return 'edge';+  const j=i+dir;+  if(j>=0&&j<sib.length){ placeRowsAt([r],par,r.phase,dir>0?sib[j]:(j>0?sib[j-1]:null)); return 'ok'; }+  if(par)return 'sub-edge';+  for(let p=r.phase+dir;p>=0&&p<phases.length;p+=dir){+    const s2=siblingsIn(null,p,new Set([r]));+    if(!s2.length)continue;+    placeRowsAt([r],null,p,dir>0?null:s2[s2.length-1]);+    return 'ok';+  }+  return 'edge'; } // Under a column sort the rows on screen are not the rows in the plan, so a move // that could go anywhere would either lie about the sort or silently re-sort the@@ -3699,7 +4144,13 @@ function sortedNeighbourMove(r,dir){   if(ti<a||ti>b)return 'edge';   // The comparator breaks ties on plan position ascending, in both sort   // directions, so landing after the neighbour is what puts the row after it.-  return moveItemAdjacent(r,rows[ti],dir>0)?'ok':'gone';+  // Plan position is a row's place among its siblings (0.79.0), so only two rows that sit+  // side by side under one parent, or at the top of one section, can trade places here.+  const n=rows[ti];+  if((treeParent(n)||'')!==(treeParent(r)||'')||(!treeParent(r)&&n.phase!==r.phase))return 'apart';+  const par=treeParent(r)?treeRows.get(treeParent(r)):null, sib=siblingsIn(par,r.phase,new Set([r])), k=sib.indexOf(n);+  placeRowsAt([r],par,r.phase,dir>0?n:(k>0?sib[k-1]:null));+  return 'ok'; } // The chart has one selection model and it is the one this uses. More than one // row selected is a different gesture (a batch move) and is not this one.@@ -3726,26 +4177,19 @@ function flushKeyMove(){ // is the whole trick: going down, the bottom-most selected row moves first, so it is // never stepping over a sibling that has not moved yet; going up, the topmost does. //-// Simulated on a copy first. If ANY selected row has nowhere to go, the press does+// Tried on the rows' places first. If ANY selected row has nowhere to go, the press does // nothing at all rather than moving the ones that can and quietly squashing the-// selection against the end of the plan.+// selection against the end of the plan. Since 0.79.0 the rows that move are the+// selection's top rows in the tree, each with its subtasks. function planGroupMove(rows,dir){-  const arr=items.slice();-  const ordered=rows.slice().sort((a,b)=>arr.indexOf(a)-arr.indexOf(b));-  const seq=dir>0?ordered.slice().reverse():ordered;+  const before=rows.map(r=>({r,parent:r.parent,pos:r.pos,phase:r.phase}));+  const seq=dir>0?rows.slice().reverse():rows.slice();   for(const r of seq){-    const idx=arr.indexOf(r);-    if(idx<0)return null;-    let j=idx+dir, crossed=false;-    while(j>=0&&j<arr.length&&arr[j].type==='group'){ crossed=true; j+=dir; }-    if(j<0||j>=arr.length)return null;          // this one is at the end of the plan-    const target=arr[j];-    arr.splice(idx,1);-    const ti=arr.indexOf(target);-    if(ti<0)return null;-    arr.splice(((dir>0)!==crossed)?ti+1:ti,0,r);-  }-  return arr;+    const res=planNeighbourMove(r,dir);+    if(res!=='ok'){ before.forEach(b=>{ if(b.parent)b.r.parent=b.parent; else delete b.r.parent; if(b.pos)b.r.pos=b.pos; else delete b.r.pos; b.r.phase=b.phase; }); placeHeaders(); return false; }+    placeHeaders();+  }+  return true; } // ── Grouping a scattered selection ── //@@ -3772,44 +4216,25 @@ function gappedSelection(rows){   // Measured over real rows only. A section header between two selected rows is not   // a gap: it is not a slot anything can occupy, and a selection either side of one   // is contiguous and has always moved as a block.-  const real=items.filter(r=>r.type!=='group');+  // A selected row's subtasks travel with it, so they are not gaps either (0.79.0).+  const under=new Set(); rows.forEach(r=>treeDesc(r).forEach(x=>under.add(x)));+  const real=items.filter(r=>r.type!=='group'&&!under.has(r));   const pos=rows.map(r=>real.indexOf(r)).sort((a,b)=>a-b);   if(pos.length<2||pos[0]<0)return false;   return pos[pos.length-1]-pos[0]!==pos.length-1; } // The selected rows go to the topmost one's slot, in plan order; everything else-// keeps the order it had and simply closes up. Headers stay where they are relative-// to the rows still around them, so the lane each row LANDS in is read back off its-// new position afterwards, by the same phaseAtPosition every other keyboard move-// asks. Rows pulled up under the topmost row's header join that lane and recolour.-function coalesceOrder(rows){-  const set=new Set(rows);-  const out=[]; let placed=false;-  items.forEach(r=>{-    if(!set.has(r)){ out.push(r); return; }-    if(placed)return;-    placed=true; rows.forEach(x=>out.push(x));-  });-  return out;-}+// keeps the order it had and closes up. Rows pulled up beside the topmost row join its+// section (and, since 0.79.0, its parent) and recolour. function groupSelectedRows(rows,dir){-  const before=items.map(rowOrderKey);-  const fromPhases=rows.map(r=>({r,from:r.phase}));-  // Computed BEFORE items is emptied, the way a group move computes its own next-  // order first: read the plan, then replace it.-  const next=coalesceOrder(rows);-  items.length=0; items.push(...next);-  // One undo entry and one inverse, the same shape a group move records, so one-  // Ctrl+Z puts every row back exactly where it was and Ctrl+Shift+Z regroups them.-  pushUndo({type:'order',order:before,phases:fromPhases});-  rows.forEach(r=>{-    const np=phaseAtPosition(r);-    if(np!==undefined&&np!==r.phase){-      r.phase=np;-      if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);-      if(r._msLine)r._msLine.style.color=phaseColor(np);-    }-  });+  // The selected rows close up under the topmost one, as its next siblings, in plan order,+  // each with its subtasks (0.79.0): the topmost row's parent and section are where they land.+  const top=rows[0], par=treeParent(top)?treeRows.get(treeParent(top)):null, rest=rows.slice(1);+  if(par&&!canNestUnder(rest,par)){ flash(saveInd,'these rows cannot all sit under "'+(par.name||'that task')+'": too deep',3200); return true; }+  const snap=placeSnap(rows);+  placeRowsAt(rest,par,top.phase,top);+  pushUndo(snap);+  placeHeaders();   openLandingLanes(rows);   applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();   refreshSelVisuals();@@ -3820,25 +4245,15 @@ function groupSelectedRows(rows,dir){   return true; } function moveSelectedRows(dir){-  const rows=selectedRows();-  if(rows.length<2)return false;+  const sel=selectedRows();+  if(sel.length<2)return false;+  const rows=treeRoots(sel);+  if(rows.length<2){ moveOneRow(rows[0],dir,false); return true; }   if(gappedSelection(rows))return groupSelectedRows(rows,dir);-  const before=items.map(rowOrderKey);-  const fromPhases=rows.map(r=>({r,from:r.phase}));-  const next=planGroupMove(rows,dir);-  if(!next){ flash(saveInd,'the end of the plan'); return true; }-  items.length=0; items.push(...next);-  // One undo entry for the whole press, carrying every phase it changed, exactly-  // like a lane drag. The 0.20.0 inverse machinery gives redo for nothing.-  pushUndo({type:'order',order:before,phases:fromPhases});-  rows.forEach(r=>{-    const np=phaseAtPosition(r);-    if(np!==undefined&&np!==r.phase){-      r.phase=np;-      if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);-      if(r._msLine)r._msLine.style.color=phaseColor(np);-    }-  });+  // One undo entry for the whole press, every row's place in it.+  const snap=placeSnap(rows);+  if(!planGroupMove(rows,dir)){ flash(saveInd,'the end of the plan'); return true; }+  pushUndo(snap);   openLandingLanes(rows);                   // a block that has entered a shut lane opens it   applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();   refreshSelVisuals();                      // the selection survives, and still looks it@@ -3948,22 +4363,21 @@ function moveFocusedRow(dir){       : 'select a task first');     return;   }+  moveOneRow(r,dir,table);+}+function moveOneRow(r,dir,table){   const sorted=table&&!manualOrder();-  const fromPhase=r.phase;-  const order=items.map(rowOrderKey);-  const res=sorted?sortedNeighbourMove(r,dir):(planNeighbourMove(r,dir)?'ok':'edge');+  const snap=placeSnap([r]);+  const res=sorted?sortedNeighbourMove(r,dir):planNeighbourMove(r,dir);   if(res!=='ok'){     if(res==='alone')flash(saveInd,'no other row is tied with this one under this sort');+    else if(res==='apart')flash(saveInd,'under a sort, rows only trade places with a row beside them in the plan',3200);+    else if(res==='sub-edge'){ const p=treeRows.get(treeParent(r)); flash(saveInd,`the ${dir>0?'last':'first'} subtask of "${p?p.name:'its task'}". Alt+Left takes it out`,3200); }     else if(res==='edge')flash(saveInd,sorted?'the end of the tied group':'the end of the plan');     return;   }-  pushUndo({type:'phase',r,phase:fromPhase,order});-  const np=phaseAtPosition(r);-  if(np!==undefined&&np!==r.phase){-    r.phase=np;-    if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);-    if(r._msLine)r._msLine.style.color=phaseColor(np);-  }+  pushUndo(snap);+  placeHeaders();   openLandingLanes([r]);                    // it landed in a shut lane, so the lane opens   reflowRows();applyFilter();refreshChartGeometry();renderTable();   // A milestone's row key is its position among the milestones, so it can change@@ -3995,6 +4409,24 @@ function applySnapshot(sn,seqIn){     setPlanCal(sn.cal);     return inv;   }+  // Rows' places (0.79.0): a move, a nest, an un-nest. Each row goes back to the place it had,+  // as one value, unless somebody else has moved it since: then theirs stands and the toast+  // says so. Its subtasks go with it.+  if(sn.type==='place'){+    const inv={type:'place',steps:[]};+    (sn.steps||[]).forEach(st=>{+      const r=st.r; if(!isLive(r))return;+      inv.steps.push({r,parent:r.parent||'',pos:r.pos||'',phase:r.phase});+      if(r.uid&&(remoteSeq.get(r.uid+'|place')||0)>seq){ undoKept.push({r,f:'place',who:remoteWho.get(r.uid+'|place')||''}); return; }+      if(st.parent)r.parent=st.parent; else delete r.parent;+      if(posOk(st.pos))r.pos=st.pos; else delete r.pos;+      if(Number.isInteger(st.phase)&&st.phase>=0&&st.phase<phases.length)r.phase=st.phase;+    });+    placeHeaders();+    openLandingLanes(inv.steps.map(x=>x.r));+    applyCollapse();reflowRows();applyFilter();refreshChartGeometry();+    return inv;+  }   if(sn.type==='order'){     const live=liveOrder();     const inv={type:'order',order:live,@@ -4192,7 +4624,7 @@ function timeTravel(from,to,via,label,chord){ function keptNote(){   if(!undoKept.length)return '';   const k=undoKept[0], who=k.who||'someone else';-  const what=k.f==='row'?`kept "${k.r.name}", which ${who} changed after you made it`:`kept ${who}'s newer ${FIELD_WORD[k.f]||k.f} on "${k.r.name}"`;+  const what=k.f==='row'?`kept "${k.r.name}", which ${who} changed after you made it`:k.f==='place'?`kept "${k.r.name}" where ${who} moved it after you`:`kept ${who}'s newer ${FIELD_WORD[k.f]||k.f} on "${k.r.name}"`;   return ' &middot; '+esc(what)+(undoKept.length>1?' and '+(undoKept.length-1)+' more':''); } function undo(){ return timeTravel(undoStack,redoStack,'undo','Undid change','Ctrl+Z'); }@@ -4212,7 +4644,8 @@ function isTypingTarget(el){ const ESC_TEXT_TYPES={text:1,search:1,number:1,tel:1,url:1,email:1,password:1,   date:1,month:1,week:1,time:1,'datetime-local':1}; // The fields whose Escape is already spoken for. See the listener in wireGlobalKeys.-const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.pill-owner-inp,.id-box,.sec-row,.tag-row,#editor';+// The quick-add row (0.79.0) owns its Escape too: it stops adding, it does not just let go of the field.+const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.pill-owner-inp,.id-box,.sec-row,.tag-row,#editor,.qa-in'; function isEscBlurTarget(el){   if(!el||!el.tagName)return false;   const t=el.tagName.toUpperCase();@@ -4327,6 +4760,16 @@ document.addEventListener('keydown',e=>{     moveFocusedRow(e.key==='ArrowDown'?1:-1);     return;   }+  // Alt+Right and Alt+Left nest a row under the one above it and take it out again (0.79.0).+  // Only with a row to act on: otherwise the browser keeps its own Back and Forward.+  if(e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey&&(e.key==='ArrowRight'||e.key==='ArrowLeft')){+    if(isTypingTarget(document.activeElement))return;+    if(!rowsLens())return;+    if(!selected.size&&!(prefs.view==='table'&&focusedKey))return;+    e.preventDefault();+    indentFocusedRow(e.key==='ArrowRight'?1:-1);+    return;+  }   // Plain Up and Down move the SELECTION, the way a spreadsheet does. The   // preventDefault is the point of the feature: the arrows are for choosing a row,   // not for nudging a scrollbar, and the pane only moves when the row you chose is@@ -4524,9 +4967,15 @@ const remoteSeq=new Map(), remoteWho=new Map(), remoteRowSeq=new Map(); // else's straight after is told: the only true conflict, the same field at the same time. const myRecent=new Map(), RECENT_MS=120000; const syncNotices=[];-const ROW_F=['s','e','date','deps','note','owner','status','desc','prov','tags','effort','name','phase'];+const ROW_F=['s','e','date','deps','note','owner','status','desc','prov','tags','effort','name','phase','parent','pos'];+// A row's place (0.79.0): its parent, its key among its siblings and, for a top-level row, its+// section. One value: sent together, taken or kept together (see rowDiff and applyRowOp).+const PLACE_F=['parent','pos','phase'];+// Two copies of a row's place differ: its parent, its key, or the section of a top-level row+// (a subtask's section is its parent's, never a value of its own).+function placeDiffOf(a,b){ return !ovEq('parent',a.parent,b.parent)||!ovEq('pos',a.pos,b.pos)||(!a.parent&&!ovEq('phase',a.phase,b.phase)); } const ROW_SETS={deps:true,tags:true};-const FIELD_WORD={s:'start',e:'deadline',date:'date',deps:'dependencies',note:'bar label',owner:'owner',status:'status',desc:'description',prov:'provenance',tags:'tags',effort:'effort',name:'name',phase:'section'};+const FIELD_WORD={s:'start',e:'deadline',date:'date',deps:'dependencies',note:'bar label',owner:'owner',status:'status',desc:'description',prov:'provenance',tags:'tags',effort:'effort',name:'name',phase:'section',parent:'place',pos:'place',place:'place'}; // Every row this tab has held, by uid, so one that comes back is the same object (its DOM, // and every undo entry that names it, still resolve). const allRows=new Map();@@ -4550,6 +4999,8 @@ function rowOv(r){   // Effort (0.63.0) on every task, null when it has none: null is the sidecar's   // "cleared", as '' is for the owner.   if(r.type!=='milestone')o.effort=effortOf(r);+  // Its place (0.79.0): '' for a top-level row and a row with no key yet.+  o.parent=r.parent||''; o.pos=r.pos||'';   return o; } const listOf=v=>Array.isArray(v)?v:[];@@ -4574,6 +5025,8 @@ function setOvField(r,f,v){   else if(f==='effort'){ if(r.type!=='milestone'){ if(v===null||v===undefined)delete r.effort; else { const ef=normalizeEffort(v); if(ef!==undefined)r.effort=ef; } } }   else if(f==='name'){ if(v)r.name=v; }   else if(f==='phase'){ if(Number.isInteger(v)&&v>=0&&v<phases.length)r.phase=v; }+  else if(f==='parent'){ if(typeof v==='string'&&v)r.parent=v; else delete r.parent; }+  else if(f==='pos'){ if(posOk(v))r.pos=v; else delete r.pos; } } function isLive(r){ return !!r&&(items[r._itemIdx]===r||items.includes(r)); } function liveRowOf(uid){ const r=allRows.get(uid); return isLive(r)?r:null; }@@ -4615,8 +5068,15 @@ function rowDiff(ov,sh){   // No tags of its own is the same as an empty list: an undo back to "none" takes the key   // off the row, and that is still a change to send.   if(!('tags' in ov))ov=Object.assign({},ov,{tags:[]});+  // A row's place is one value (0.79.0): any part of it changed sends all of it, so the last+  // writer wins the whole place (Figma keeps a layer's parent and position in one property for+  // the same reason). A subtask's section is never sent: it is its parent's, kept by the server.+  const child=!!ov.parent;+  if(!ovEq('parent',ov.parent,sh.parent)||!ovEq('pos',ov.pos,sh.pos)||(!child&&!ovEq('phase',ov.phase,sh.phase))){+    set={parent:ov.parent||'',pos:ov.pos||''}; if(!child)set.phase=ov.phase;+  }   for(const f of ROW_F){-    if(!(f in ov))continue;+    if(!(f in ov)||PLACE_F.includes(f))continue;     if(ROW_SETS[f]){       const k=setKey(f), A=listOf(ov[f]), B=listOf(sh[f]), As=new Set(A.map(k)), Bs=new Set(B.map(k));       const plus=A.filter(x=>!Bs.has(k(x))), minus=B.filter(x=>!As.has(k(x)));@@ -4637,7 +5097,7 @@ function rowDiff(ov,sh){ // A created row, whole, in the sidecar's `added` shape. function addedOut(r){   const o={};-  ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','provenance','tags','effort'].forEach(k=>{if(r[k]!==undefined)o[k]=cloneV(r[k]);});+  ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','provenance','tags','effort','parent','pos'].forEach(k=>{if(r[k]!==undefined)o[k]=cloneV(r[k]);});   return o; } // A created row's id, as the server settled it (two people making a row of the same name@@ -4709,15 +5169,8 @@ function computeOps(){     if(dead){ const d=rowDiff(ov,dead); if(d)rows.push(d); }   });   shadow.rows.forEach((_,u)=>{ if(!live.has(u))dels.push({op:'del',uid:u}); });-  const target=groupOrder(liveOrder()), base=groupOrder(shadow.order.filter(k=>live.has(k)));-  let moves=orderMoves(base,target);-  // The moves are checked by replaying them; if they would not land exactly on this-  // page's order, the whole order goes instead, merged by the server like a move.-  if(moves.length){-    const sim=base.concat(target.filter(k=>!base.includes(k)));-    moves.forEach(m=>moveInOrder(sim,m.uid,m.after));-    if(sim.join('\n')!==target.join('\n'))moves=[{op:'order',order:target}];-  }+  // No moves (0.79.0): a row's place is its own fields, sent with the rest of them above.+  const moves=[];   const c=curStruct();   if(c.phases!==shadow.phases)tail.push({op:'phases',phases:phasesOut()});   if(regInSidecar&&c.reg!==shadow.reg)tail.push({op:'reg',tags:regOut()});@@ -4798,7 +5251,7 @@ function getState(){       // does not survive the next save: leaving uid out would have quietly       // stripped the durable id off every row the user ever created, and in v4 it       // is also the key every other part of the file uses to name that row.-      ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags','effort'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});+      ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags','effort','parent','pos'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});       return o;}),   }; }@@ -4868,6 +5321,9 @@ function applyOverrideRow(r,sv,phaseN){   if('effort' in sv&&r.type!=='milestone'){ if(sv.effort===null)delete r.effort; else { const ef=normalizeEffort(sv.effort); if(ef!==undefined)r.effort=ef; } }   if(sv.name)r.name=sv.name;   if(Number.isInteger(sv.phase)&&sv.phase>=0&&sv.phase<phaseN)r.phase=sv.phase;+  // Its place (0.79.0), as the server's merge applies it: '' makes it a top-level row.+  if(sv.parent!==undefined){ if(typeof sv.parent==='string'&&sv.parent)r.parent=sv.parent; else delete r.parent; }+  if(sv.pos!==undefined){ if(posOk(sv.pos))r.pos=sv.pos; else delete r.pos; }   return r; } function applyState(state){@@ -4940,6 +5396,8 @@ function applyState(state){   // restores lane visibility. Geometry (axis, caps, deps) is left to startApp, which   // rebuilds the timeline right after this returns.   if(phasesRebuilt&&rowsBuilt){ placeHeaders(); reflowRows(); refreshSections(); applyFilter(); }+  // The rows' parents and keys came with the overrides (0.79.0): lay the rows out from them.+  else if(rowsBuilt){ placeHeaders(); reflowRows(); } } // ── Changelog ── //@@ -4961,8 +5419,10 @@ let moveHint=null; function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;} function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;}-function snapTask(r){ return {start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice(),eff:effortOf(r)}; }-function snapMs(r){ return {name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase,tags:ownTags(r).slice(),deps:(r.deps||[]).slice(),id:r.id||''}; }+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),parent:treeParent(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(),deps:(r.deps||[]).slice(),id:r.id||'',parent:treeParent(r)}; }+// The task a row sits under, by name, for the changelog (0.79.0).+function parentEv(a,b){ const nm=u=>{ if(!u)return ''; const x=allRows.get(u)||rowByUid(u); return x?x.name||'':''; }; return {from:{parent:a||'',name:nm(a)},to:{parent:b||'',name:nm(b)}}; } // 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.@@ -5018,7 +5478,9 @@ function diffAndLog(via){     if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',id,name:nameOf(id),from:{desc:a.desc||''},to:{desc:b.desc||''}});     if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',id,name:nameOf(id),from:{label:a.provL||''},to:{label:b.provL||''}});     if((a.name||'')!==(b.name||'')) evs.push({action:'rename',id,name:b.name||'',from:{name:a.name||''},to:{name:b.name||''}});-    if(a.phase!==b.phase) evs.push({action:'phase',id,name:nameOf(id),from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});+    // A subtask's section follows its top-level task's, which says so itself (0.79.0).+    if((a.parent||'')!==(b.parent||'')) evs.push(Object.assign({action:'parent',id,name:nameOf(id)},parentEv(a.parent,b.parent)));+    if(a.phase!==b.phase&&!b.parent) evs.push({action:'phase',id,name:nameOf(id),from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});     // A row's own tags (0.60.0), the row side of the server's `tags` action. Absent and []     // both diff as none: the sidecar keeps the difference, the changelog need not.     if(!sameTags(a.tags,b.tags)) evs.push({action:'tags',id,name:nameOf(id),from:{tags:a.tags||[]},to:{tags:b.tags}});@@ -5081,7 +5543,8 @@ function diffAndLog(via){     if((a.status||'')!==(b.status||'')) evs.push({action:'status',ms:true,key:k,name:nm,from:{status:a.status||''},to:{status:b.status||''}});     if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,key:k,name:nm,from:{desc:a.desc||''},to:{desc:b.desc||''}});     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((a.parent||'')!==(b.parent||'')) evs.push(Object.assign({action:'parent',ms:true,key:k,name:nm},parentEv(a.parent,b.parent)));+    if(a.phase!==b.phase&&!b.parent) 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;@@ -5122,7 +5585,7 @@ function diffAndLog(via){   const before=lastSnap.order,after=cur.order;   // Only a real reorder. Adding or removing a row changes this list, and so does   // moving one to another lane, and both of those already say so themselves.-  const explained=evs.some(e=>e.action==='phase');+  const explained=evs.some(e=>e.action==='phase'||e.action==='parent');   if(!explained&&before.length===after.length&&JSON.stringify(before)!==JSON.stringify(after)){     const moved=after.filter((v,i)=>before[i]!==v);     evs.push({action:'reorder',order:after,moved:moved.slice(0,8),hint:moveHint||undefined});@@ -5456,8 +5919,9 @@ function applyOps(ops,ctx){   const T={rows:new Set(),fields:new Set(),fresh:new Set(),structure:false,order:false,phases:false,reg:false,dates:false,cal:false};   // A batch that only reorders rows cannot move the plan's span, so the two whole-plan   // scans that compare it before and after are skipped (0.78.1), and the reader's place is-  // read off the rows alone (placeMarkRows).-  const orderOnly=!ctx.settle&&Array.isArray(ops)&&ops.length>0&&ops.every(o=>o&&o.op==='order');+  // read off the rows alone (placeMarkRows). Since 0.79.0 a move is the row's own key, so a+  // batch whose rows change nothing but their keys is one of these too.+  const orderOnly=!ctx.settle&&Array.isArray(ops)&&ops.length>0&&ops.every(o=>o&&(o.op==='order'||keyOnlyOp(o)));   const mark=orderOnly?placeMarkRows():placeMark();   const range0=prefs.view==='gantt'&&!orderOnly?JSON.stringify(scanRange()):null;   liveMerging=true; orderOnlyMerge=orderOnly;@@ -5476,6 +5940,12 @@ function applyOps(ops,ctx){         });         if(ctx.settle)settleSent(ctx.settle,T);       });+      // The server's order is derived from the places this batch carried (0.79.0). A page with+      // nothing of its own on the way that lays its rows out otherwise has missed a change.+      if(T.orderCheck&&!inflight&&!T.resync){+        const lo=liveOrder(), have=new Set(lo), F=T.orderCheck.filter(k=>have.has(k)), want=new Set(F);+        if(F.join('\n')!==lo.filter(k=>want.has(k)).join('\n')&&!computeOps().length)T.resync='order';+      }       repaintAfter(T,range0);     }));     if(T.structure)keepPlace(mark);@@ -5491,6 +5961,20 @@ function applyOps(ops,ctx){   if(T.resync)requestResync(T.resync);   return T; }+// A row op that changes nothing but the row's key among its siblings (0.79.0): the same parent+// and, for a top-level row, the same section. Applied, it moves the row inside its section and+// changes nothing else about it or any other row.+function keyOnlyOp(o){+  if(!o||o.op!=='row'||!o.set||typeof o.set!=='object'||o.add||o.del)return false;+  const r=liveRowOf(o.uid); if(!r)return false;+  for(const k of Object.keys(o.set)){+    if(k==='pos')continue;+    if(k==='parent'&&(o.set.parent||'')===(r.parent||''))continue;+    if(k==='phase'&&o.set.phase===r.phase)continue;+    return false;+  }+  return 'pos' in o.set;+} // A full catch-up on purpose (0.77.1), for a page that found it disagrees with the server in a // way no announcement will put right: a row in another section than the server has it, a // value it could not take, an op it could not apply. What this page changed and has not sent@@ -5504,7 +5988,7 @@ function applyOne(op,mine,who,T){   if(op.op==='row')return applyRowOp(op,mine,who,T);   if(op.op==='add')return applyAddOp(op,mine,who,T);   if(op.op==='del')return applyDelOp(op,mine,who,T);-  if(op.op==='order')return rebaseOrder(op.order,mine,T,!!op.grouped);+  if(op.op==='order'){ if(Array.isArray(op.order)){ shadow.order=op.order.slice(); T.orderCheck=op.order; } return; }   const flying=k=>!mine&&!!inflight&&!!inflight.sent[k];   const sentDiff=(k,cur)=>mine&&mine.sent[k]?cur!==mine.struct[k]:false;   if(op.op==='phases'){@@ -5567,6 +6051,16 @@ function applyRowOp(op,mine,who,T){   if(cur&&!('tags' in cur))cur.tags=[];   const sent=mine?mine.vals.get(uid):null, sentF=mine?mine.fields.get(uid):null;   const flying=!mine&&inflight?inflight.fields.get(uid):null;+  // A row's place is one value (0.79.0): where this tab has its own change to any part of it,+  // on its way or not sent yet, the whole place stays as this tab has it; it reaches the server+  // after this one and wins there. A subtask's section is its parent's, never this tab's change.+  const placeDiff=placeDiffOf;+  let placePend=false;+  if(cur&&PLACE_F.some(f=>f in op.set)){+    const oldPlace={parent:sh.parent,pos:sh.pos,phase:sh.phase};+    if(mine)placePend=!!(sent&&sentF&&PLACE_F.some(f=>sentF.has(f)))&&placeDiff(cur,sent);+    else placePend=!!(flying&&PLACE_F.some(f=>flying.has(f)))||placeDiff(cur,oldPlace);+  }   for(const f of Object.keys(op.set)){     if(!ROW_F.includes(f))continue;     const v=op.set[f], old=sh[f];@@ -5574,7 +6068,8 @@ function applyRowOp(op,mine,who,T){     if(!r||!(f in cur))continue;     const mineHere=!!(sent&&sentF&&sentF.has(f));     let next;-    if(ROW_SETS[f]){+    if(PLACE_F.includes(f))next=placePend?cur[f]:v;+    else if(ROW_SETS[f]){       // An item this tab has touched (added or removed since the server's copy, in a batch on       // its way or since) is where this tab has it; every other item is where the server has       // it. Without the batch on its way, an item added and then taken out again before the@@ -5609,8 +6104,12 @@ function applyRowOp(op,mine,who,T){     // undo the move for everybody), and the page fetches the plan to catch up.     if(f==='phase'&&r.phase!==next){ sh[f]=old; T.resync='section'; continue; }     cur[f]=cloneV(next);-    if(!mine){ remoteSeq.set(uid+'|'+f,++syncSeq); remoteWho.set(uid+'|'+f,who); remoteWho.set(uid+'|row',who); remoteRowSeq.set(uid,syncSeq); }+    if(!mine){ remoteSeq.set(uid+'|'+f,++syncSeq); remoteWho.set(uid+'|'+f,who); remoteWho.set(uid+'|row',who); remoteRowSeq.set(uid,syncSeq);+      if(PLACE_F.includes(f)){ remoteSeq.set(uid+'|place',syncSeq); remoteWho.set(uid+'|place',who); } }     T.rows.add(r); T.fields.add(f);+    // The rows are laid out again from their places once the batch is in. In a batch that only+    // changes rows' keys (keyOnlyOp) that is a reorder inside sections and nothing else.+    if(PLACE_F.includes(f)){ T.place=true; if(orderOnlyMerge)T.order=true; else T.structure=true; rowBatchDirty=true; }   } } // The same field, changed by somebody else a moment after this person set it: they are@@ -5637,6 +6136,13 @@ function applyAddOp(op,mine,who,T){     // This tab's own new row, confirmed. Its task id is the server's: two people making a     // row of the same name in the same second do not get the same id.     if(local&&row&&row.id&&local.id&&row.id!==local.id){ renameRowId(local,row.id); T.rows.add(local); T.structure=true; }+    // And its place is the server's (0.79.0): two people adding a row at the end of one+    // section in the same second give both the same key, and the server parts them. Unless+    // this tab has moved it since, which goes out next and wins.+    if(local&&row){ const sent=mine.vals.get(uid), now=rowOv(local);+      if(sent&&!placeDiffOf(now,sent)){ const want={parent:row.parent||'',pos:row.pos||'',phase:Number.isInteger(row.phase)?row.phase:now.phase}; let ch=false;+        PLACE_F.forEach(f=>{ if(!ovEq(f,want[f],now[f])){ setOvField(local,f,cloneV(want[f])); ch=true; } });+        if(ch){ T.rows.add(local); T.place=true; T.structure=true; rowBatchDirty=true; } } }     return;   }   if(local)return;@@ -5672,47 +6178,6 @@ function applyDelOp(op,mine,who,T){   removeRow(r,true);   T.structure=true; }-// The server's order, with this tab's own moves that it does not have yet replayed on top,-// and any row this tab has that it does not list (made here and not sent yet) kept beside-// the row it follows here.-function rebaseOrder(F,mine,T,grouped){-  if(!Array.isArray(F))return;-  const old=shadow.order;-  shadow.order=F.slice();-  // Grouped as this page lays rows out, both sides (0.77.1): what this tab moved itself is-  // the difference between the two, and nothing that is only a matter of grouping.-  const raw=liveOrder(), localOld=groupOrder(raw);-  const liveSet=new Set(localOld);-  // Rows that arrived in this same batch take the server's place for them, not a guess.-  const mineLocal=localOld.filter(k=>!T.fresh.has(k));-  const base=groupOrder(mine&&mine.order?mine.order:old).filter(k=>liveSet.has(k)&&!T.fresh.has(k));-  const pend=orderMoves(base,mineLocal);-  // Moves this page holds and the server does not are unsent changes, however they came about-  // (a page that missed a change and caught up holds some it never made): afterSync must look.-  if(pend.length)markLocalEdit();-  const res=F.filter(k=>liveSet.has(k));-  const inRes=new Set(res);-  localOld.forEach((k,i)=>{-    if(inRes.has(k))return;-    let j=i-1; while(j>=0&&!inRes.has(localOld[j]))j--;-    res.splice(j<0?0:res.indexOf(localOld[j])+1,0,k); inRes.add(k);-  });-  pend.forEach(m=>moveInOrder(res,m.uid,m.after));-  // A server that keeps its order grouped (0.77.1) and a page that would group it otherwise-  // disagree about which section some row is in. Nothing announced will ever change that,-  // so the page fetches the whole plan, unless the difference is this page's own unsent move.-  if(grouped&&!mine&&!inflight&&!pend.length){-    const fl=F.filter(k=>liveSet.has(k));-    if(groupOrder(fl).join('\n')!==fl.join('\n')&&!computeOps().some(o=>o.op==='row'&&o.set&&'phase' in o.set))T.resync='sections';-  }-  if(res.join('\n')===raw.join('\n'))return;-  // The same rows in another order (applyOrder takes nothing else): their sections, the-  // headers' places and every row's own paint stand as they were, so the repaint moves the-  // rows that moved and the arrows, and nothing else (0.78.1). It used to be a structural-  // change: every label, bar, the legend, the filters and the whole timeline, ~200 ms on-  // every open page for each row somebody dragged.-  if(applyOrder(res))T.order=true;-} // What a batch sent and the server did not take, where this tab has not changed it since, // goes back to the server's value; otherwise it would be sent again with every save. function settleSent(b,T){@@ -5728,7 +6193,8 @@ function settleSent(b,T){     if(!('tags' in cur))cur.tags=[];     fs.forEach(f=>{       if(!(f in cur)||!(f in sh&&sh[f]!==undefined||f==='tags'))return;-      if(ovEq(f,cur[f],sent[f])&&!ovEq(f,cur[f],sh[f])){ setOvField(r,f,cloneV(sh[f])); T.rows.add(r); T.fields.add(f); }+      if(ovEq(f,cur[f],sent[f])&&!ovEq(f,cur[f],sh[f])){ setOvField(r,f,cloneV(sh[f])); T.rows.add(r); T.fields.add(f);+        if(PLACE_F.includes(f)){ T.place=true; T.structure=true; rowBatchDirty=true; } }     });   });   const c=curStruct();@@ -5736,7 +6202,6 @@ function settleSent(b,T){   if(b.sent.reg&&c.reg===b.struct.reg&&c.reg!==shadow.reg){ tagReg=(normalizeTagRegistry(shadow.regV||[])||[]).map(regEntry); T.reg=true; }   if(b.sent.dates&&c.dates===b.struct.dates&&c.dates!==shadow.dates){ planDates=shadow.datesV?Object.assign({},shadow.datesV):{start:cleanDay(project&&project.start),end:cleanDay(project&&project.end)}; T.dates=true; }   if(b.sent.cal&&c.cal===b.struct.cal&&c.cal!==shadow.cal){ planCal=cleanCal(shadow.calV)||cleanCal(project&&project.calendar)||{workHolidays:[],extraDaysOff:[]}; PLAN_OFF_CACHE.clear(); T.cal=true; }-  if(b.order)rebaseOrder(shadow.order,b,T); } // The rows a batch touched, repainted once; the lanes, the legend and the axis only when // the batch changed the plan's shape or its span.@@ -5919,7 +6384,7 @@ function renderPresence(){ window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),merges:()=>liveMerges,pulls:()=>pullCount,dirty:()=>computeOps().map(o=>o.uid||(o.row&&o.row.uid)||o.op),rev:()=>baseRev,pending:pendingCount,notices:()=>syncNotices.slice(),kept:()=>undoKept.map(k=>({uid:k.r&&k.r.uid,f:k.f,who:k.who})),inflight:()=>!!inflight,   // For the collaboration suite (0.75.1): the plan as this tab holds it, row by row, to compare with the server's.   rows:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,name:r.name||'',owner:r.owner||'',status:r.status||'',start:r.start||'',end:r.end||'',date:r.date||'',phase:r.phase,deps:(r.deps||[]).slice().sort(),desc:r.description||'',-    note:r.note||'',tags:ownTags(r).map(t=>t.toLowerCase()).sort(),effort:effortOf(r),id:r.id||''})),+    note:r.note||'',tags:ownTags(r).map(t=>t.toLowerCase()).sort(),effort:effortOf(r),id:r.id||'',parent:treeParent(r),pos:r.pos||''})),   // The suite's hands (0.77.0): the same edits a person makes, one undo entry each, without   // the clicks, so a run can make hundreds of them across several tabs.   order:()=>liveOrder(),@@ -5929,10 +6394,21 @@ window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),m     if(r.type==='milestone'){ refreshMsChrome(r); updateMilestone(r); } else { updateBar(r.id); refreshLabelRow(r); }     if(f==='tags')afterTagsChange();     scheduleSave('edit'); return true; },+  // A move is a change of the row's place (0.79.0): straight after `after`, as its sibling, or+  // first among its own siblings when `after` is null.   move:(uid,after)=>{ const r=liveRowOf(uid), a=after?liveRowOf(after):null;-    if(!r||(after&&!a)||(a&&a.phase!==r.phase))return false;-    const o=liveOrder(); if(!moveInOrder(o,uid,after))return false;-    pushUndo({type:'order',order:items.map(rowOrderKey)}); applyOrder(o); scheduleSave('drag'); return true; },+    if(!r||(after&&!a)||(a&&a.phase!==r.phase)||a===r)return false;+    const par=a?(treeParent(a)?treeRows.get(treeParent(a)):null):(treeParent(r)?treeRows.get(treeParent(r)):null);+    if(par&&!canNestUnder([r],par))return false;+    const snap=placeSnap([r]); placeRowsAt([r],par,r.phase,a); pushUndo(snap); layoutRows(); renderTable(); scheduleSave('drag'); return true; },+  // Subtasks (0.79.0): put a row under a task (as its last subtask), or take it out (it lands+  // straight after its parent), the way the keyboard and a drag do.+  nest:(uid,puid)=>{ const r=liveRowOf(uid), p=liveRowOf(puid); if(!r||!p||!canNestUnder([r],p))return false;+    const snap=placeSnap([r]); placeRowsAt([r],p,p.phase,lastKid(p,new Set([r]))); pushUndo(snap); layoutRows(); renderTable(); scheduleSave('nest'); return true; },+  unnest:uid=>{ const r=liveRowOf(uid); if(!r||!treeParent(r))return false; return indentRow(r,-1); },+  indent:(uid,dir)=>{ const r=liveRowOf(uid); return r?indentRow(r,dir):false; },+  tree:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,parent:treeParent(r),pos:r.pos||'',depth:treeDepth(r),phase:r.phase,hidden:!!r._collapsed,ctx:!!r._ctx})),+  fold:(uid,on)=>{ const r=liveRowOf(uid); if(!r||!treeHasKids(r))return false; if(collapsedRows.has(rowTreeKey(r))!==!!on)toggleRowFold(r); return true; },   del:uid=>{ const r=liveRowOf(uid); if(!r)return false; deleteRow(r); return true; },   undo:()=>undo(), redo:()=>redo(),   dates:()=>Object.assign({},planDates), cal:()=>JSON.parse(JSON.stringify(planCal)),@@ -5978,6 +6454,7 @@ function refreshConflictCount(){ } function showConflictBar(){   document.documentElement.setAttribute('data-save','blocked');+  edReclamp();      // the bottom band an open panel keeps clear of grows with the banner   showSaveErr();   // Already up. Do NOT rebuild it: a rebuild every 800ms would disarm the confirm button   // under the user's second click and would throw away the reload warning they are@@ -6035,6 +6512,7 @@ function clearConflict(msg){   conflictBar.classList.remove('show');   conflictBar.innerHTML='';   document.documentElement.removeAttribute('data-save');+  edReclamp();   unpinSaveErr();   if(msg)flash(saveInd,msg,4000); }@@ -6680,6 +7158,9 @@ function openRowLink(){   // gantt view and see the row selected/highlighted").   if(prefs.view!=='gantt')setView('gantt');   if(r.phase!==undefined&&isCollapsed(r.phase))setCollapsed(r.phase,false);+  // A subtask inside a folded task: the tasks above it unfold (0.79.0).+  { let p=treeParent(r), opened=false; while(p){ if(collapsedRows.has(p)){ collapsedRows.delete(p); opened=true; } p=tree.parentOf.get(p); }+    if(opened){ saveFolds(); refreshTreeDom(); applyCollapse(); } }   const hidden=(r.phase!==undefined&&!phaseVis[r.phase])||!rowShown(r)||(isMs(r)&&!msVis);   if(hidden){     if(r.phase!==undefined)phaseVis[r.phase]=true;@@ -6735,7 +7216,23 @@ function nameInner(r){   // most worth seeing at a glance and it has to be clickable to be fixable.   const ow=(r.owner||'').trim();   const op=`<span class="tag owner-pill${ow?'':' none'}" title="${ow?'Click to change owner':'Nobody owns this yet. Click to set an owner'}">${esc(ow||'NO OWNER')}</span>`;-  return nt+tg+op+rowTagsHtml(r);+  return nt+subCountHtml(r)+tg+op+rowTagsHtml(r);+}+// A task's hours with its subtasks' (0.79.0), as ClickUp rolls a time estimate up: the+// task's own estimate plus every subtask's, cancelled work left out. Shown beside its own in+// the Table; Analytics counts every task's own hours once, so nothing is counted twice.+function subEffort(r){ let h=0,n=0; treeDesc(r).forEach(x=>{ if(x.type==='milestone'||isCancelled(x))return; const e=effortOf(x); if(e!==null){ h+=e; n++; } }); return n?h:null; }+function effortRollHtml(r){+  const sub=treeHasKids(r)?subEffort(r):null; if(sub===null)return '';+  const own=isCancelled(r)?0:(effortOf(r)||0), tot=Math.round((own+sub)*10)/10;+  return `<span class="eff-roll" title="${esc(fmtEffort(tot)+' with its subtasks: '+(effortOf(r)===null?'none of its own':fmtEffort(own)+' of its own')+' and '+fmtEffort(Math.round(sub*10)/10)+' in its subtasks')}"> &middot; ${esc(fmtEffort(tot))}</span>`;+}+// How many subtasks a task has, after its name (0.79.0), the way ClickUp marks a parent.+const PLUS_SVG='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';+function subCountHtml(r){+  if(!treeHasKids(r))return '';+  const n=treeDesc(r).length, k=(tree.kids.get(rowTreeKey(r))||[]).length;+  return `<span class="sub-count" title="${esc(plural(n,'subtask')+(n>k?', '+k+' directly under it':''))}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v9a3 3 0 0 0 3 3h9"/><path d="M15 13l3 3-3 3"/></svg>${n}</span>`; }  // Extracted from the initial build loop so a row created at runtime is built by@@ -6750,12 +7247,16 @@ function buildRowDom(r,idx){     fillLaneName(nm,isDerivedGroup(r)?(dp?dp.name:r.label):groupLabel(r.label),dp?(dp.lead||''):'',dp?dp.tags:null);     const btn=document.createElement('button');btn.className='lane-toggle';btn.type='button';     btn.innerHTML='<span class="lane-count"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';-    lDiv.appendChild(nm);lDiv.appendChild(btn);+    // Quick add (0.79.0, issue #6): a task at the end of this section, typed in place.+    const add=document.createElement('button');add.className='lane-add';add.type='button';add.title='Add a task to this section';+    add.setAttribute('aria-label','Add a task to this section');add.innerHTML=PLUS_SVG;+    lDiv.appendChild(nm);lDiv.appendChild(add);lDiv.appendChild(btn);     r._laneBtn=btn;     bDiv.className='bar-row group';   }   else if(r.type==='milestone'){     lDiv.className='label-row ms';+    lDiv.appendChild(Object.assign(document.createElement('span'),{className:'sub-tog sub-none'}));     lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));     lDiv.appendChild(makeEditBtn(r));     lDiv.appendChild(makeLinkBtn(r));@@ -6779,7 +7280,15 @@ function buildRowDom(r,idx){     r._msEl=d;r._msHit=hit;r._msLine=ln;   }   else{lDiv.className='label-row';lDiv.dataset.id=r.id;+    // Subtasks (0.79.0): the chevron that folds this task's subtasks, in a slot every row+    // keeps once the plan has any, so names line up; and the + that adds one, on hover.+    const tog=document.createElement('button');tog.type='button';tog.className='sub-tog';tog.setAttribute('aria-label','Fold or unfold the subtasks');+    tog.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';+    lDiv.appendChild(tog); r._subTog=tog;     lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));+    const add=document.createElement('button');add.type='button';add.className='row-add';add.title='Add a subtask';+    add.setAttribute('aria-label','Add a subtask');add.innerHTML=PLUS_SVG;+    lDiv.appendChild(add);     lDiv.appendChild(makeEditBtn(r));     lDiv.appendChild(makeLinkBtn(r));     r._nameSpan=lDiv.querySelector('.row-name');@@ -7056,6 +7565,7 @@ function relayout(){     else if(r.id&&barEls[r.id]){const b=barEls[r.id].bar,x1=toX(r.start),x2=toX(r.end);b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';}   });   positionToday();positionDates();+  paintSpans();   layoutMsCaps();   sizeTailroom();   drawDeps();@@ -7454,7 +7964,7 @@ function depRowRec(r){ // are used only while it is unchanged. function depGeoSig(){   let dim=0; items.forEach((r,i)=>{ if(r._bDiv&&r._bDiv.classList.contains('dim'))dim=(dim*31+i)%1000000007; });-  return [W,prefs.fs||1,document.documentElement.dataset.ms||'',items.length,[...collapsed].sort().join(','),dim].join('|');+  return [W,prefs.fs||1,document.documentElement.dataset.ms||'',items.length,[...collapsed].sort().join(','),[...collapsedRows].sort().join(','),dim].join('|'); } // Everything the routing needs, measured once per redraw rather than once per arrow. The // rows are walked exactly once here: this runs on every mousemove of a bar drag, so the@@ -8109,7 +8619,9 @@ function clipOf(r){     // The row's OWN tags travel with it (0.60.0). Its section's do not need to: a copy     // pasted into another section takes that section's, which is what "effective" means.     tags:ownTags(r).slice(), effort:effortOf(r),-    deps:Array.isArray(r.deps)?r.deps.slice():[]};+    deps:Array.isArray(r.deps)?r.deps.slice():[],+    // Where it sat (0.79.0): a subtask copied with its task is pasted under the task's copy.+    srcUid:r.uid||'', srcParent:treeParent(r)}; } // Ctrl+C. The selection is already the app's one selection model, so this works the // same from either lens and with either kind of row in it.@@ -8290,6 +8802,24 @@ async function pasteRows(){     const deps=c.deps.map(d=>idMap.has(d)?idMap.get(d):d).filter(d=>d&&d!==clones[i].id);     if(deps.length)clones[i].deps=deps;   });+  // Their place (0.79.0): straight after the row they were pasted below, as its siblings,+  // or at the end of the section in view.+  // A row copied together with the task it sits under is pasted under that task's copy, in+  // the order copied; the rest go in as siblings.+  { const below=tgt.below&&isLive(tgt.below)?tgt.below:null;+    let par=below&&treeParent(below)?treeRows.get(treeParent(below)):null;+    const ph=below?below.phase:tgt.phase;+    const byUid=new Map(); clipRows.forEach((c,i)=>{ if(c.srcUid)byUid.set(c.srcUid,clones[i]); });+    const roots=[], kidsOf=new Map();+    clipRows.forEach((c,i)=>{ const p=c.srcParent&&byUid.get(c.srcParent);+      if(p&&p!==clones[i]&&p.type!=='milestone'){ if(!kidsOf.has(p))kidsOf.set(p,[]); kidsOf.get(p).push(clones[i]); } else roots.push(clones[i]); });+    const h=r=>{ let m=0; (kidsOf.get(r)||[]).forEach(k=>{ m=Math.max(m,1+h(k)); }); return m; };+    let after=below;+    // Too deep under the row's task: the copies go in after that task's top-level row instead.+    if(par&&treeDepth(par)+1+Math.max(0,...roots.map(h))>TREE_DEPTH_MAX){ let top=below; while(top&&treeParent(top))top=treeRows.get(treeParent(top)); par=null; after=top; }+    clones.forEach(c=>{ c.phase=ph; });+    placeRowsAt(roots,par,ph,after||(siblingsIn(null,ph,null).slice(-1)[0]||null));+    kidsOf.forEach((ks,p)=>{ const keys=keysBetween(null,null,ks.length); ks.forEach((k,j)=>{ k.parent=p.uid; k.pos=keys[j]; }); }); }   // Into the plan, in copy order, each one below the last.   const steps=[];   clones.forEach((r,i)=>{@@ -8352,7 +8882,7 @@ labelsEl.addEventListener('click',e=>{   // that ends somewhere else never clears a boolean, and the stale flag then eats   // the next real click.   if(Date.now()-dragEndedAt<150)return;-  if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit'))return;+  if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit')||e.target.closest('.sub-tog, .row-add, .lane-add, .qa-row'))return;   // The status pill is its own control. A plain click on it edits the status and   // goes no further, so it never also opens the name editor or selects the row. A   // modified click falls through to selection, the same as clicking the name.@@ -8589,11 +9119,86 @@ function rowYCenter(el){return el.offsetTop+el.offsetHeight/2;} function hideDropIndicators(){   labelDropInd.classList.remove('show');   barDropInd.classList.remove('show');+  clearNestMark();+}++// ── Dropping rows (0.79.0) ──+//+// Where a drag lands, in either lens. `seq` is what the person can see, in plan order (the+// chart's section headers included), less the rows being dragged and their subtasks; `i` is+// the row under the pointer and `frac` how far down it the pointer is; `dx` how far the+// pointer has travelled sideways since the drag began. Onto the middle of a task: the rows+// become its last subtasks. Between two rows: a move, at the level the sideways travel picks+// among the levels that gap allows, and the drop line is indented to show it (ClickUp's+// List and Gantt drag; Notion's outline does the same with the pointer's x).+const DROP_STEP=24;+function dropAt(seq,i,frac,dx,moving){+  const rows=treeRoots(moving); if(!rows.length)return null;+  const R=seq[i]; if(!R)return null;+  if(frac>=0.25&&frac<=0.75&&!isDerivedGroup(R)&&canNestUnder(rows,R))return {kind:'nest',target:R,rows};+  const above=frac<0.5, X=above?(seq[i-1]||null):R, Y=above?R:(seq[i+1]||null);+  const hmax=Math.max(0,...rows.map(r=>treeHeight(tree,rowTreeKey(r))));+  let phase, dmin=0, dmax=0;+  // In the Table there are no section headers: the top half of a section's first row is the+  // top of that section, as it always was there.+  const edge=X&&!isDerivedGroup(X)&&Y&&!isDerivedGroup(Y)&&above&&Y.phase!==X.phase;+  if(!X||isDerivedGroup(X)||edge){ phase=edge?Y.phase:X?X.phase:(Y?Y.phase:0); }+  else {+    phase=X.phase;+    dmax=Math.min(treeDepth(X)+(canNestUnder(rows,X)?1:0),TREE_DEPTH_MAX-hmax);+    dmin=(Y&&!isDerivedGroup(Y)&&Y.phase===phase)?treeDepth(Y):0;+  }+  if(dmin>dmax)return null;+  const d=Math.max(dmin,Math.min(dmax,treeDepth(rows[0])+Math.round(dx/DROP_STEP)));+  const up=x=>x&&treeParent(x)?treeRows.get(treeParent(x))||null:null;+  let parent=null, after=null;+  if(X&&!isDerivedGroup(X)&&!edge){+    if(d>0){ let a=X; while(a&&treeDepth(a)>d-1)a=up(a); parent=a||null; }+    if(treeDepth(X)>=d){ let b=X; while(b&&treeDepth(b)>d)b=up(b); after=b||null; }+  }+  if(parent&&!canNestUnder(rows,parent))return null;+  return {kind:'gap',parent,phase:parent?parent.phase:phase,after,depth:d,X,Y,above,rows};+}+// The drop, applied: one undo entry, one save, the rows' subtasks with them.+function applyDrop(drop,via){+  if(!drop)return false;+  const rows=drop.rows, set=new Set(rows);+  const before=placeSnap(rows);+  if(drop.kind==='nest'){+    const t=drop.target;+    placeRowsAt(rows,t,t.phase,lastKid(t,set));+    if(collapsedRows.has(rowTreeKey(t)))setRowFolded(t,false);+  } else {+    // Dropped where it already is: nothing to do, and no undo entry for it.+    if(rows.length===1){ const r=rows[0], pk=drop.parent?rowTreeKey(drop.parent):'';+      if((treeParent(r)||'')===pk&&r.phase===drop.phase&&sibNext(r,-1)===drop.after)return false; }+    placeRowsAt(rows,drop.parent,drop.phase,drop.after);+  }+  pushUndo(before);+  placeHeaders();+  openLandingLanes(rows);+  applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();+  moveHint=rows.length===1?'"'+(rows[0].name||'a row')+'"':rows.length+' rows';+  scheduleSave(via);+  return true;+}+function clearNestMark(){ document.querySelectorAll('.nest-target').forEach(x=>x.classList.remove('nest-target')); }+// The chart's indicator for a drop: the line between two rows, indented to the level it+// lands at, or the task it would go under, outlined.+function showChartDrop(drop){+  clearNestMark();+  if(!drop){ hideDropIndicators(); return; }+  if(drop.kind==='nest'){ hideDropIndicators(); const t=drop.target; if(t._lDiv)t._lDiv.classList.add('nest-target'); if(t._bDiv)t._bDiv.classList.add('nest-target'); return; }+  const ref=drop.above?drop.Y:drop.X; if(!ref||!ref._lDiv){ hideDropIndicators(); return; }+  const lt=drop.above?ref._lDiv.offsetTop:ref._lDiv.offsetTop+ref._lDiv.offsetHeight;+  const bt=drop.above?ref._bDiv.offsetTop:ref._bDiv.offsetTop+ref._bDiv.offsetHeight;+  labelDropInd.style.top=lt+'px'; labelDropInd.style.left=(drop.depth*18*(prefs.fs||1)+(drop.depth?8:0))+'px'; labelDropInd.classList.add('show');+  barDropInd.style.top=bt+'px'; barDropInd.classList.add('show'); }- function wireLabelDragArm(){ labelsEl.addEventListener('mousedown',e=>{   if(e.target.closest('.status-pill, .owner-pill'))return;   // editing a pill, not starting a drag+  if(e.target.closest('.sub-tog, .row-add, .lane-add, .qa-row'))return;   // a fold, a quick add (0.79.0)   const row=e.target.closest('.label-row[data-id], .label-row.ms');   if(!row)return;   // The row handle drags the row, up and down only: a handle moves a row, it does not@@ -8887,6 +9492,12 @@ function deletePhase(i,reassignTo){   if(rows.length&&(reassignTo===undefined||reassignTo===null||reassignTo===i||!phases[reassignTo]))return false;   const sn=phaseSnapshot();   const name=p.name, dest=rows.length?phases[reassignTo].name:null;+  // The section's top-level rows join the destination's where they stood in the plan: ahead+  // of its own rows when the section came before it, after them when it came after, in their+  // order, their subtasks with them (0.79.0: a row's place is its key, so they get keys there).+  const tops=rows.filter(r=>!treeParent(r)), there=siblingsIn(null,reassignTo,new Set(rows));+  const keys=i<reassignTo?keysBetween(null,there.length?there[0].pos:null,tops.length):keysBetween(there.length?there[there.length-1].pos:null,null,tops.length);+  tops.forEach((r,k)=>{ r.pos=keys[k]; });   rows.forEach(r=>{ r.phase=reassignTo; });   const map=new Map();   phases.forEach((_,oi)=>{ if(oi<i)map.set(oi,oi); else if(oi>i)map.set(oi,oi-1); });@@ -9778,25 +10389,31 @@ function openBatchEditor(){   const earliest=tasks.reduce((a,r)=>r.start<a?r.start:a,tasks[0].start);   const latest=tasks.reduce((a,r)=>r.end>a?r.end:a,tasks[0].end); -  editorEl.innerHTML=`<div class="drag-bar"></div>-    <div class="batch-title">${tasks.length} task${tasks.length===1?'':'s'} selected${others?`, ${others} milestone${others===1?'':'s'} unchanged`:''} <button class="de-btn secondary" id="beCopyIds" style="float:right;padding:2px 9px;font-size:10px" title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button></div>-    <div class="batch-hint">All changes are relative, each task shifts from its own dates</div>-    <div style="margin-bottom:10px">-      <div class="de-row">-        <div><label>earliest start</label><input type="date" id="bStart" value="${earliest}"></div>-        <div><label>latest end</label><input type="date" id="bEnd" value="${latest}"></div>-      </div>-      <div style="font-size:10px;color:var(--text2);margin-top:2px">Changing a date shifts all selected by the same offset</div>-    </div>-    <div style="border-top:1px solid var(--border-muted);padding-top:8px;margin-bottom:4px">-      <div class="batch-row"><label>shift all</label><input type="number" id="bShiftAll" value="0" step="1"> <span>days</span></div>-      <div class="batch-row"><label>shift start</label><input type="number" id="bShiftS" value="0" step="1"> <span>days</span></div>-      <div class="batch-row"><label>shift end</label><input type="number" id="bShiftE" value="0" step="1"> <span>days</span></div>-    </div>-    <div class="batch-tags" id="bTagsWrap"></div>-    <div class="batch-tags" id="bEffWrap"></div>-    <div class="de-actions"><button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button><button class="de-btn danger" id="bDelete" title="${esc(batchDeleteTitle(rows.length,others))}">Delete</button></div>`;-  showEditor(window.innerWidth/2-155,window.innerHeight/2-130,'compact');+  // The layout every panel has (edShell, 0.79.0). The two dates and the three shifts land+  // on Apply; the tags and the effort under them act at once, so they are set apart.+  const shift=(label,id)=>edF(label,`<input type="number" id="${id}" value="0" step="1">`,{w:'third',for:id,hint:'days'});+  editorEl.innerHTML=edShell({+    title:`${tasks.length} task${tasks.length===1?'':'s'} selected`,+    sub:(others?`${others} milestone${others===1?'':'s'} unchanged. `:'')+'All changes are relative, each task shifts from its own dates',+    body:edF('earliest start',`<input type="date" id="bStart" value="${earliest}">`,{w:'half',for:'bStart'})+      +edF('latest end',`<input type="date" id="bEnd" value="${latest}">`,{w:'half',for:'bEnd'})+      +'<div class="ed-note">Changing a date shifts all selected by the same offset</div>'+      +shift('shift all','bShiftAll')+shift('shift start','bShiftS')+shift('shift end','bShiftE')+      +'<div class="ed-f sep" id="bTagsWrap"></div>'+      +'<div class="ed-f sep" id="bEffWrap"></div>',+    actions:'<button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button>'+      +'<button class="de-btn secondary" id="beCopyIds" title="Copy the selected items\' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>'+      +`<button class="de-btn danger" id="bDelete" title="${esc(batchDeleteTitle(rows.length,others))}">Delete</button>`,+  });+  renderBatchTags();+  renderBatchEffort();+  // In the middle of the room the window leaves it, measured once it is filled in.+  showEditor((w,h)=>({x:(window.innerWidth-w)/2,y:(window.innerHeight-edBottomBand()-h)/2}));+  // Enter applies and Escape cancels, as in the item editor and the New panel. The tag and+  // effort boxes keep their own Enter (it adds the tag, it sets the effort) and Escape.+  setEditorKeys(e=>{e.stopPropagation();+    if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('bApply').click();}+    else if(e.key==='Escape'){e.preventDefault();closeEditor();}});    $('bApply').addEventListener('click',()=>{     const newStart=$('bStart').value, newEnd=$('bEnd').value;@@ -9814,8 +10431,6 @@ function openBatchEditor(){   $('bClose').addEventListener('click',closeEditor);   $('bDelete').addEventListener('click',()=>deleteBatchFromPanel());   wireBatchCopy();-  renderBatchTags();-  renderBatchEffort(); } // Effort across the selection (0.63.1). Like the tags, it acts at once rather than on // Apply: one press sets (or, with the box empty, clears) the estimate on every selected@@ -9825,13 +10440,16 @@ function openBatchEditor(){ function renderBatchEffort(){   const w=$('bEffWrap'); if(!w)return;   const tasks=selectedRows().filter(r=>r&&!isGroup(r)&&!isMs(r)&&r.id);-  if(!tasks.length){ w.innerHTML=''; return; }+  if(!tasks.length){ w.innerHTML=''; w.style.display='none'; return; }+  w.style.display='';   const est=tasks.filter(r=>effortOf(r)!==null), sum=est.reduce((a,r)=>a+effortOf(r),0);   const same=est.length===tasks.length&&est.every(r=>effortOf(r)===effortOf(est[0]));-  w.innerHTML=`<div class="batch-row bt-row"><label>effort</label><input type="text" id="bEffort" inputmode="decimal" autocomplete="off" placeholder="hours, 2d, 1w" title="${esc(EFFORT_HELP)}" aria-label="Effort to set on every selected task" value="${same?esc(String(effortOf(est[0]))):''}">`+  // A field of the panel's grid (edF's shape: the label, then one block), filled in place.+  w.innerHTML=`<label for="bEffort">effort <span class="de-hint">on every selected task, saved at once</span></label><div class="ed-c"><div class="ed-inline">`+    +`<input type="text" id="bEffort" inputmode="decimal" autocomplete="off" placeholder="hours, 2d, 1w" title="${esc(EFFORT_HELP)}" aria-label="Effort to set on every selected task" value="${same?esc(String(effortOf(est[0]))):''}">`     +`<button type="button" class="de-btn secondary" id="bEffortSet" title="Set this effort on all ${plural(tasks.length,'selected task')}, or with the box empty, clear it. Milestones carry none. One Ctrl+Z takes it back.">Set on all</button></div>`     +`<div class="bt-none" id="bEffSum">${est.length} of ${plural(tasks.length,'task')} estimated${est.length?', '+esc(fmtEffort(sum))+' in all':''}</div>`-    +`<div class="bt-none" id="bEffErr" style="color:#f85149;display:none"></div>`;+    +`<div class="bt-none" id="bEffErr" style="color:var(--alarm);display:none"></div></div>`;   const inp=$('bEffort');   const go=()=>{     const v=parseEffort(inp.value);@@ -9862,10 +10480,11 @@ function renderBatchTags(){   const rows=selectedRows().filter(r=>r&&!isGroup(r));   const m=new Map();   rows.forEach(r=>ownTags(r).forEach(t=>{ const k=tagKey(t); if(!m.has(k))m.set(k,{t,n:0}); m.get(k).n++; }));-  w.innerHTML=`<div class="batch-row bt-row"><label>tags</label><input type="text" id="bTag" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" placeholder="a tag" aria-label="A tag to add to every selected row">`+  w.innerHTML=`<label for="bTag">tags <span class="de-hint">on every selected row, saved at once</span></label><div class="ed-c"><div class="ed-inline">`+    +`<input type="text" id="bTag" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" placeholder="a tag" aria-label="A tag to add to every selected row">`     +`<button type="button" class="de-btn secondary" id="bTagAdd" title="Add this tag to all ${plural(rows.length,'selected row')}, milestones included. One Ctrl+Z takes it back.">Add to all</button></div>`     +(m.size?`<div class="bt-chips">`+[...m.values()].map(e=>`<span class="sec-tag"${tagAttrs(e.t)} title="${esc(e.t)}: on ${e.n} of the ${rows.length} selected">${esc(e.t)} <small>${e.n}</small><button type="button" class="bt-x" data-tag="${esc(e.t)}" title="Remove ${esc(e.t)} from every selected row that carries it" aria-label="Remove ${esc(e.t)} from every selected row">&times;</button></span>`).join('')+'</div>'-      :`<div class="bt-none">No selected row carries a tag of its own.</div>`);+      :`<div class="bt-none">No selected row carries a tag of its own.</div>`)+'</div>';   const inp=$('bTag');   const add=()=>{ const words=inp.value.split(',').map(x=>x.trim()).filter(Boolean); if(!words.length){ inp.focus(); return; }     batchTags(r=>[...ownTags(r),...words]); };@@ -10106,7 +10725,7 @@ document.addEventListener('mousemove',e=>{         if(grow.uid&&!selected.has(grow.uid)){selected.clear();selected.add(grow.uid);selAnchor=grow.uid;refreshSelVisuals();updateSelCount();}         document.body.style.cursor='ew-resize';       } else {-        reorderState={active:true,id:null};+        reorderState={active:true,id:null,x0:gesture.startX};         closeEditor();         const moveIds=(grow.uid&&selected.has(grow.uid)&&selected.size>1)           ? selectedRows().map(x=>x.uid) : [grow.uid];@@ -10143,7 +10762,7 @@ document.addEventListener('mousemove',e=>{       // Vertical → reorder. This one IS a row move rather than a date move, so it       // carries the whole set including any milestones in it, which is what       // Alt+Up and Alt+Down have always done. Keyed by uid for the same reason.-      reorderState={active:true,id};+      reorderState={active:true,id,x0:gesture.startX};       closeEditor();       const dr=items.find(d=>d.id===id);       const moveIds=(dr&&dr.uid&&selected.has(dr.uid)&&selected.size>1)@@ -10221,25 +10840,19 @@ document.addEventListener('mousemove',e=>{   if(reorderState&&reorderState.active){     const labelsRect=labelsEl.getBoundingClientRect();     const mouseY=e.clientY-labelsRect.top+labelsEl.scrollTop;-    let targetIdx=-1, insertBefore=true;-    for(let i=0;i<items.length;i++){-      const r=items[i];if(reorderState.moveIds.includes(r.uid))continue;-      const cy=rowYCenter(r._lDiv);-      if(mouseY<cy){targetIdx=i;insertBefore=true;break;}-      targetIdx=i;insertBefore=false;-    }-    if(targetIdx>=0){-      const ref=items[targetIdx];-      // Both columns are measured against their own offset parent, and the cursor-      // was converted into that same space above, so the line sits on the slot-      // boundary the pointer is actually nearest.-      labelDropInd.style.top=(insertBefore?ref._lDiv.offsetTop:ref._lDiv.offsetTop+ref._lDiv.offsetHeight)+'px';labelDropInd.classList.add('show');-      barDropInd.style.top=(insertBefore?ref._bDiv.offsetTop:ref._bDiv.offsetTop+ref._bDiv.offsetHeight)+'px';barDropInd.classList.add('show');-      reorderState.targetIdx=targetIdx;reorderState.insertBefore=insertBefore;-    } else {-      hideDropIndicators();-      reorderState.targetIdx=null;-    }+    // The rows on screen, less the ones being dragged and their subtasks (0.79.0). Both+    // columns are measured against their own offset parent, and the cursor was converted+    // into that same space above, so the line sits on the slot boundary the pointer is+    // actually nearest.+    const moving=reorderState.moveIds.map(rowByUid).filter(Boolean);+    const skip=new Set(moving); moving.forEach(m=>treeDesc(m).forEach(x=>skip.add(x)));+    const seq=items.filter(r=>!skip.has(r)&&r._lDiv&&r._lDiv.offsetHeight>0);+    let at=-1, frac=1;+    for(let k=0;k<seq.length;k++){ const el=seq[k]._lDiv, t=el.offsetTop, h=el.offsetHeight; if(mouseY<t+h){ at=k; frac=Math.max(0,(mouseY-t)/h); break; } }+    if(at<0&&seq.length)at=seq.length-1;+    const drop=at>=0?dropAt(seq,at,frac,e.clientX-(reorderState.x0||e.clientX),moving):null;+    reorderState.drop=drop;+    showChartDrop(drop);   } }); @@ -10262,40 +10875,7 @@ document.addEventListener('mouseup',()=>{     hideDropIndicators();     reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});     document.body.style.cursor='';-    if(reorderState.targetIdx!=null){-      snapshotOrder();-      const moveItems=reorderState.moveIds.map(rowByUid).filter(Boolean);-      const remainingItems=items.filter(r=>!reorderState.moveIds.includes(r.uid));-      const actualInsert=remainingItems.indexOf(items[reorderState.targetIdx]);-      const finalInsert=reorderState.insertBefore?actualInsert:actualInsert+1;-      remainingItems.splice(finalInsert,0,...moveItems);-      items.length=0;items.push(...remainingItems);-      // Where a row lands is what lane it is in: the group header above it is the-      // lane. Dropping across a header therefore changes the phase, and the bar-      // recolours to match, rather than leaving a row sitting under a header it-      // does not belong to.-      const phaseChanges=[];-      moveItems.forEach(mi=>{-        const at=items.indexOf(mi);-        let g=null;-        for(let i=at;i>=0;i--){ if(items[i].type==='group'){g=items[i];break;} }-        if(g&&g.phase!==undefined&&g.phase!==mi.phase){-          phaseChanges.push({r:mi,from:mi.phase});-          mi.phase=g.phase;-          if(mi.id&&barEls[mi.id])barEls[mi.id].bar.style.background=phaseColor(g.phase);-          if(mi._msLine)mi._msLine.style.color=phaseColor(g.phase);-        }-      });-      if(phaseChanges.length){-        // fold the phase restore into the order snapshot already pushed, so the-        // whole gesture is still one Ctrl+Z-        const top=undoStack[undoStack.length-1];-        if(top&&top.type==='order')top.phases=phaseChanges;-        moveHint=moveItems.length===1?'"'+(moveItems[0].name||'a row')+'"':moveItems.length+' rows';-      }-      applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();-      drawDeps();scheduleSave('drag-reorder');-    }+    if(reorderState.drop)applyDrop(reorderState.drop,reorderState.drop.kind==='nest'?'drag-nest':'drag-reorder');     reorderState=null;   } });@@ -10753,7 +11333,7 @@ $('btnExport').addEventListener('click',e=>{ // sidecar. `getState()` is a delta for the server to merge; this is the whole // merged plan, which is what an export is. function clientMergedData(){-  const FIELDS=['type','uid','id','name','label','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags'];+  const FIELDS=['type','uid','parent','pos','id','name','label','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags'];   const clean=r=>{const o={};FIELDS.forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});     if(o.status)o.status=statusKey(o.status);        // the canonical vocabulary, as the server normalises on the way out     tidyTags(o);                                      // the data-file rule: a non-empty list or no key@@ -10775,7 +11355,7 @@ function clientMergedData(){ const CSV_FORMULA_LEAD=/^[=+\-@\t\r]/; function csvGuardCell(s){return CSV_FORMULA_LEAD.test(s)?"'"+s:s;} function clientItemsToCSV(itemsArr,phasesArr){-  const headers=['uid','id','name','start','end','phase','owner','status','description','provenance','provenance_url','deps','tags','type','label','date','striped'];+  const headers=['uid','parent','id','name','start','end','phase','owner','status','description','provenance','provenance_url','deps','tags','type','label','date','striped'];   const cell=v=>{if(v==null)return '';const s=csvGuardCell(String(v));     return (s.includes(',')||s.includes('"')||s.includes('\n')||s.includes('\r'))?'"'+s.replace(/"/g,'""')+'"':s;};   const phaseName=i=>{const p=phasesArr&&phasesArr[i];return p&&p.name?p.name:(i==null?'':i);};@@ -10830,6 +11410,44 @@ async function doExport(fmt){  // ── Import menu ── let importFormat = null;+// The files the chooser offers for each format.+const IMPORT_ACCEPT={json:'.json,.csv,.xml',csv:'.csv','msproject-xml':'.xml','jira-csv':'.csv','clickup-csv':'.csv','clickup-json':'.json'};+// A ClickUp import answers with a report: what changed on the way in and what did not come+// across. The import reloads this tab (and every other open one), so the report is held in+// this tab's session storage across the reload and shown once, over the plan it produced.+// Nobody else's tab gets it: they did not import anything.+const IMPORT_REPORT_KEY='adom-gantt.importReport';+// Shown once: the key is taken out of storage before anything is drawn, so a refresh, or a+// later reload for somebody else's change, never brings it back. It sits where the+// undated-rows warning sits and closes with its x; nothing else on the page waits on it.+function showImportReport(){+  if(SNAPSHOT)return;+  let held=null;+  try{ const raw=sessionStorage.getItem(IMPORT_REPORT_KEY); sessionStorage.removeItem(IMPORT_REPORT_KEY); if(raw)held=JSON.parse(raw); }catch(e){ held=null; }+  const rp=held&&held.report;+  const box=$('importReport');+  if(!rp||typeof rp!=='object'||!box)return;+  const arr=v=>Array.isArray(v)?v:[];+  const num=v=>Number.isFinite(+v)?+v:0;+  const nw=(k,one,many)=>`${k} ${k===1?one:many}`;+  const kinds=[nw(num(rp.tasks),'task','tasks')]; if(num(rp.milestones))kinds.push(nw(num(rp.milestones),'milestone','milestones'));+  const extra=[]; if(num(rp.subtasks))extra.push(`${num(rp.subtasks)} of them subtasks`); if(num(rp.undated))extra.push(`${num(rp.undated)} undated`); if(num(rp.deps))extra.push(nw(num(rp.deps),'dependency','dependencies'));+  const list=(title,rows,cls)=>rows.length?`<h4>${esc(title)}</h4><ul${cls?` class="${cls}"`:''}>${rows.map(x=>`<li>${x}</li>`).join('')}</ul>`:'';+  const changed=arr(rp.changed).map(s=>esc(s));+  const statuses=arr(rp.statuses).map(s=>`<span class="ir-cu">${esc(s.name)}</span> &rarr; ${esc(statusTextFor(s.to))} <span class="ir-dim">(${num(s.count)}${s.by==='guess'?', a guess':s.by==='date'?', it has a done date':''})</span>`);+  const dropped=arr(rp.dropped).map(d=>`${esc(d.what)}${num(d.count)?` <span class="ir-dim">on ${nw(num(d.count),'task','tasks')}</span>`:''}${d.detail?`<span class="ir-dim">: ${esc(d.detail)}</span>`:''}`);+  box.innerHTML=`<div class="ir-head"><div class="ir-title">Imported from ClickUp</div>`+    +`<button type="button" class="ir-close" id="irClose" title="Close the import report" aria-label="Close the import report">&times;</button></div>`+    +`<div class="ir-body"><div class="ir-sum">${esc(nw(num(rp.rows),'row','rows'))} in ${esc(nw(num(rp.sections),'section','sections'))}: ${esc(kinds.join(' and '))}${extra.length?', '+esc(extra.join(', ')):''}. `+    +`<span class="ir-dim">From ${held.file?esc(held.file)+', ':''}${/^ClickUp API/.test(rp.from||'')?'the':'a'} ${esc(rp.from||'ClickUp export')}.</span></div>`+    +(rp.unchecked?`<div class="ir-note">${esc(rp.unchecked)}</div>`:'')+    +list('Changed on the way in',changed)+    +list('Statuses',statuses,'ir-st')+    +list('Not carried over',dropped)+    +`</div>`;+  box.classList.add('show');+  $('irClose').addEventListener('click',()=>box.classList.remove('show'));+} function wireImportAndHelp(){ $('btnImport').addEventListener('click',e=>{   closeMenus();const rect=e.currentTarget.getBoundingClientRect();@@ -10838,9 +11456,11 @@ $('btnImport').addEventListener('click',e=>{     <div class="dd-item" data-fmt="json">JSON (adom-gantt)</div>     <div class="dd-item" data-fmt="csv">CSV</div>     <div class="dd-item" data-fmt="msproject-xml">MS Project XML</div>-    <div class="dd-item" data-fmt="jira-csv">Jira CSV</div>`;+    <div class="dd-item" data-fmt="jira-csv">Jira CSV</div>+    <div class="dd-item" data-fmt="clickup-csv" title="A List or Table view's Export view (CSV), or the workspace export">ClickUp CSV</div>+    <div class="dd-item" data-fmt="clickup-json" title="What ClickUp's API answers for a List's tasks, saved to a file. It carries the dependencies a CSV export leaves out">ClickUp API JSON</div>`;   importMenu.style.left=Math.min(rect.left,window.innerWidth-200)+'px';importMenu.style.top=(rect.bottom+4)+'px';importMenu.classList.add('show');-  importMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{importFormat=item.dataset.fmt;importFileEl.accept=importFormat.includes('xml')?'.xml':importFormat.includes('csv')?'.csv':'.json,.csv,.xml';importFileEl.click();closeMenus();});});+  importMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{importFormat=item.dataset.fmt;importFileEl.accept=IMPORT_ACCEPT[importFormat]||'.json,.csv,.xml';importFileEl.click();closeMenus();});});   e.stopPropagation(); }); @@ -10848,9 +11468,14 @@ importFileEl.addEventListener('change',async e=>{   const file=e.target.files[0];if(!file)return;   const text=await file.text();   try{-    const r=await fetch(`import/${importFormat}`,{method:'POST',headers:{'Content-Type':'text/plain'},body:text});+    // A ClickUp task with no date lands on the day of the import (Drew, 2026-11-03): this+    // person's day, not the server's, which may already be tomorrow.+    const q=importFormat.startsWith('clickup')?'?today='+isoDay(new Date()):'';+    const r=await fetch(`import/${importFormat}${q}`,{method:'POST',headers:{'Content-Type':'text/plain'},body:text});     const res=await r.json();-    if(res.ok){flash(saveInd,`imported ${res.items} items`);setTimeout(()=>location.reload(),500);}+    if(res.ok){+      if(res.report){ try{ sessionStorage.setItem(IMPORT_REPORT_KEY,JSON.stringify({report:res.report,file:file.name})); }catch(err){} }+      flash(saveInd,`imported ${res.items} items`);setTimeout(()=>location.reload(),500);}     else{alert('Import failed: '+(res.error||'unknown error'));}   }catch(err){alert('Import error: '+err.message);}   importFileEl.value='';@@ -11723,6 +12348,104 @@ function wireTagBox(box,initial,kind,onChange){   return { get:()=>{ take(); return list.slice(); }, input:inp, box }; } +// ── One layout for every panel that edits a task (0.79.0) ──+//+// The item editor (a task or a milestone, from the chart or from the Table), the New panel+// and the batch panel are three jobs with one look: the same width, the same title, the+// same grid of labelled fields and the same row of buttons, all of it in the app's text+// size. edShell is the frame and edF is one field, so a field added to any of them (the+// subtasks branch's parent, which goes full width under the section) is laid out like the+// rest without any CSS of its own. The stylesheet's "edit panel" block says how the grid+// works; here is only what goes in it.+//+// o.title   the title, already escaped+// o.sub     an optional line under it, already escaped+// o.body    the fields (edF ... or an .ed-note)+// o.actions the buttons+// o.err     the id of the panel's error line, which sits over the buttons+function edShell(o){+  return '<div class="drag-bar" aria-hidden="true"></div>'+    +`<div class="ed-head"><div class="ed-title" id="edTitle">${o.title}</div>${o.sub?`<div class="ed-sub">${o.sub}</div>`:''}</div>`+    +`<div class="ed-body"><div class="ed-grid">${o.body}</div></div>`+    +`<div class="ed-foot">${o.err?`<div class="ed-err" id="${o.err}" style="display:none"></div>`:''}<div class="de-actions">${o.actions}</div></div>`;+}+// One field: the label (and its small print) over the control. `w` is 'half', 'third' or+// 'twothirds' for a field that shares its row, nothing for one that takes the whole row;+// `id` names the field for the code that hides it (a milestone's start); `for` ties the+// label to the control; `labelId` names the label for the code that rewords it.+function edF(label,control,o){+  o=o||{};+  return `<div class="ed-f${o.w?' '+o.w:''}${o.sep?' sep':''}"${o.id?` id="${o.id}"`:''}>`+    +`<label${o.for?` for="${o.for}"`:''}${o.labelId?` id="${o.labelId}"`:''}>${label}${o.hint?` <span class="de-hint">${o.hint}</span>`:''}</label>`+    +`<div class="ed-c">${control}</div></div>`;+}+// A text that can run long (a name, a note, the bar label, a provenance, an owner) is a+// textarea that wraps and grows: one line high while it fits, as many lines as it needs+// when it does not, as it is typed and when the panel opens. It is still one line of+// text: Enter applies the panel as it always did (the panel's keys take it), and a line+// break that arrives in a paste becomes a space, so what is saved is exactly what the+// one-line field saved. Its value is assigned by the caller, never interpolated here.+function edTa(id,o){+  o=o||{};+  return `<textarea id="${id}" class="ed-grow" rows="1"${o.ph?` placeholder="${esc(o.ph)}"`:''}${o.max?` maxlength="${o.max}"`:''}${o.attrs||''}></textarea>`;+}+// Fit one growing field to its text. Set to auto first, so it can SHRINK when text goes;+// the body keeps its scroll position across the moment the field is one line high.+function edGrow(ta){+  if(!ta||!ta.isConnected||!ta.offsetParent)return;+  const body=ta.closest('.ed-body'), keep=body?body.scrollTop:0;+  ta.style.height='auto';+  const cs=getComputedStyle(ta);+  ta.style.height=Math.ceil(ta.scrollHeight+(parseFloat(cs.borderTopWidth)||0)+(parseFloat(cs.borderBottomWidth)||0))+'px';+  if(body&&body.scrollTop!==keep)body.scrollTop=keep;+}+// A select cannot wrap, and a section's name can be longer than its field: the field's+// tooltip is the whole name of the section it holds, kept as the pick changes.+function edSelTitle(sel){+  if(!sel)return;+  const set=()=>{ const o=sel.options[sel.selectedIndex]; sel.title=o?o.textContent:''; };+  sel.addEventListener('change',set); set();+}+// Every growing field, then the panel back inside the window. Run when a panel opens and+// whenever the width or the type can have changed under it (a resize, the text size, the+// web fonts arriving), since each of those rewraps the text.+function edFit(){+  if(!editorEl.classList.contains('show'))return;+  edSetMaxH();+  editorEl.querySelectorAll('textarea.ed-grow').forEach(edGrow);+  edKeepInView();+}+function edOneLine(ta){+  const v=ta.value; if(!/[\r\n]/.test(v))return;+  const flat=s=>s.replace(/\r\n?|\n/g,' '), at=ta.selectionStart;+  ta.value=flat(v);+  if(typeof at==='number'){ const p=flat(v.slice(0,at)).length; try{ ta.setSelectionRange(p,p); }catch(_){} }+}+// A field growing at the foot of a body that has started to scroll keeps its last line in+// view, so nobody types below the edge of the panel.+function edShowField(ta){+  const body=ta.closest('.ed-body'); if(!body||document.activeElement!==ta)return;+  const b=body.getBoundingClientRect(), t=ta.getBoundingClientRect();+  if(t.bottom>b.bottom)body.scrollTop+=Math.ceil(t.bottom-b.bottom)+2;+  else if(t.top<b.top)body.scrollTop-=Math.ceil(b.top-t.top)+2;+}+// Wired once, on the panel element, which outlives every panel built in it. The input+// listener CAPTURES, so a field has its new height before its own listeners run (the+// owner field places its list under itself on input, and must measure the grown field).+function wireEditorFit(){+  editorEl.addEventListener('input',e=>{+    const t=e.target;+    if(!t||!t.matches||!t.matches('textarea.ed-grow'))return;+    edOneLine(t); edGrow(t); edShowField(t);+  },true);+  // The panel changes size as its fields grow, its lists fill and its fields show or hide;+  // whatever the cause, it is moved back inside the window. Only its PLACE changes here,+  // never its size, so this can never feed itself.+  if(typeof ResizeObserver==='function')new ResizeObserver(()=>edKeepInView()).observe(editorEl);+  window.addEventListener('resize',()=>edFit());+  try{ if(document.fonts&&document.fonts.addEventListener)document.fonts.addEventListener('loadingdone',()=>edFit()); }catch(_){}+}+ // ── The full item editor ── // // Everything about one row in one panel: the same fields the create panel offers,@@ -11766,35 +12489,35 @@ function openItemModal(r,carriedName,opts){   //   THE BUTTONS are Apply / Cancel / Delete rather than Create / Cancel, with Delete   //   holding the margin-left:auto that has kept the destructive button away from the   //   other two since 0.37.0.-  editorEl.innerHTML=`<div class="drag-bar"></div>-    <div class="batch-title">Edit ${ms?'milestone':'task'}</div>-    <div class="de-row">-      <div><label>type</label><select id="imType" disabled title="A task has a start and an end, a milestone has one date. Changing one into the other is not an edit, so make the row you want and delete this one."><option value="task">Task</option><option value="milestone">Milestone</option></select></div>-      <div style="flex:1"><label>phase</label><select id="imPhase">${phases.map((ph,i)=>`<option value="${i}">${esc(ph.name)}</option>`).join('')}</select></div>-    </div>-    <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="imName" placeholder="What is it?"></div>-    <div class="de-row" style="margin-top:8px">-      <div style="flex:1"><label>owner</label><input type="text" id="imOwner" autocomplete="off" placeholder="one name"></div>-      <div style="flex:1"><label>status</label><select id="imStatus"><option value="">none</option>${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>-    </div>-    <div class="de-row">-      <div id="imStartWrap"><label>start</label><input type="date" id="imStart"></div>-      <div><label id="imEndLabel">deadline</label><input type="date" id="imEnd"></div>-      <div id="imEffWrap"><label>effort <span class="de-hint">hours</span></label><input type="text" id="imEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}"></div>-    </div>-    <div class="de-note-row"><label>notes</label><input type="text" id="imDesc" placeholder="Shown on hover and in the table"></div>-    <div class="de-note-row"><label>provenance <span class="de-hint">${p.url?'a linked source, kept as it is':'where this came from'}</span></label>${p.url-      ?`<a class="prov-fixed" id="imProvLink" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" title="${esc(p.label+', '+p.url)}">${esc(p.label)}</a>`-      :'<input type="text" id="imProv">'}</div>-    <div class="de-note-row"><label>tags <span class="de-hint">Enter or a comma adds one</span></label>${tagBoxHtml('imTags','Tags on this row')}</div>-    <div class="de-note-row" id="imNoteWrap"><label>label on the bar <span class="de-hint">a few characters, e.g. 6m</span></label><input type="text" id="imNote" maxlength="24" placeholder="e.g. 6m"></div>-    <div class="de-dep-section" id="imDepsWrap"><label>dependencies</label><ul class="dep-list" id="imDepList"></ul>-      <select id="imAddDep" style="margin-top:4px"></select></div>-    <div class="de-actions"><button class="de-btn primary" id="imApply">Apply</button>-      <button class="de-btn secondary" id="imCancel">Cancel</button>-      <button class="de-btn secondary" id="imCopyLink" title="Copy a link to this row, to paste in Chat">Copy link</button>-      <button class="de-btn danger" id="imDelete" title="Delete this row from the plan. Undoable with Ctrl+Z">Delete</button></div>-    <div id="imErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;+  //+  // The layout is every panel's (edShell, 0.79.0): type and section side by side (the+  // section's name is the longer, so it has two thirds of the row), the name across, owner+  // and status, the two dates, effort and the bar label (task only: both are hidden on a+  // milestone, so its next row closes up), notes, provenance beside tags, and the dependency+  // list. A subtask's parent (the subtasks branch) goes full width under the section.+  editorEl.innerHTML=edShell({+    title:ms?'Edit milestone':'Edit task',+    err:'imErr',+    body:edF('type','<select id="imType" disabled title="A task has a start and an end, a milestone has one date. Changing one into the other is not an edit, so make the row you want and delete this one."><option value="task">Task</option><option value="milestone">Milestone</option></select>',{w:'third',for:'imType'})+      +edF('phase',`<select id="imPhase">${phases.map((ph,i)=>`<option value="${i}">${esc(ph.name)}</option>`).join('')}</select>`,{w:'twothirds',for:'imPhase'})+      +edF('name',edTa('imName',{ph:'What is it?'}),{for:'imName',hint:'required'})+      +edF('owner',edTa('imOwner',{ph:'one name',attrs:' autocomplete="off"'}),{w:'half',for:'imOwner'})+      +edF('status',`<select id="imStatus"><option value="">none</option>${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select>`,{w:'half',for:'imStatus'})+      +edF('start','<input type="date" id="imStart">',{w:'half',id:'imStartWrap',for:'imStart'})+      +edF('deadline','<input type="date" id="imEnd">',{w:'half',for:'imEnd',labelId:'imEndLabel'})+      +edF('effort',`<input type="text" id="imEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}">`,{w:'half',id:'imEffWrap',for:'imEffort',hint:'hours'})+      +edF('label on the bar',edTa('imNote',{ph:'a few characters',max:24}),{w:'half',id:'imNoteWrap',for:'imNote',hint:'e.g. 6m'})+      +edF('notes',edTa('imDesc',{ph:'Shown on hover and in the table'}),{for:'imDesc'})+      +edF('provenance',p.url+        ?`<a class="prov-fixed" id="imProvLink" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" title="${esc(p.label+', '+p.url)}">${esc(p.label)}</a>`+        :edTa('imProv'),{w:'half',for:p.url?'':'imProv',hint:p.url?'a link, kept as it is':'where this came from'})+      +edF('tags',tagBoxHtml('imTags','Tags on this row'),{w:'half',for:'imTagsIn',hint:'Enter or a comma adds one'})+      +edF('dependencies','<ul class="dep-list" id="imDepList"></ul><select id="imAddDep"></select>',{id:'imDepsWrap',sep:true,for:'imAddDep'}),+    actions:'<button class="de-btn primary" id="imApply">Apply</button>'+      +'<button class="de-btn secondary" id="imCancel">Cancel</button>'+      +'<button class="de-btn secondary" id="imCopyLink" title="Copy a link to this row, to paste in Chat">Copy link</button>'+      +'<button class="de-btn danger" id="imDelete" title="Delete this row from the plan. Undoable with Ctrl+Z">Delete</button>',+  });   // values assigned, never interpolated, so nothing typed here can become markup   $('imName').value=carriedName!==undefined?carriedName:(r.name||'');   $('imPhase').value=String(r.phase);@@ -11859,11 +12582,15 @@ function openItemModal(r,carriedName,opts){   if(secT.length)$('imTags').title='Its section also gives it: '+secT.join(', ')+'. Those are edited on the Setup page.';   // Beside what was clicked: the bar, the milestone or the Deps cell, flipped to its left   // when the right has no room; otherwise beside the row's label. placeEditor clamps it.+  // Placed once its real size is known (it depends on the text size and the window).   const rect=opts.anchor||(r._lDiv||document.body).getBoundingClientRect();-  const PW=440;-  let px=rect.right+12; if(opts.anchor&&px+PW>window.innerWidth)px=Math.max(8,rect.left-PW-12);-  showEditor(px,rect.top,false);+  showEditor(w=>{+    let px=rect.right+12;+    if(opts.anchor&&px+w>window.innerWidth-ED_MARGIN)px=Math.max(ED_MARGIN,rect.left-w-12);+    return {x:px,y:rect.top};+  });   ['imPhase','imStatus','imAddDep'].forEach(id=>{ const el=$(id); if(el)wireAppSelect(el); });+  edSelTitle($('imPhase'));   wireOwnerCombo($('imOwner'));   if(opts.focus==='deps'&&canDep)$('imAddDep').focus();   else{ $('imName').focus();$('imName').select(); }@@ -11892,7 +12619,7 @@ function openItemModal(r,carriedName,opts){     // the table's phase cell already records its move.     if(moves){       const ed=undoStack.pop();-      pushUndo({type:'multi',steps:[{type:'phase',r,phase:r.phase,order:items.map(rowOrderKey)},ed]});+      pushUndo({type:'multi',steps:[placeSnap([r]),ed]});     }     r.name=name;     const end=$('imEnd').value;@@ -11983,30 +12710,28 @@ function openCreateEditor(){   // following the signed-in identity rather than freezing a name into the session.   const provDefault=USER_NAME?('Added by '+USER_NAME):'Added by hand';   const provVal=(memo.prov!==undefined&&memo.prov!==null)?memo.prov:provDefault;-  editorEl.innerHTML=`<div class="drag-bar"></div>-    <div class="batch-title">New item</div>-    <div class="de-row">-      <div><label>type</label><select id="ncType"><option value="task">Task</option><option value="milestone">Milestone</option></select></div>-      <div style="flex:1"><label>phase</label><select id="ncPhase">${phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('')}</select></div>-    </div>-    <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="ncName" placeholder="What is it?"></div>-    <div class="de-row" style="margin-top:8px">-      <div style="flex:1"><label>owner</label><input type="text" id="ncOwner" autocomplete="off" placeholder="one name" value="${esc(ownerVal)}"></div>-      <div style="flex:1"><label>status</label><select id="ncStatus">${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>-    </div>-    <div class="de-row">-      <div id="ncStartWrap"><label>start</label><input type="date" id="ncStart" value="${startVal}"></div>-      <div><label id="ncEndLabel">deadline</label><input type="date" id="ncEnd" value="${endVal}"></div>-      <div id="ncEffWrap"><label>effort <span class="de-hint">hours</span></label><input type="text" id="ncEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}" value="${memo.effort===null||memo.effort===undefined?'':esc(String(memo.effort))}"></div>-    </div>-    <div class="de-note-row"><label>notes</label><input type="text" id="ncDesc" placeholder="Shown on hover and in the table" value="${esc(descVal)}"></div>-    <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="ncProv" value="${esc(provVal)}"></div>-    <div class="de-note-row"><label>tags <span class="de-hint">Enter or a comma adds one</span></label>${tagBoxHtml('ncTags','Tags on the new row')}</div>-    <div class="de-actions"><button class="de-btn primary" id="ncCreate">Create</button>-      <button class="de-btn secondary" id="ncCancel">Cancel</button></div>-    <div id="ncErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;+  // The same layout as the item editor, field for field (edShell, 0.79.0).+  editorEl.innerHTML=edShell({+    title:'New item',+    err:'ncErr',+    body:edF('type','<select id="ncType"><option value="task">Task</option><option value="milestone">Milestone</option></select>',{w:'third',for:'ncType'})+      +edF('phase',`<select id="ncPhase">${phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('')}</select>`,{w:'twothirds',for:'ncPhase'})+      +edF('name',edTa('ncName',{ph:'What is it?'}),{for:'ncName',hint:'required'})+      +edF('owner',edTa('ncOwner',{ph:'one name',attrs:' autocomplete="off"'}),{w:'half',for:'ncOwner'})+      +edF('status',`<select id="ncStatus">${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select>`,{w:'half',for:'ncStatus'})+      +edF('start',`<input type="date" id="ncStart" value="${startVal}">`,{w:'half',id:'ncStartWrap',for:'ncStart'})+      +edF('deadline',`<input type="date" id="ncEnd" value="${endVal}">`,{w:'half',for:'ncEnd',labelId:'ncEndLabel'})+      +edF('effort',`<input type="text" id="ncEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}" value="${memo.effort===null||memo.effort===undefined?'':esc(String(memo.effort))}">`,{w:'half',id:'ncEffWrap',for:'ncEffort',hint:'hours'})+      +edF('notes',edTa('ncDesc',{ph:'Shown on hover and in the table'}),{for:'ncDesc'})+      +edF('provenance',edTa('ncProv'),{w:'half',for:'ncProv',hint:'where this came from'})+      +edF('tags',tagBoxHtml('ncTags','Tags on the new row'),{w:'half',for:'ncTagsIn',hint:'Enter or a comma adds one'}),+    actions:'<button class="de-btn primary" id="ncCreate">Create</button><button class="de-btn secondary" id="ncCancel">Cancel</button>',+  });+  // Assigned, never interpolated, like the item editor's fields.+  $('ncOwner').value=ownerVal; $('ncDesc').value=descVal; $('ncProv').value=provVal;+  // Under the New button, its right edge on the button's.   const r=$('btnNew').getBoundingClientRect();-  showEditor(r.left-220,r.bottom+8,false);+  showEditor(w=>({x:r.right-w,y:r.bottom+8}));   // The three selects carry too, and they are set BEFORE syncType runs: a carried   // milestone has to open with its start field already hidden and its second date   // already labelled "date", not flip a frame later. A carried phase that no longer@@ -12026,6 +12751,7 @@ function openCreateEditor(){   // are six names typed and nothing else.   const tagBox=wireTagBox($('ncTags'),memo.tags||[],'nc');   ['ncType','ncPhase','ncStatus'].forEach(id=>{ const el=$(id); if(el)wireAppSelect(el); });+  edSelTitle($('ncPhase'));   wireOwnerCombo($('ncOwner'));   $('ncName').focus();   $('ncCancel').addEventListener('click',closeEditor);@@ -12106,6 +12832,131 @@ function openCreateEditor(){ // identifiers together on one muted line, email shown and not hidden behind a // hover. No orgs at this size, and no kebab: the card is display, the rows below // are the actions.+// ── Quick add (0.79.0, issue #6) ──+//+// A task typed in place: the + on a section's header (or the "+ Add task" line under a+// section in the Table) opens a blank row at the end of that section, the + on a task opens+// one as its last subtask. Enter adds the task and leaves the row open for the next one;+// Esc, or Enter on an empty row, stops. Each task is made the way New makes one: one undo+// entry, one changelog line, a row in the sidecar's `added`. Its fields are New's remembered+// ones (owner, status, tags) and New's default dates, then whatever the view is filtered to+// (an owner, a status, a tag), so the new row stays on screen, as ClickUp fills a new task+// from the view's filters. Everything else is edited afterwards, as now.+let qa=null;+function openQuickAdd(o){+  if(depMode)setDepMode(false);+  closeEditor(); closeQuickAdd(true);+  const parent=o&&o.parent||null;+  if(parent&&!canHoldKids(parent)){ flash(saveInd,parent.type==='milestone'?'a milestone cannot hold subtasks':'subtasks go '+TREE_DEPTH_MAX+' levels deep at most',3200); return; }+  const phase=parent?parent.phase:(o&&Number.isInteger(o.phase)?o.phase:0);+  if(!phases[phase])return;+  if(parent&&collapsedRows.has(rowTreeKey(parent))){ setRowFolded(parent,false); refreshTreeDom(); applyCollapse(); }+  if(isCollapsed(phase))setCollapsed(phase,false);+  const inp=document.createElement('input');+  inp.type='text'; inp.className='qa-in'; inp.autocomplete='off'; inp.spellcheck=false;+  inp.placeholder=(parent?'Subtask of '+(parent.name||'this task'):'Task in '+(phases[phase].name||'this section'))+'. Enter adds it, Esc stops';+  inp.setAttribute('aria-label',parent?'Name of the new subtask':'Name of the new task');+  qa={phase,parent,lens:prefs.view==='table'?'table':'gantt',depth:parent?treeDepth(parent)+1:0,input:inp,queue:Promise.resolve(),pending:0,made:0,from:o&&o.from||parent||null};+  wireQuickAddInput(inp);+  if(qa.lens==='gantt'){+    const l=document.createElement('div'); l.className='qa-row'; l.style.setProperty('--depth',String(qa.depth));+    const plus=document.createElement('span'); plus.className='qa-plus'; plus.innerHTML=PLUS_SVG; l.appendChild(plus); l.appendChild(inp);+    const b=document.createElement('div'); b.className='qa-bar';+    qa.l=l; qa.b=b;+    placeQuickAdd();+    sizeTailroom(); drawDeps();+  } else renderTable();+  try{ inp.focus({preventScroll:true}); }catch(e){ inp.focus(); }+  const el=qa.lens==='gantt'?qa.l:inp.closest('tr');+  if(el&&el.scrollIntoView)el.scrollIntoView({block:'nearest'});+  if(qa.lens==='gantt')syncLabelScroll();+}+// Where the blank row sits: under the last row of the section, or of the task's subtree.+function quickAddAnchor(){+  if(!qa)return null;+  if(qa.parent){ const d=treeDesc(qa.parent); return d.length?d[d.length-1]:qa.parent; }+  const rows=items.filter(r=>!isDerivedGroup(r)&&r.phase===qa.phase);+  return rows.length?rows[rows.length-1]:items.find(r=>isDerivedGroup(r)&&r.phase===qa.phase)||null;+}+// The chart's blank row follows its anchor through every reflow, keeping the person's typing+// and the caret: it is not one of the plan's rows, so a reflow does not place it.+function isQaEl(el){ return el.classList.contains('qa-row')||el.classList.contains('qa-bar'); }+function placeQuickAdd(){+  if(!qa||qa.lens!=='gantt'||!qa.l)return;+  if(qa.parent&&!isLive(qa.parent)){ closeQuickAdd(); return; }+  const a=quickAddAnchor(); if(!a||!a._lDiv||!a._bDiv){ closeQuickAdd(); return; }+  if(a._lDiv.nextSibling===qa.l&&a._bDiv.nextSibling===qa.b)return;+  const inp=qa.input, had=document.activeElement===inp, s0=inp.selectionStart, s1=inp.selectionEnd;+  qa.moving=true;+  labelsEl.insertBefore(qa.l,a._lDiv.nextSibling); bars.insertBefore(qa.b,a._bDiv.nextSibling);+  qa.moving=false;+  if(had){ try{ inp.focus({preventScroll:true}); inp.setSelectionRange(s0,s1); }catch(e){} }+}+function closeQuickAdd(quiet){+  if(!qa)return;+  const q=qa; qa=null;+  if(q.l){ q.l.remove(); q.b.remove(); if(!quiet){ sizeTailroom(); drawDeps(); } }+  if(q.lens==='table'&&!quiet)renderTable();+}+function wireQuickAddInput(inp){+  inp.addEventListener('keydown',e=>{+    e.stopPropagation();+    if(e.key==='Enter'){+      e.preventDefault();+      const name=inp.value.trim();+      if(!name){ if(!qa||!qa.pending)closeQuickAdd(); return; }+      inp.value='';+      quickAddCreate(name);+    } else if(e.key==='Escape'){ e.preventDefault(); closeQuickAdd(); }+  });+  inp.addEventListener('mousedown',e=>e.stopPropagation());+  inp.addEventListener('click',e=>e.stopPropagation());+  // Clicking away from an empty row puts it away; a name being typed stays until Esc.+  inp.addEventListener('blur',()=>{ setTimeout(()=>{ if(qa&&qa.input===inp&&!qa.moving&&!qa.rendering&&document.activeElement!==inp&&!inp.value.trim()&&!qa.pending)closeQuickAdd(); },200); });+}+// The values a quick-added row starts with: New's remembered owner, status and tags and New's+// default dates, then the view's own filters.+function quickAddDefaults(){+  const memo=lastCreated||{}, today=isoDay(new Date());+  const start=memo.start||today, end=memo.end||addDays(today,7);+  const o={start:start<=end?start:end,end,owner:memo.owner||'',status:memo.status&&STATUS_ORDER.includes(memo.status)?memo.status:'not-started',tags:(memo.tags||[]).slice()};+  if(prefs.ownerFilter)o.owner=prefs.ownerFilter===OWNER_NONE?'':prefs.ownerFilter;+  if(statusSel&&statusSel.value)o.status=statusSel.value;+  if(prefs.tagFilter&&prefs.tagFilter.length){ const want=tagFilterKeys(), sec=new Set(((phases[qa.phase]||{}).tags||[]).map(tagKey));+    if(![...want].some(k=>sec.has(k))&&!o.tags.some(t=>want.has(tagKey(t)))){ const t=usedTags().find(e=>want.has(e.key)); if(t)o.tags.push(t.tag); } }+  const provDefault=USER_NAME?('Added by '+USER_NAME):'Added by hand';+  o.prov=(memo.prov!==undefined&&memo.prov!==null)?memo.prov:provDefault;+  return o;+}+// One task from the blank row. Made in the order the names were entered, each after the one+// before it, however quickly they come (a uid is a round trip to the server).+function quickAddCreate(name){+  const q=qa; if(!q)return;+  q.pending++;+  q.queue=q.queue.then(async()=>{+    let uid=null;+    try{ uid=await allocUid(); }+    catch(err){ flash(saveInd,'no item ID could be allocated, so "'+name+'" was not added ('+err.message+')',5000); return; }+    if(q.parent&&!isLive(q.parent)){ flash(saveInd,'its task was deleted, so "'+name+'" was not added',5000); return; }+    const d=quickAddDefaults();+    const r={type:'task',uid,name,phase:q.parent?q.parent.phase:q.phase,id:freshId(name),start:d.start,end:d.end,status:d.status};+    if(d.owner)r.owner=d.owner;+    if(d.tags.length)r.tags=normalizeTags(d.tags);+    if(d.prov)r.provenance=d.prov;+    if(q.parent)r.parent=rowTreeKey(q.parent);+    const sib=siblingsIn(q.parent,r.phase,null);+    r.pos=keyBetween(sib.length?sib[sib.length-1].pos:null,null);+    addedItems.push(r);+    insertRow(r);+    pushUndo({type:'create',r});+    q.made++;+    renderTable();+    rebuildTimeline();+    if(r.tags&&r.tags.length)afterTagsChange();+    scheduleSave('quick-add');+  }).catch(e=>{ try{ console.warn('adom-gantt: quick add',e); }catch(_){} }).finally(()=>{ q.pending--; });+  return q.queue;+} function openWhoMenu(){   const chip=$('whoChip'); if(!chip||!ADOM_USER)return;   closeMenus();@@ -12140,7 +12991,11 @@ $('btnNew').addEventListener('click',e=>{e.stopPropagation();closeMenus();openCr // rebuilt: the browser reflows and the JS re-measures. Everything downstream // that was measured in pixels (milestone cap collision rows, the tailroom, the // dependency arrows) has to be re-run after a change, which is what relayout does.-function applyFontScale(){ document.documentElement.style.setProperty('--fs', String(prefs.fs)); }+function applyFontScale(){+  document.documentElement.style.setProperty('--fs', String(prefs.fs));+  // An open panel is in the app's text size too (0.79.0): its fields rewrap at the new size.+  if(editorEl.classList.contains('show'))edFit();+} function syncFsCtl(){   const i = FS_STEPS.indexOf(prefs.fs);   const pct = $('fsPct');@@ -12294,12 +13149,11 @@ document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.targ // size, not of the plan, so it goes nowhere near the sidecar or the changelog and a // reload puts auto-placement back. Nothing about it is a preference to save. let edDrag=null, edManualPos=null;-// The panel's own size, for clamping. Read from the live box when it has one; the-// square side is the fallback for a panel that has not been laid out yet.+// The panel's own size, for clamping, read from the live box (showEditor shows the panel+// before anything places it, so the box is real by then). function edSize(){   const r=editorEl.getBoundingClientRect();-  const sq=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ed-sq'),10)||256;-  return {w:r.width||sq, h:r.height||sq};+  return {w:r.width||400, h:r.height||400}; } // The one place a panel is positioned. Callers pass where they WOULD like it; if the // user has dragged a panel this session that wish is overridden, and either way the@@ -12311,19 +13165,43 @@ function edSize(){ // pixels away from where it was left, which is exactly the kind of small lie this // feature cannot afford: the promise is that it opens where you put it. const ED_MARGIN=8;+// The band along the bottom edge that no panel enters (0.79.0): the selection chip, the+// undo toast, the save indicator and the save-blocked banner live there and sit above the+// panel, and the chip once covered Apply on a tall editor. Its height is the stylesheet's+// --ed-bottom, which grows while the banner is up; a data warning that stands taller than+// that is cleared too.+function edBottomBand(){+  let b=parseFloat(getComputedStyle(editorEl).getPropertyValue('--ed-bottom'))||60;+  const dwEl=$('dataWarn'), dw=dwEl&&dwEl.classList.contains('show')?dwEl.getBoundingClientRect():null;+  if(dw&&dw.height)b=Math.max(b,Math.ceil(window.innerHeight-dw.top)+8);+  return b;+} function edClampX(x,w){ return Math.round(Math.max(ED_MARGIN,Math.min(x,window.innerWidth-w-ED_MARGIN))); }-function edClampY(y,h){ return Math.round(Math.max(ED_MARGIN,Math.min(y,window.innerHeight-h-ED_MARGIN))); }+function edClampY(y,h){ return Math.round(Math.max(ED_MARGIN,Math.min(y,window.innerHeight-edBottomBand()-h))); }+// The panel's height is capped by the stylesheet (max-height) at the window less the top+// margin and the bottom band, so a taller panel scrolls its body; this keeps that cap equal+// to the band edBottomBand measures, data warning included.+function edSetMaxH(){+  const mh=Math.max(120,window.innerHeight-ED_MARGIN-edBottomBand());+  editorEl.style.maxHeight=mh+'px';+}+// The cap and the place again, for a change to the band rather than to the panel.+function edReclamp(){ if(!editorEl.classList.contains('show'))return; edSetMaxH(); edKeepInView(); }+// Back inside the window: after the panel grew (a field wrapped, a list filled), after the+// window or the text size changed. Moves it, never resizes it.+function edKeepInView(){+  if(!editorEl.classList.contains('show'))return;+  const r=editorEl.getBoundingClientRect();+  const x=edClampX(r.left,r.width), y=edClampY(r.top,r.height);+  if(Math.abs(x-r.left)>=1)editorEl.style.left=x+'px';+  if(Math.abs(y-r.top)>=1)editorEl.style.top=y+'px';+} function placeEditor(x,y){   if(edManualPos){x=edManualPos.left;y=edManualPos.top;}   const {w,h}=edSize();   editorEl.style.left=edClampX(x,w)+'px';   editorEl.style.top =edClampY(y,h)+'px'; }-// The square side, as a number, for the auto-placement maths. Read from the same-// custom property the stylesheet sizes the panel with, so there is one source of it.-function edSquareSide(){-  return parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ed-sq'),10)||256;-} // Show the panel in the shape this caller needs, then position it. Shown BEFORE // placing on purpose: a display:none box measures zero, and the clamp is only honest // if it measures the real one. Both happen inside one task, so nothing paints between@@ -12367,19 +13245,20 @@ function syncEditorDates(r){   set('deStart',r.start); set('deEnd',r.end);   set('imStart',r.start); set('imEnd',r.end); }-// The shape a caller wants, named rather than flagged. 'square' is the fixed-side task-// panel; 'compact' is the same dense metrics without the fixed aspect, for the panels-// whose content is a different shape (the milestone panel is short and wide, the batch-// panel is a list of shifts). Anything else is a full-size panel: the item modal and the-// create card carry a name, an owner, a status and a provenance field, they are read and-// filled rather than glanced at, and 0.47.0 deliberately leaves their metrics alone.-function showEditor(x,y,shape){+// Every panel has one shape since 0.79.0 (the square task panel and the compact metrics+// are gone), so a caller says only where it would like the panel: a point, or a function+// of the panel's measured width and height that returns one, for a caller that places the+// panel by its size (beside a bar, flipped when the right has no room; centred).+function showEditor(x,y){   // Every panel in the app opens through here, so this is the one place an armed   // Delete has to be withdrawn when the user's attention moves to a panel instead.   disarmDelete();-  editorEl.classList.toggle('square',shape==='square');-  editorEl.classList.toggle('compact',shape==='square'||shape==='compact');   editorEl.classList.add('show');+  edSetMaxH();+  // The fields at their real height before anything is measured: a long name opens+  // wrapped, not cut to one line and grown a frame later.+  editorEl.querySelectorAll('textarea.ed-grow').forEach(edGrow);+  if(typeof x==='function'){ const {w,h}=edSize(); const at=x(w,h); x=at.x; y=at.y; }   placeEditor(x,y); } // ── One Tab per field in the panels, the way the table already walks a row ──@@ -12424,8 +13303,18 @@ function wireEditorTab(){     // A text field is landed on the way Tab lands on one, with its contents selected.     // A date field is not: select() on one throws in Blink, and there is nothing to     // select anyway since it lands on its first segment.-    if(next.tagName==='INPUT'&&next.type!=='date'&&next.select)next.select();+    if(((next.tagName==='INPUT'&&next.type!=='date')||next.tagName==='TEXTAREA')&&next.select)next.select();   },true);+  // A growing field (a textarea since 0.79.0) is landed on by Tab the way the one-line+  // field it replaced was: its whole text selected, so typing replaces it. The browser+  // does that for an input and not for a textarea, so it is done here, for a Tab only;+  // a click still puts the caret where it lands.+  let tabAt=0;+  editorEl.addEventListener('keydown',e=>{ if(e.key==='Tab')tabAt=performance.now(); },true);+  editorEl.addEventListener('focusin',e=>{+    const t=e.target;+    if(t&&t.matches&&t.matches('textarea.ed-grow')&&performance.now()-tabAt<500){ try{ t.select(); }catch(_){} }+  }); } function wireEditorDrag(){ editorEl.addEventListener('mousedown',e=>{@@ -12435,7 +13324,7 @@ editorEl.addEventListener('mousedown',e=>{   // The header is in here rather than carrying its own handler so there is still exactly   // one drag in this panel, and grabbing the name behaves the same as grabbing the grip   // down to the clamp and the remembered position.-  if(!e.target.closest('.drag-bar,.de-title'))return;+  if(!e.target.closest('.drag-bar,.ed-head'))return;   e.preventDefault();   const rect=editorEl.getBoundingClientRect();   edDrag={sx:e.clientX-rect.left,sy:e.clientY-rect.top,moved:false};@@ -12459,6 +13348,12 @@ document.addEventListener('mouseup',()=>{     // not a choice and deliberately does not pin anything.     const r=editorEl.getBoundingClientRect();     edManualPos={left:r.left,top:r.top};+    // The release is not a click away from the panel. A panel pushed against an edge (or+    // into the bottom band, 0.79.0) stops while the pointer goes on, so the button comes up+    // outside it, and the click the browser makes of the press and the release landed on+    // the page and closed the panel that had just been put there. Every click handler+    // already ignores the click that ends a drag; this marks this drag as one.+    dragEndedAt=Date.now();   }   document.body.classList.remove('ed-dragging');   edDrag=null;@@ -12666,6 +13561,9 @@ function tableHiddenWhy(shown,total){ function tableRowIn(r){   const q=(searchInp.value||'').trim().toLowerCase(), st=statusSel.value;   if(isGroup(r))return false;+  // A folded task's subtasks are out, and a dimmed task is in only for a subtask (0.79.0).+  if(manualOrder()){ let p=treeParent(r); while(p){ if(collapsedRows.has(p))return false; p=tree.parentOf.get(p); } }+  if(tableCtx.has(r))return true;   if(r.phase!==undefined&&(isCollapsed(r.phase)||!phaseVis[r.phase]))return false;   if(!rowShown(r,tagFilterKeys()))return false;   if(isMs(r)&&!msVis)return false;@@ -12688,20 +13586,27 @@ function tableRowIn(r){ function patchTableRows(rows,fields){   if(!tableBody||!tableRowsCache||fields.has('deps')||fields.has('name')||fields.has('phase'))return false;   if(tblEditing)return false;+  // A task's hours with its subtasks' (0.79.0) change with a subtask's, so its row is rewritten too.+  if(fields.has('effort')){ const more=new Set(rows); rows.forEach(r=>{ let p=treeParent(r); while(p){ const x=treeRows.get(p); if(x)more.add(x); p=tree.parentOf.get(p); } }); rows=[...more]; }   const c=tableRowsCache, at=new Map(c.keys.map((k,i)=>[k,i]));-  const touched=new Set(rows.map(r=>rowKey(r)));+  const touched=new Set(rows.map(r=>rowKey(r))), ctx0=tableCtx;   let now, keys, html;   withDepIndex(()=>{     now=tableRows();     if(!now.length)return;+    // A task shown dimmed for its subtasks' sake, or no longer (0.79.0), is written again too.+    now.forEach(r=>{ if(tableCtx.has(r)!==ctx0.has(r))touched.add(rowKey(r)); });     keys=now.map(r=>rowKey(r));     html=now.map((r,i)=>{ const j=at.get(keys[i]); return j===undefined||touched.has(keys[i])||tableForceKeys.has(keys[i])?tableRowHtml(r):c.html[j]; });   });   if(!now.length)return false;   // the empty message is renderTable's+  const qaSel=tableQaFocus();+  tableQaLines(now,keys,html);   reconcileTable(keys,html);   tableForceKeys.clear();   tableRowsCache={keys,html};   syncTableCount(now.length);+  tableQaAttach(qaSel);   markFocusedRow(); paintCritTable(); paintPeerFocus();   return true; }@@ -12709,12 +13614,14 @@ function patchTableRows(rows,fields){ // whose markup is the same keeps its element, a changed or new row gets a new one, a row no // longer listed goes, and only rows out of place are moved. With nothing to keep (the first // render, the empty message) or most rows new, the body is written in one go, which is faster.+// The quick-add lines (0.79.0) are keyed like the rows: 'qa:' and the section, or 'qa:entry'.+function tableKidKey(el){ return el&&el.dataset?(el.dataset.key||(el.dataset.qa!==undefined?'qa:'+el.dataset.qa:'')):''; } function reconcileTable(keys,html){   const c=tableRowsCache, kids=tableBody.children;   let byKey=null;   if(c&&kids.length===c.keys.length){     byKey=new Map();-    for(let i=0;i<kids.length;i++){ const el=kids[i]; if(!el.dataset||el.dataset.key!==c.keys[i]){ byKey=null; break; } byKey.set(c.keys[i],{el,html:c.html[i]}); }+    for(let i=0;i<kids.length;i++){ const el=kids[i]; if(tableKidKey(el)!==c.keys[i]){ byKey=null; break; } byKey.set(c.keys[i],{el,html:c.html[i]}); }   }   if(!byKey){ tableBody.innerHTML=html.join(''); return; }   const want=new Array(keys.length); let fresh=0;@@ -12733,28 +13640,40 @@ function syncTableCount(n){   countEl.textContent = n===total ? `${total} items` : `${n} of ${total} items`;   countEl.title = n===total ? 'Every task and milestone in the plan is listed.' : tableHiddenWhy(n,total); }+// Rows in the Table. In plan order, subtasks follow the tree (0.79.0): a folded task hides+// them, and a task that does not pass the filters but has a subtask that does stays on screen,+// dimmed, for context (tableCtx). Under a sort every row stands on its own, as ClickUp shows+// subtasks as separate tasks to sort them.+let tableCtx=new Set();+function tableRowPasses(r,q,st,ks){+  if(isGroup(r))return false;+  // A collapsed phase hides its rows in the table too, so the two lenses agree: a+  // milestone (and every task) in a collapsed lane drops out of the list exactly as+  // its caps and bars drop off the chart. isCollapsed is read straight off the set+  // rather than the per-row _collapsed flag, so it is right even before a reflow.+  if(r.phase!==undefined&&isCollapsed(r.phase))return false;+  if(r.phase!==undefined&&!phaseVis[r.phase])return false;+  // The tag filter is a ROW rule since 0.60.0: own tags plus the section's. Since+  // 0.64.0 rowShown also carries the owner (one choice shared with the chart's legend)+  // and the Overdue chip, so the table no longer filters owners on its own.+  if(!rowShown(r,ks))return false;+  if(isMs(r)&&!msVis)return false;+  if(st&&statusOf(r)!==st)return false;+  if(q&&tableSearchHay(r).indexOf(q)<0)return false;+  return true;+} function tableRows(){   const q=(searchInp.value||'').trim().toLowerCase();   const st=statusSel.value, ks=tagFilterKeys();-  const rows=[];-  items.forEach(r=>{-    if(isGroup(r))return;-    // A collapsed phase hides its rows in the table too, so the two lenses agree: a-    // milestone (and every task) in a collapsed lane drops out of the list exactly as-    // its caps and bars drop off the chart. isCollapsed is read straight off the set-    // rather than the per-row _collapsed flag, so it is right even before a reflow.-    if(r.phase!==undefined&&isCollapsed(r.phase))return;-    if(r.phase!==undefined&&!phaseVis[r.phase])return;-    // The tag filter is a ROW rule since 0.60.0: own tags plus the section's. Since-    // 0.64.0 rowShown also carries the owner (one choice shared with the chart's legend)-    // and the Overdue chip, so the table no longer filters owners on its own.-    if(!rowShown(r,ks))return;-    if(isMs(r)&&!msVis)return;-    const o=ownerOf(r);-    if(st&&statusOf(r)!==st)return;-    if(q&&tableSearchHay(r).indexOf(q)<0)return;-    rows.push(r);-  });+  let rows=items.filter(r=>tableRowPasses(r,q,st,ks));+  tableCtx=new Set();+  if(manualOrder()&&tree.order.some(k=>tree.parentOf.get(k))){+    const ok=new Set(rows), up=new Set(), folded=new Set();+    ok.forEach(r=>{ let p=treeParent(r); while(p){ const x=treeRows.get(p); if(!x||ok.has(x)||up.has(x))break; up.add(x); p=tree.parentOf.get(p); } });+    tree.order.forEach(k=>{ const p=tree.parentOf.get(k); if(p&&(collapsedRows.has(p)||folded.has(p)))folded.add(k); });+    rows=items.filter(r=>!isGroup(r)&&!folded.has(rowTreeKey(r))&&(ok.has(r)||(up.has(r)&&!isCollapsed(r.phase)&&phaseVis[r.phase]!==false)));+    rows.forEach(r=>{ if(!ok.has(r))tableCtx.add(r); });+  }   if(manualOrder())return rows;      // already in plan order   const val=r=>sortValueOf(r,sortKey);   // A blank sorts last in both directions: an unassigned row should never push@@ -12868,6 +13787,8 @@ function renderTable(){     tableRowsCache=null;     tableBody.innerHTML='<tr><td colspan="13" class="table-empty">'+why+'</td></tr>';return;}   const keys=rows.map(r=>rowKey(r));+  const qaSel=tableQaFocus();+  tableQaLines(rows,keys,html);   // Keyed patch (0.75.1). Only the rows whose markup changed are replaced: rewriting all   // ~500 rows cost the browser about half a second of style and layout on every save   // anyone made, for one changed cell. Since 0.78.1 also when rows moved, came or went@@ -12875,10 +13796,44 @@ function renderTable(){   reconcileTable(keys,html);   tableForceKeys.clear();   tableRowsCache={keys,html};+  tableQaAttach(qaSel);   markFocusedRow();   paintCritTable();   paintPeerFocus(); }+// Quick add in the Table (0.79.0): the blank row being typed into, spliced in among the rows'+// keys and markup. Keyed like the rows, so a live change still patches the table instead of rebuilding it.+function tableQaLines(rows,keys,html){+  if(!manualOrder()&&qa&&qa.lens==='table'){+    // Under a sort the rows of a section are not together, so the blank row opens under the row+    // whose + was pressed; what it adds takes its place in the sort.+    const i=qa.from?rows.indexOf(qa.from):-1;+    if(i>=0){ keys.splice(i+1,0,'qa:entry'); html.splice(i+1,0,`<tr class="qa-entry" data-qa="entry"><td colspan="13" class="qa-cell"><span class="t-ind"></span></td></tr>`); }+  }+  if(manualOrder()){+    // The blank row goes under the last row on screen of its section, or of its task's subtree.+    let at=null;+    if(qa&&qa.lens==='table')for(let i=rows.length-1;i>=0&&!at;i--){ const x=rows[i]; if(qa.parent?(x===qa.parent||treeUnderRow(x,qa.parent)):x.phase===qa.phase)at=x; }+    for(let i=rows.length-1;i>=0;i--){+      const ph=rows[i].phase;+      // No "+ Add task" line under each section (0.79.0, Drew's default): quick add is the + on+      // hover, on a row for a subtask, on a section's header in the chart, and New for the rest.+      if(at&&rows[i]===at){ keys.splice(i+1,0,'qa:entry'); html.splice(i+1,0,`<tr class="qa-entry" data-qa="entry"><td colspan="13" class="qa-cell" style="--depth:${qa.depth}"><span class="t-ind"></span></td></tr>`); }+    }+  }+}+// Where the caret is in the blank row's field, when it has the focus.+function tableQaFocus(){ return qa&&qa.lens==='table'&&document.activeElement===qa.input?[qa.input.selectionStart,qa.input.selectionEnd]:null; }+// The blank row's field is the same element through every render, so what is typed and the+// caret survive somebody else's change arriving mid-word, the row moving included (moving a+// node takes the focus out of it).+function tableQaAttach(sel){+  if(!qa||qa.lens!=='table')return;+  const cell=tableBody.querySelector('tr.qa-entry .qa-cell');+  if(!cell){ closeQuickAdd(true); return; }+  if(qa.input.parentNode!==cell){ qa.rendering=true; cell.appendChild(qa.input); qa.rendering=false; }+  if(sel&&document.activeElement!==qa.input){ qa.rendering=true; try{ qa.input.focus({preventScroll:true}); qa.input.setSelectionRange(sel[0],sel[1]); }catch(e){} qa.rendering=false; }+} let tableRowsCache=null; // Rows whose DOM was changed by hand (a cell editor), re-rendered whatever their markup says. const tableForceKeys=new Set();@@ -12888,19 +13843,27 @@ function tableRowHtml(r){     const dot=r.phase!==undefined?`<span class="phase-dot" style="background:${esc(phaseColor(r.phase))}"></span>`:'';     const dates=ms?(dl?fmt(dl):''):((sd&&dl)?fmt(sd)+' → '+fmt(dl):'');     const ed=editable?' editable':'';-    const trCls=[ms?'is-ms':'',isCancelled(r)?'cancelled':''].filter(Boolean).join(' ');+    // The tree (0.79.0): in plan order a row is indented under its task, a task with subtasks+    // carries the chevron that folds them, and a task held for context is dimmed. Under a sort+    // a subtask says which task it sits under.+    const plan=manualOrder(), kids=treeHasKids(r), folded=kids&&collapsedRows.has(rowTreeKey(r)), pk=treeParent(r);+    const trCls=[ms?'is-ms':'',isCancelled(r)?'cancelled':'',tableCtx.has(r)?'ctx-dim':'',folded?'kids-folded':''].filter(Boolean).join(' ');+    const nested=plan&&labelsEl.classList.contains('has-nest');+    const lead=nested?`<span class="t-ind" style="--depth:${treeDepth(r)}"></span>`+(kids?`<button type="button" class="sub-tog" tabindex="-1" aria-expanded="${folded?'false':'true'}" title="${esc((folded?'Show':'Hide')+' its '+plural(treeDesc(r).length,'subtask')+'. This is your view only, it does not change the plan.')}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button>`:'<span class="sub-tog sub-none"></span>'):'';+    const subOf=!plan&&pk&&treeRows.get(pk)?`<span class="sub-of" title="${esc('A subtask of '+(treeRows.get(pk).name||''))}">&#8627;</span>`:'';+    const addSub=canHoldKids(r)?`<button type="button" class="row-add" tabindex="-1" title="Add a subtask" aria-label="Add a subtask">${PLUS_SVG}</button>`:'';     return `<tr data-key="${esc(rowKey(r))}"${trCls?` class="${trCls}"`:''}>`       +`<td class="c-uid">${r.uid?ROW_SEL_HTML:''}${r.uid?`<button class="uid-link" type="button" tabindex="-1" title="Copy a link to this row, to paste in Chat">#${esc(r.uid)}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg></button>`:''}</td>`-      +`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`-        +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>`-      +`<td class="c-phase${ed}" data-field="phase"${phaseTagsOf(r).length?` title="Tags: ${esc(phaseTagsOf(r).join(', '))}"`:''}>${dot}${esc(phaseNameOf(r))}</td>`+      +`<td class="c-name${ed}" data-field="name">${lead||subOf?`<span class="t-name">${lead}${subOf}`:''}<span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`+        +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span>${kids?subCountHtml(r):''}${lead||subOf?'</span>':''}${addSub}</td>`+      +`<td class="c-phase${ed}" data-field="phase"${phaseTagsOf(r).length?` title="Tags: ${esc(phaseTagsOf(r).join(', '))}"`:''}>${dot}${esc(phaseNameOf(r))}${r.phase===undefined?'':`<button type="button" class="sec-add" tabindex="-1" title="${esc('Add a task to '+phaseNameOf(r))}" aria-label="Add a task to this section">${PLUS_SVG}</button>`}</td>`       +`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'NO OWNER'}</td>`       +(ms?'<td class="c-date empty"></td>'           :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(fmt(sd)):'not set'}</td>`)       +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(fmt(dl)):'not set'}</td>`       +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`       +(ms?'<td class="c-effort empty"></td>'-          :`<td class="c-effort${ed}${effortOf(r)===null?' empty':''}" data-field="effort">${effortOf(r)===null?'not est.':esc(fmtEffort(effortOf(r)))}</td>`)+          :`<td class="c-effort${ed}${effortOf(r)===null?' empty':''}" data-field="effort">${effortOf(r)===null?'not est.':esc(fmtEffort(effortOf(r)))}${effortRollHtml(r)}</td>`)       +`<td class="c-deps${(()=>{const d=depsOf(r);return (d.needs.length||d.blocks.length)?'':' empty';})()}">${depCell(r)}</td>`       +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`       +tagsCell(r,ed)@@ -12960,6 +13923,7 @@ function endLaneDrag(commit){ function wireLaneDrag(){ labelsEl.addEventListener('mousedown',e=>{   if(e.button!==0)return;+  if(e.target.closest('.lane-add'))return;      // quick add, not a lane drag (0.79.0)   const row=e.target.closest('.label-row.group');   if(!row)return;   const g=items.find(r=>r._lDiv===row);@@ -13153,29 +14117,11 @@ function endRowDrag(commit){   rowDrop.classList.remove('show');   if(d.tr)d.tr.classList.remove('row-lifted');   document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));-  if(!commit||!d.armed||d.targetIdx==null)return;-  const r=d.r, target=d.targetItem;-  if(!target||target===r)return;-  const fromPhase=r.phase;-  const toPhase=target.phase;-  // One undo for the whole gesture, position and lane together.-  pushUndo({type:'phase',r,phase:fromPhase,order:items.map(rowOrderKey)});-  const cur=items.indexOf(r);-  if(cur<0)return;-  items.splice(cur,1);-  let at=items.indexOf(target);-  if(at<0)at=items.length; else if(!d.before)at+=1;-  items.splice(at,0,r);-  if(toPhase!==undefined&&toPhase!==fromPhase){-    r.phase=toPhase;-    if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(toPhase);-    if(r._msLine)r._msLine.style.color=phaseColor(toPhase);-  }-  reflowRows();applyFilter();refreshChartGeometry();renderTable();-  // A cross-lane drop already reports itself as a phase move, which reads better-  // than "reordered rows"; a same-lane drop is a reorder and says which row moved.-  moveHint='"'+(r.name||'a row')+'"';-  scheduleSave('drag-row');+  clearNestMark();+  if(!commit||!d.armed||!d.drop)return;+  // One undo for the whole gesture, the row's place and section together (0.79.0: onto a+  // task's middle nests it, between rows moves it at the level the pointer picks).+  applyDrop(d.drop,d.drop.kind==='nest'?'drag-nest':'drag-row'); }  function rowDragMove(e){@@ -13195,22 +14141,26 @@ function rowDragMove(e){   else if(!dir&&autoScroll){clearInterval(autoScroll);autoScroll=null;}   else if(dir&&autoScroll){clearInterval(autoScroll);autoScroll=setInterval(()=>{scroller.scrollTop+=dir*14;},16);} -  const rows=[...tableBody.querySelectorAll('tr[data-key]')].filter(t=>t!==rowDrag.tr);-  let target=null, before=true;-  for(const t of rows){-    const b=t.getBoundingClientRect();-    if(e.clientY<b.top+b.height/2){target=t;before=true;break;}-    target=t;before=false;-  }+  // The rows on screen, less the lifted one and its subtasks, which travel with it (0.79.0).+  const skip=new Set([rowDrag.r,...treeDesc(rowDrag.r)]);+  const trs=[...tableBody.querySelectorAll('tr[data-key]')].filter(t=>{ const x=rowItem(t.dataset.key); return x&&!skip.has(x); });+  const seq=trs.map(t=>rowItem(t.dataset.key));+  let at=-1, frac=1;+  for(let k=0;k<trs.length;k++){ const b=trs[k].getBoundingClientRect(); if(e.clientY<b.bottom){ at=k; frac=Math.max(0,(e.clientY-b.top)/b.height); break; } }+  if(at<0&&trs.length)at=trs.length-1;   document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));-  if(!target){rowDrop.classList.remove('show');rowDrag.targetIdx=null;return;}-  const tb=target.getBoundingClientRect();-  rowDrop.style.top=((before?tb.top:tb.bottom)-sr.top+scroller.scrollTop)+'px';+  clearNestMark();+  const drop=at>=0?dropAt(seq,at,frac,e.clientX-rowDrag.x,[rowDrag.r]):null;+  rowDrag.drop=drop;+  if(!drop){rowDrop.classList.remove('show');return;}+  if(drop.kind==='nest'){ rowDrop.classList.remove('show'); trs[seq.indexOf(drop.target)].classList.add('nest-target'); return; }+  const ref=drop.above?drop.Y:drop.X, tr=trs[seq.indexOf(ref)];+  if(!tr){rowDrop.classList.remove('show');return;}+  const tb=tr.getBoundingClientRect();+  rowDrop.style.top=((drop.above?tb.top:tb.bottom)-sr.top+scroller.scrollTop)+'px';+  rowDrop.style.left=(drop.depth?28+drop.depth*16:0)+'px';   rowDrop.classList.add('show');-  rowDrag.targetIdx=1;-  rowDrag.targetItem=rowItem(target.dataset.key);-  rowDrag.before=before;-  if(rowDrag.targetItem&&rowDrag.targetItem.phase!==rowDrag.r.phase)target.classList.add('lane-target');+  if(drop.phase!==rowDrag.r.phase)tr.classList.add('lane-target'); }  function wireTableRowDrag(){@@ -13230,6 +14180,7 @@ tableBody.addEventListener('mousedown',e=>{     return;   }   if(e.target.closest('.row-del')||e.target.closest('.cell-edit'))return;+  if(e.target.closest('.sub-tog, .row-add, .sec-add, .qa-add, tr.qa-entry'))return;   // a fold, a quick add (0.79.0)   const tr=e.target.closest('tr[data-key]');if(!tr)return;   if(!manualOrder())return;                 // sorted: the note explains why   const r=rowItem(tr.dataset.key);if(!r)return;@@ -13657,7 +14608,10 @@ function wireOwnerCombo(inp,onPick){     os.forEach((o,k)=>{ o.classList.toggle('active',k===i); o.setAttribute('aria-selected',k===i?'true':'false'); });     if(i>=0&&os[i]&&os[i].scrollIntoView)os[i].scrollIntoView({block:'nearest'});   };-  const pick=v=>{ inp.value=v; closeOwnerCombo(); if(onPick)onPick(v); };+  // A panel's owner field wraps and grows (0.79.0); a value set here is not typed, so no input+  // event refits it: it is refitted by hand.+  const fit=()=>{ if(inp.matches&&inp.matches('textarea.ed-grow'))edGrow(inp); };+  const pick=v=>{ inp.value=v; fit(); closeOwnerCombo(); if(onPick)onPick(v); };   // On the WINDOW, in the capture phase, and only while this list is open: these keys are   // decided before the panel's Enter (Apply) and the Table cell's Enter and Tab (commit)   // can see them, and when nothing is highlighted they are left alone for exactly those.@@ -13671,7 +14625,7 @@ function wireOwnerCombo(inp,onPick){       return;     }     if(e.key==='Enter'&&a>=0&&os[a]){ e.preventDefault(); e.stopImmediatePropagation(); pick(os[a].dataset.value); return; }-    if(e.key==='Tab'&&a>=0&&os[a]){ inp.value=os[a].dataset.value; closeOwnerCombo(); return; }   // and Tab goes on+    if(e.key==='Tab'&&a>=0&&os[a]){ inp.value=os[a].dataset.value; fit(); closeOwnerCombo(); return; }   // and Tab goes on     if(e.key==='Escape'){ e.preventDefault(); e.stopImmediatePropagation(); closeOwnerCombo(); }   };   const open=()=>{@@ -13773,6 +14727,9 @@ function openCellList(sel,td){     `<div class="cl-opt" data-value="${esc(o.value)}">${esc(o.textContent)}</div>`).join('');   document.body.appendChild(el);   const r=td.getBoundingClientRect();+  // A list opened from a panel's field is at least as wide as the field (0.79.0), the way+  // the owner list is, so it reads as that field's list.+  if(td.closest&&td.closest('#editor'))el.style.minWidth=Math.round(r.width)+'px';   el.style.left=Math.max(4,Math.min(r.left,window.innerWidth-el.offsetWidth-6))+'px';   const below=window.innerHeight-r.bottom;   el.style.top=(below>el.offsetHeight+8?r.bottom+2:Math.max(4,r.top-el.offsetHeight-2))+'px';@@ -13969,7 +14926,7 @@ function commitCell(r,field,v){     const np=parseInt(v,10);     if(!phases[np]||np===r.phase)return;     // One undo step covers both halves: the field and the re-homing it causes.-    pushUndo({type:'phase',r,phase:r.phase,order:items.map(rowOrderKey)});+    pushUndo(placeSnap([r]));     moveRowToPhase(r,np);     scheduleSave('table-edit');     return;@@ -14026,6 +14983,16 @@ tableHead.addEventListener('click',e=>{   renderTable(); }); tableBody.addEventListener('click',e=>{+  // Subtasks and quick add (0.79.0).+  const tog=e.target.closest('button.sub-tog');+  if(tog){ e.stopPropagation(); const tr=tog.closest('tr'); const r=rowItem(tr&&tr.dataset.key); if(r&&treeHasKids(r))toggleRowFold(r); return; }+  const ra=e.target.closest('.row-add');+  if(ra){ e.stopPropagation(); const tr=ra.closest('tr'); const r=rowItem(tr&&tr.dataset.key); if(r)openQuickAdd({parent:r}); return; }+  const qb=e.target.closest('.qa-add');+  if(qb){ e.stopPropagation(); openQuickAdd({phase:+qb.dataset.phase}); return; }+  const sa=e.target.closest('.sec-add');+  if(sa){ e.stopPropagation(); const tr=sa.closest('tr'); const r=rowItem(tr&&tr.dataset.key); if(r)openQuickAdd({phase:r.phase,from:r}); return; }+  if(e.target.closest('tr.qa-entry'))return;   // The row handle selects the row and opens no cell (selectFromHandle). Ctrl and Shift on   // it are the Table's own multi-select, over the rows the Table is showing.   const rs=e.target.closest('.row-sel');@@ -14077,6 +15044,9 @@ tableBody.addEventListener('click',e=>{ // One click, undoable. A confirm dialog for something Ctrl+Z already covers is // a speed bump, not a safety net. function deleteRow(r){+  // A task goes with its subtasks (0.79.0), the way ClickUp sends them to the trash together:+  // one Ctrl+Z puts them all back.+  if(treeHasKids(r)){ deleteRows([r],'table-delete'); return; }   const label=r.name||r.label||'row';   if(focusedKey===rowKey(r))focusedKey=null;   const info=removeRow(r);@@ -14094,8 +15064,16 @@ function deleteRow(r){ // goes. Both lenses come through here: the table's Delete key and the chart's armed // Delete are the same delete, one undo entry and one changelog batch either way, and // `via` is the only thing that differs so the log still says which lens it came from.+// A set with every subtask of every row in it, in plan order.+function withSubtasks(rows){+  const set=new Set(); rows.forEach(r=>{ set.add(r); treeDesc(r).forEach(x=>set.add(x)); });+  return items.filter(r=>set.has(r));+} function deleteRows(victims,via){   if(!victims||!victims.length)return 0;+  const named=victims.length, lead=victims[0];+  victims=withSubtasks(victims);+  const subs=victims.length-named;   const steps=[];   victims.slice().reverse().forEach(v=>{     const info=removeRow(v,true);@@ -14105,8 +15083,9 @@ function deleteRows(victims,via){   pushUndo({type:'multi',steps});   clearSelection();   keepTailLast();applyFilter();refreshChartGeometry();renderTable();-  // One row is named, the way the single delete names it ("Deleted 1 rows" read badly).-  undoToast.innerHTML='Deleted '+(steps.length===1?esc(steps[0].r.name||steps[0].r.label||'1 row'):steps.length+' rows')+' &middot; <kbd>Ctrl+Z</kbd>';+  // One row is named, the way the single delete names it ("Deleted 1 rows" read badly); a task+  // deleted with its subtasks says how many went with it (0.79.0).+  undoToast.innerHTML=(named===1&&subs?'Deleted '+esc(lead.name||lead.label||'row')+' and its '+plural(subs,'subtask'):'Deleted '+(steps.length===1?esc(steps[0].r.name||steps[0].r.label||'1 row'):steps.length+' rows'))+' &middot; <kbd>Ctrl+Z</kbd>';   showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});   scheduleSave(via||'table-delete');   return steps.length;@@ -14132,18 +15111,24 @@ function disarmDelete(){ } function armDelete(victims){   disarmDelete();-  const n=victims.length;+  const named=victims.length, namedUids=victims.map(v=>v.uid);+  victims=withSubtasks(victims);+  const n=victims.length, subs=n-named;   victims.forEach(v=>{     if(v._lDiv)v._lDiv.classList.add('del-armed');     if(v._bDiv)v._bDiv.classList.add('del-armed');     if(v._msLine)v._msLine.classList.add('del-armed');   });-  delArm={uids:victims.map(v=>v.uid),timer:setTimeout(disarmDelete,4000)};+  // The second press is checked against the rows NAMED (armedFor); every row that would go,+  // the subtasks with them (0.79.0), wears the mark.+  delArm={uids:namedUids,marked:victims.map(v=>v.uid),timer:setTimeout(disarmDelete,4000)};   markArmedRows();   undoToast.__armed=true;   // One row is named, so the question says WHICH row (the Table's cursor and its   // selection can be two different rows); a set is counted.-  undoToast.innerHTML='Press <kbd>Delete</kbd> again to delete '+(n===1?'&ldquo;'+esc(victims[0].name||victims[0].label||'this row')+'&rdquo;':n+' rows');+  undoToast.innerHTML='Press <kbd>Delete</kbd> again to delete '+(n===1?'&ldquo;'+esc(victims[0].name||victims[0].label||'this row')+'&rdquo;'+    :named===1?'&ldquo;'+esc(victims[0].name||victims[0].label||'this row')+'&rdquo; and its '+plural(subs,'subtask')+    :n+' rows'+(subs?' ('+plural(subs,'subtask')+' among them)':''));   showToast(undoToast,4000,()=>{undoToast.__armed=false;undoToast.innerHTML=UNDO_TOAST_DEFAULT;disarmDelete();}); } // The Table's rows wear the armed mark too. Called again after the Table repaints (from@@ -14152,7 +15137,7 @@ function markArmedRows(){   if(!tableBody||!delArm)return;   tableBody.querySelectorAll('tr[data-key]').forEach(t=>{     const r=rowItem(t.dataset.key);-    t.classList.toggle('del-armed',!!(r&&r.uid&&delArm.uids.includes(r.uid)));+    t.classList.toggle('del-armed',!!(r&&r.uid&&(delArm.marked||delArm.uids).includes(r.uid)));   }); } // The second press acts on what the first press NAMED. Anything that could have changed@@ -14684,6 +15669,7 @@ function setView(v){   // The marks live on chart rows. Leaving the lens leaves the question behind too,   // rather than carrying an armed delete into a view that never showed it.   disarmDelete();+  closeQuickAdd(true);   if(v!=='gantt'&&depMode)setDepMode(false);   // the mode is a chart mode   prefs.view=v;savePrefs();applyView();   // Analytics is derived from the model, so it is rendered on the way IN and never@@ -14833,6 +15819,7 @@ if(UNDATED.length){   dw.classList.add('show');   dw.querySelector('.dw-close').addEventListener('click',()=>dw.classList.remove('show')); }+showImportReport(); }  @@ -14916,6 +15903,7 @@ async function init(){   wireLabelColumnResize();   wireEditorDrag();   wireEditorTab();+  wireEditorFit();   restoreTableSort();   wireLaneDrag();   wireTableColumnResize();
install.sh

  
package.json+3−2
@@ -2,8 +2,8 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.78.2",-  "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.",+  "version": "0.79.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, Jira CSV and ClickUp (its CSV exports, its API's JSON, or the API itself, with a report of what did not come across); exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {     "adom-gantt": "server.js"@@ -24,6 +24,7 @@     "view the project schedule",     "plot our milestones on a timeline",     "import this MS Project file",+    "move my ClickUp project into a gantt chart",     "export the schedule as SVG",     "who owns what on this project",     "show the plan as a table",
page.json+3−2
@@ -2,8 +2,8 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.78.2",-  "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.",+  "version": "0.79.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, Jira CSV and ClickUp (its CSV exports, its API's JSON, or the API itself, with a report of what did not come across); exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {     "adom-gantt": "server.js"@@ -24,6 +24,7 @@     "view the project schedule",     "plot our milestones on a timeline",     "import this MS Project file",+    "move my ClickUp project into a gantt chart",     "export the schedule as SVG",     "who owns what on this project",     "show the plan as a table",
server.js+1551−94
@@ -988,7 +988,14 @@ function logImport(format, result, actor) {   // reloads, and the announcement says who imported what.   broadcastLive({ type: 'reload', why: 'import', format, actor: actor || 'user', ts: Date.now() });   try {-    appendLog([{ action: 'import', format, items: result.items, phases: result.phases, via: 'import' }], actor || 'user');+    const ev = { action: 'import', format, items: result.items, phases: result.phases, via: 'import' };+    // What an import from another tool could not bring across (ClickUp), so a reader of the+    // log knows the plan never had it rather than that somebody removed it.+    if (Array.isArray(result.logged) && result.logged.length) ev.notCarried = result.logged.slice(0, 20);+    // Rows given the import's day for want of a date of their own, so a reader of the log knows+    // those dates were never in the source.+    if (result.undated) { ev.undated = result.undated; ev.undatedOn = result.undatedOn; }+    appendLog([ev], actor || 'user');   } catch (e) {     console.error('adom-gantt: could not record the import in the changelog:', e.message);   }@@ -1127,6 +1134,13 @@ function describeEvent(e) {   if (action === 'phase') {     return `${who} moved ${name} from the ${fld('from', 'phase', '?')} lane to ${fld('to', 'phase', '?')}${via}`;   }+  // Subtasks (0.79.0): a row put under a task, taken out from under one, or moved between two.+  if (action === 'parent') {+    const f = fld('from', 'name', ''), t = fld('to', 'name', '');+    if (t && !f) return `${who} made ${name} a subtask of "${t}"${via}`;+    if (f && !t) return `${who} took ${name} out from under "${f}"${via}`;+    return `${who} moved ${name} from under "${f || '?'}" to under "${t || '?'}"${via}`;+  }   if (action === 'add') {     return `${who} added a new ${e.kind || 'item'} to the plan: ${name}${via}`;   }@@ -1165,7 +1179,10 @@ function describeEvent(e) {     return `${who} ran a data migration: ${oneLine(e.what || 'changed rows')}. ${e.count || 0} value(s) updated${e.detail ? '. ' + oneLine(e.detail) : ''}${via}`;   }   if (action === 'import') {-    return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`;+    const not = Array.isArray(e.notCarried) && e.notCarried.length+      ? ` Not carried over: ${e.notCarried.slice(0, 8).map(oneLine).join('; ')}${e.notCarried.length > 8 ? `; and ${e.notCarried.length - 8} more` : ''}.` : '';+    const und = Number(e.undated) > 0 ? ` ${Number(e.undated)} row(s) with no date of their own were placed on ${fmtDate(e.undatedOn)} and tagged undated.` : '';+    return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}.${und}${not} Everything before this point describes a different plan${via}`;   }   if (action === 'auto-schedule') {     const names = Array.isArray(e.moved) && e.moved.length@@ -1463,6 +1480,36 @@ 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.+// A whole-state save that changes a created row's own fields (0.79.0). The sidecar keeps a+// created row twice: its entry in `added` and, once anybody has edited it, a full override in+// `items` that the merge applies on top. A script that edits `added` and sends the overrides back+// as it read them saved a change the override then hid: on 2026-11-03 an agent's new dependency+// and date change on a created row were lost that way, under an override an earlier owner edit+// had written. So where this save changed a field of a created row, and the override still holds+// that field's previous value (and this save did not change the override itself), the override+// takes the new value. An override that says something else is a deliberate edit and stands.+function carryAddedEdits(store, old) {+  if (!store || !old || !Array.isArray(store.added) || !Array.isArray(old.added) || !Array.isArray(store.items)) return 0;+  const J = v => JSON.stringify(v === undefined ? null : v);+  const was = new Map(old.added.filter(a => a && a.uid).map(a => [a.uid, a]));+  const stored = new Map((Array.isArray(old.items) ? old.items : []).filter(o => o && o.uid).map(o => [o.uid, o]));+  const ovs = new Map(store.items.filter(o => o && o.uid).map(o => [o.uid, o]));+  let n = 0;+  store.added.forEach(a => {+    const b = a && a.uid && was.get(a.uid), o = a && a.uid && ovs.get(a.uid);+    if (!b || !o) return;+    const before = overrideOf(b), after = overrideOf(a), prev = stored.get(a.uid);+    new Set(Object.keys(before).concat(Object.keys(after))).forEach(f => {+      if (f === 'uid' || J(before[f]) === J(after[f])) return;     // this save did not change it+      if (!(f in o) || J(o[f]) !== J(before[f])) return;            // the override says something else+      if (prev && J(prev[f]) !== J(o[f])) return;                   // this save changed the override itself+      if (after[f] === undefined) delete o[f]; else o[f] = JSON.parse(JSON.stringify(after[f]));+      n++;+    });+  });+  if (n) console.log(`save: carried ${n} field(s) of created rows into their overrides`);+  return n;+} 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 };@@ -1471,6 +1518,8 @@ function overrideOf(r) {   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();+  if (typeof r.parent === 'string' && r.parent) o.parent = r.parent;+  if (posOk(r.pos)) o.pos = r.pos;   return o; } // One field's value, cleaned, or undefined to drop it. Never an error: a bad field in a@@ -1496,6 +1545,9 @@ function opValue(f, v) {   if (f === 'effort') { if (v === null) return null; const e = normalizeEffort(v); return e === undefined ? undefined : e; }   if (f === 'deps') return Array.isArray(v) ? [...new Set(v.filter(x => typeof x === 'string' && x).map(x => x.slice(0, 200)))] : undefined;   if (f === 'tags') return normalizeTags(v);+  // A row's place (0.79.0): the uid of its parent ('' for none), and its key among its siblings.+  if (f === 'parent') return v === null || v === undefined ? '' : (typeof v === 'string' ? v.slice(0, 40) : undefined);+  if (f === 'pos') return v === null || v === undefined || v === '' ? '' : (posOk(v) ? v : undefined);   return undefined; } // The rows of the plan, in the order the sidecar keeps them: every data-file row and every@@ -1527,11 +1579,17 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {   const typeOf = uid => (mergedByUid.get(uid) || {}).type;   const entryFor = uid => {     let o = entries.get(uid);-    if (!o) { const base = mergedByUid.get(uid); o = base ? overrideOf(base) : { uid }; entries.set(uid, o); state.items.push(o); }+    const base = mergedByUid.get(uid);+    if (!o) { o = base ? overrideOf(base) : { uid }; entries.set(uid, o); state.items.push(o); }+    // An entry that only placed the row (0.79.0: `{uid, pos}`, written at startup for every row)+    // is filled out the first time an op touches the row, as an entry made here always was: a+    // list merges item by item onto the list the row HAS, which a bare entry does not carry,+    // and a dependency or a tag the data file gave the row would otherwise be dropped.+    else if (base) { const full = overrideOf(base); Object.keys(full).forEach(k => { if (!(k in o)) o[k] = full[k]; }); }     return o;   };   let orderTouched = false, phasesTouched = false, moved = false;-  const moves = [];+  const moves = [], placed = new Set(), legacyPhase = new Set();   // A row coming back: off the deleted list, and announced whole, as the merge reads it.   const undelete = uid => {     state.deleted = state.deleted.filter(k => k !== uid);@@ -1552,7 +1610,7 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {       // A data-file row coming back is an undelete; a created row coming back is the same.       if (dataUids.has(a.uid) || isAdded(a.uid)) { undelete(a.uid); continue; }       const row = {};-      ['type', 'uid', 'id', 'name', 'start', 'end', 'date', 'phase', 'owner', 'status', 'description', 'deps', 'note', 'provenance', 'tags', 'effort', 'striped']+      ['type', 'uid', 'id', 'name', 'start', 'end', 'date', 'phase', 'owner', 'status', 'description', 'deps', 'note', 'provenance', 'tags', 'effort', 'striped', 'parent', 'pos']         .forEach(k => { if (a[k] !== undefined) row[k] = a[k]; });       row.type = row.type === 'milestone' ? 'milestone' : 'task';       const okDates = row.type === 'milestone' ? (typeof row.date === 'string' && OP_DAY.test(row.date))@@ -1561,6 +1619,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);+      // Its place (0.79.0): a parent it can sit under and a key, or neither. A parent it cannot+      // have makes it a top-level row; a bad key puts it after its siblings.+      if (row.parent !== undefined) {+        const C0 = placeCtx(state, data);+        let pp = typeof row.parent === 'string' ? row.parent.slice(0, 40) : '';+        if (pp && !C0.base.has(pp) && C0.idTo.has(pp)) pp = C0.idTo.get(pp);+        if (pp && (!C0.base.has(pp) || (C0.base.get(pp) || {}).type === 'milestone' || (C0.live(pp) && (C0.tree().depth.get(pp) || 0) + 1 > TREE_DEPTH_MAX))) { refused.push('add: ' + a.uid + ' parent'); pp = ''; }+        if (pp) row.parent = pp; else delete row.parent;+      }+      if (row.pos !== undefined && !posOk(row.pos)) delete row.pos;       // 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@@ -1590,10 +1658,31 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {       if (uid && !mergedByUid.has(uid) && state.deleted.includes(uid) && (dataUids.has(uid) || isAdded(uid))) undelete(uid);       if (!uid || !mergedByUid.has(uid)) { refused.push('row: unknown ' + uid); continue; }       const ms = typeOf(uid) === 'milestone', o = entryFor(uid), done = {};-      const set = op.set && typeof op.set === 'object' && !Array.isArray(op.set) ? op.set : {};+      const set = op.set && typeof op.set === 'object' && !Array.isArray(op.set) ? Object.assign({}, op.set) : {};+      // A row's place (0.79.0): its parent, its key and, for a top-level row, its section are+      // one value. A newer page sends them together, and they are taken or refused together,+      // so the last writer wins the whole place (Figma keeps parent and position in one+      // property for the same reason). A section with no parent or key beside it comes from a+      // page from before 0.79.0, which knows nothing of either; legacyPlace re-places that row below.+      const hasPlace = 'parent' in set || 'pos' in set;+      let placeOk = true;+      if (hasPlace) {+        if ('pos' in set && opValue('pos', set.pos) === undefined) placeOk = false;+        if (placeOk && 'parent' in set) {+          const pv = opValue('parent', set.parent);+          if (pv === undefined) placeOk = false;+          else if (pv) {+            const C0 = placeCtx(state, data);+            const pu = !C0.base.has(pv) && C0.idTo.has(pv) ? C0.idTo.get(pv) : pv;+            if (parentProblem(C0, uid, pu)) placeOk = false; else set.parent = pu;+          }+        }+        if (!placeOk) refused.push('row ' + uid + ': place');+      }       for (const f of Object.keys(set)) {-        if (!['s', 'e', 'date', 'note', 'owner', 'status', 'desc', 'prov', 'name', 'phase', 'effort', 'deps', 'tags'].includes(f)) continue;+        if (!['s', 'e', 'date', 'note', 'owner', 'status', 'desc', 'prov', 'name', 'phase', 'effort', 'deps', 'tags', 'parent', 'pos'].includes(f)) continue;         if (ms ? OP_TASK_ONLY.has(f) : f === 'date') continue;+        if (!placeOk && (f === 'parent' || f === 'pos' || f === 'phase')) continue;         const v = opValue(f, set[f]);         if (v === undefined) { refused.push('row ' + uid + ': ' + f); continue; }         o[f] = Array.isArray(v) ? v.slice() : v; done[f] = true;@@ -1622,6 +1711,8 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {       const setOut = {}; fields.forEach(f => { setOut[f] = Array.isArray(o[f]) ? o[f].slice() : (o[f] && typeof o[f] === 'object' ? Object.assign({}, o[f]) : o[f]); });       out.push({ op: 'row', uid, set: setOut });       if (done.phase) orderTouched = true;+      if (hasPlace && placeOk && (done.parent || done.pos || done.phase)) placed.add(uid);+      else if (done.phase) legacyPhase.add(uid);     } else if (op.op === 'move') {       if (!uid) { refused.push('move: no uid'); continue; }       moves.push({ uid, after: typeof op.after === 'string' ? op.after.slice(0, 40) : null });@@ -1656,24 +1747,30 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {   // from the server and sent moves to "fix" the server's copy; on 2026-11-03 two pages that   // disagreed about which section 24 rows were in corrected each other three times a   // second for an hour, and every other page redrew each time.-  if (orderTouched || moved || phasesTouched) {-    const live = liveKeyList(data, state), liveSet = new Set(live), n = sectionCount(state, data);-    const phaseOf = u => { const o = entries.get(u); if (o && Number.isInteger(o.phase) && o.phase >= 0 && o.phase < n) return o.phase; const r = mergedByUid.get(u); return r ? r.phase : undefined; };-    let cur = Array.isArray(state.order) ? state.order : live.filter(k => dataUids.has(k));-    cur = [...new Set(cur.filter(k => liveSet.has(k)))];-    const inCur = new Set(cur);-    live.forEach(k => {-      if (inCur.has(k)) return;-      const ph = phaseOf(k);-      let at = -1;-      for (let i = cur.length - 1; i >= 0; i--) if (phaseOf(cur[i]) === ph) { at = i; break; }-      if (at < 0) cur.push(k); else cur.splice(at + 1, 0, k);-      inCur.add(k);-    });-    moves.forEach(m => moveInOrder(cur, m.uid, m.after));-    cur = groupBySection(cur, phaseOf, n);+  if (orderTouched || moved || phasesTouched || placed.size || legacyPhase.size) {+    // The tree (0.79.0). An older page's moves and section changes are turned into places in it+    // first (legacyPlace); then the tree is kept whole (normalizeTree); then the flat order,+    // which is now derived, never edited, is written for pages and servers that read only it.+    const C = placeCtx(state, data, null, u => mergedByUid.get(u));+    if (moved || legacyPhase.size) {+      const t0 = C.tree();+      // A subtask an older page moved to another section by its section field leaves its parent.+      const forced = new Set([...legacyPhase].filter(u => t0.parentOf.get(u) && t0.phaseOf.get(u) !== C.val(u, 'phase')));+      legacyPlace(C, legacyTarget(C, moves), { placed, forced });+    }+    normalizeTree(C, { placed });+    const cur = C.tree().order;     const changed = !Array.isArray(state.order) || cur.join('\n') !== state.order.join('\n');     state.order = cur;+    // What the server changed on its own (a key, a subtask's section, a row whose parent went)+    // is announced as the rows' own fields, in the op that set them when there is one, so a+    // page takes the place the server settled on.+    C.changed.forEach((fs, u) => {+      const a = out.find(x => x.op === 'add' && x.uid === u && x.row);+      if (a) { Object.keys(fs).forEach(f => { if (f === 'parent' && !fs[f]) delete a.row.parent; else a.row[f] = fs[f]; }); return; }+      const r = out.find(x => x.op === 'row' && x.uid === u);+      if (r) Object.assign(r.set, fs); else out.push({ op: 'row', uid: u, set: Object.assign({}, fs) });+    });     // `grouped` tells a page it can trust the order to match its own layout: one that does     // not, groups some row into another section than the server does, and fetches the plan.     // Moves that leave the order as it was make no revision and are announced to nobody: a@@ -1681,7 +1778,7 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {     // reloaded, and announcing each one cost every other open page about 13 ms, eight times     // a second (measured on a copy of the team plan). Rows added, deleted or moved to another     // section always send the order, because pages regroup on it.-    if (orderTouched || changed) out.push({ op: 'order', order: cur.slice(), grouped: true });+    if (orderTouched || changed || C.changed.size) out.push({ op: 'order', order: cur.slice(), grouped: true });   }   // Sections first, then rows, then the order (0.77.1). A page applies an announcement in   // the order it is given, and a page that had not got a section yet refused a row moved@@ -1693,18 +1790,32 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { } function resyncOnStart() {   try {-    const st = loadState();-    if (!st || typeof st !== 'object' || Array.isArray(st) || st.v !== 4) return;-    const merged = mergedData(), items = merged.items || [];-    const phaseOf = new Map(items.map(r => [r && r.uid, r && r.phase]));-    // Only an order that lists exactly the plan's rows: that is the one the merge applies,-    // and anything else is left for the next save that touches the order to complete.-    if (Array.isArray(st.order) && st.order.length === items.length && st.order.every(u => phaseOf.has(u))) {-      st.order = groupBySection(st.order, u => phaseOf.get(u), (merged.phases || []).length);-    }+    let st = loadState();+    if (st && (typeof st !== 'object' || Array.isArray(st) || st.v !== 4)) return;+    const data = loadData();+    // A plan with no sidecar gets one now if its rows have no keys (0.79.0): every row needs+    // one before anybody nests or moves anything, or a key made between two rows with none+    // would land at an edge. The rows keep the order every page shows them in today.+    const fresh = !st;+    const itemsBefore = st ? JSON.stringify(st.items || []) : '';+    const merged = new Map((mergedData().items || []).map(r => [r && r.uid, r]));+    if (fresh) st = { v: 4, items: [], deleted: [], added: [], stateRev: 0 };+    const C = placeCtx(st, data, null);+    const live = C.liveList();+    // The order pages show today: the stored one when it lists exactly the plan's rows (that+    // is the one the merge applied), else the data file's order with created rows after it.+    const ord = Array.isArray(st.order) ? st.order.filter(u => C.live(u) && C.base.has(u)) : [];+    const full = ord.length === live.length && new Set(ord).size === live.length;+    normalizeTree(placeCtx(st, data, full ? ord : live, u => merged.get(u)));+    if (fresh && !st.items.length) return;+    st.order = placeCtx(st, data, null).tree().order;     st.stateRev = (typeof st.stateRev === 'number' ? st.stateRev : 0) + 1;+    // The first start of 0.79.0 gives every row its place key, writing an override for a row+    // that had none: the sidecar as it was is kept first, once, as the uid and milestone id+    // migrations keep theirs.+    if (!fresh && JSON.stringify(st.items || []) !== itemsBefore) backupOnce(STATE_FILE, '.pre-pos.bak');     writeAtomic(STATE_FILE, JSON.stringify(st));-  } catch (e) { console.error('Start: the saved order was not regrouped (' + e.message + ')'); }+  } catch (e) { console.error('Start: the rows were not placed (' + e.message + ')'); } } // How many sections the plan has with this sidecar applied: its own full list when it // carries one, the data file's otherwise. A row's section is an index into that list.@@ -1720,6 +1831,409 @@ function groupBySection(keys, phaseOf, n) {   keys.forEach(k => { const p = phaseOf(k); if (Number.isInteger(p) && p >= 0 && p < n) buckets[p].push(k); else rest.push(k); });   return [].concat(...buckets, rest); }+// ── Subtasks (0.79.0): a row's parent, and its place among its siblings ──+//+// Two fields on a row. `parent` is the uid of the task it sits under ('' or absent: top level+// in its section). `pos` is its place among its siblings: a fractional index key, a string+// that sorts between its two neighbours' keys. A move, a nest or an un-nest is then a change+// to ONE row's own fields, the last writer wins them, and there is no shared list for two+// tabs to disagree about. This is how Figma's multiplayer keeps its layer tree (a parent link+// and a position on each child), and ClickUp keeps an order index on each task. The flat+// `order` is derived from these and still written, for pages and servers that predate them.+//+// The keys are David Greenspan's fractional indexing (public domain, as published in the+// `fractional-indexing` package): base 62, an integer part whose first character says how+// long it is, then a fraction with no trailing zero. "a0" is the first key; appending at the+// end only increments the integer part, so keys stay short. Kept line for line with the+// page's copy, like normalizeTags.+const POS_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';+const POS_MAX = 200;+// ClickUp's default for nested subtasks: a top-level task, then up to three levels under it.+const TREE_DEPTH_MAX = 3;+function fiMidpoint(a, b) {+  const D = POS_DIGITS, zero = D[0];+  if (b !== null && a >= b) throw new Error(a + ' >= ' + b);+  if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) throw new Error('trailing zero');+  if (b) {+    let n = 0;+    while ((a[n] || zero) === b[n]) n++;+    if (n > 0) return b.slice(0, n) + fiMidpoint(a.slice(n), b.slice(n));+  }+  const da = a ? D.indexOf(a[0]) : 0, db = b !== null ? D.indexOf(b[0]) : D.length;+  if (db - da > 1) return D[Math.round(0.5 * (da + db))];+  if (b && b.length > 1) return b.slice(0, 1);+  return D[da] + fiMidpoint(a.slice(1), null);+}+function fiIntLen(h) {+  if (h >= 'a' && h <= 'z') return h.charCodeAt(0) - 97 + 2;+  if (h >= 'A' && h <= 'Z') return 90 - h.charCodeAt(0) + 2;+  throw new Error('invalid key head ' + h);+}+function fiIntPart(k) { const n = fiIntLen(k[0]); if (n > k.length) throw new Error('invalid key ' + k); return k.slice(0, n); }+function fiCheck(k) {+  if (k === 'A' + POS_DIGITS[0].repeat(26)) throw new Error('invalid key ' + k);+  const i = fiIntPart(k);+  if (k.slice(i.length).slice(-1) === POS_DIGITS[0]) throw new Error('invalid key ' + k);+}+function fiStep(x, up) {+  const D = POS_DIGITS;+  if (x.length !== fiIntLen(x[0])) throw new Error('invalid integer ' + x);+  const head = x[0], digs = x.slice(1).split('');+  let carry = true;+  for (let i = digs.length - 1; carry && i >= 0; i--) {+    const d = D.indexOf(digs[i]) + (up ? 1 : -1);+    if (up ? d === D.length : d === -1) digs[i] = up ? D[0] : D.slice(-1);+    else { digs[i] = D[d]; carry = false; }+  }+  if (!carry) return head + digs.join('');+  if (up) {+    if (head === 'Z') return 'a' + D[0];+    if (head === 'z') return null;+    const h = String.fromCharCode(head.charCodeAt(0) + 1);+    if (h > 'a') digs.push(D[0]); else digs.pop();+    return h + digs.join('');+  }+  if (head === 'a') return 'Z' + D.slice(-1);+  if (head === 'A') return null;+  const h = String.fromCharCode(head.charCodeAt(0) - 1);+  if (h < 'Z') digs.push(D.slice(-1)); else digs.pop();+  return h + digs.join('');+}+function fiBetween(a, b) {+  if (a !== null) fiCheck(a);+  if (b !== null) fiCheck(b);+  if (a !== null && b !== null && a >= b) throw new Error(a + ' >= ' + b);+  if (a === null) {+    if (b === null) return 'a' + POS_DIGITS[0];+    const ib = fiIntPart(b), fb = b.slice(ib.length);+    if (ib === 'A' + POS_DIGITS[0].repeat(26)) return ib + fiMidpoint('', fb);+    if (ib < b) return ib;+    const res = fiStep(ib, false);+    if (res === null) throw new Error('cannot decrement any more');+    return res;+  }+  if (b === null) {+    const ia = fiIntPart(a), fa = a.slice(ia.length), i = fiStep(ia, true);+    return i === null ? ia + fiMidpoint(fa, null) : i;+  }+  const ia = fiIntPart(a), fa = a.slice(ia.length), ib = fiIntPart(b), fb = b.slice(ib.length);+  if (ia === ib) return ia + fiMidpoint(fa, fb);+  const i = fiStep(ia, true);+  if (i === null) throw new Error('cannot increment any more');+  return i < b ? i : ia + fiMidpoint(fa, null);+}+function posOk(k) {+  if (typeof k !== 'string' || !k || k.length > POS_MAX || !/^[A-Za-z][0-9A-Za-z]*$/.test(k)) return false;+  try { fiCheck(k); return true; } catch (e) { return false; }+}+// A key between two others, either of which may be missing (the start, the end) or not a+// key at all. Two neighbours in the wrong order (two tabs gave rows the same place a moment+// ago, and the server has not separated them yet) give a key just after the first.+function keyBetween(a, b) {+  a = posOk(a) ? a : null; b = posOk(b) ? b : null;+  if (a !== null && b !== null && a >= b) b = null;+  try { return fiBetween(a, b); } catch (e) { try { return fiBetween(a, null); } catch (e2) { return fiBetween(null, null); } }+}+// n keys in order between two others, spread so none grows longer than it has to.+function keysBetween(a, b, n) {+  a = posOk(a) ? a : null; b = posOk(b) ? b : null;+  if (a !== null && b !== null && a >= b) b = null;+  if (n <= 0) return [];+  if (n === 1) return [keyBetween(a, b)];+  const out = [];+  if (b === null) { let c = a; for (let i = 0; i < n; i++) { c = keyBetween(c, null); out.push(c); } return out; }+  if (a === null) { let c = b; for (let i = 0; i < n; i++) { c = keyBetween(null, c); out.push(c); } return out.reverse(); }+  const mid = Math.floor(n / 2), c = keyBetween(a, b);+  return keysBetween(a, c, mid).concat([c], keysBetween(c, b, n - mid - 1));+}+// Siblings in their order: by key, a row with no key after every row with one (in the order+// it was given), and two equal keys by uid, so every tab and the server agree.+function sibCmp(a, b) {+  const x = posOk(a.pos) ? a.pos : '', y = posOk(b.pos) ? b.pos : '';+  if (x && y && x !== y) return x < y ? -1 : 1;+  if (x && !y) return -1;+  if (y && !x) return 1;+  if (!x && !y && a._i !== b._i) return a._i - b._i;+  return a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0;+}+// The tree, from the rows' own fields. `rows`: {uid, type, phase, parent, pos}, in the order to+// fall back on for rows with no key. A parent that is missing, a milestone, the row itself or+// part of a loop is ignored (a loop is cut at its smallest uid), so any input gives a tree.+// Returns the flat order (sections in list order; each top-level row followed by its subtree,+// depth first), each row's effective parent, depth and section (its top-level ancestor's).+function treeOf(rows, nSections) {+  const by = new Map(), list = [];+  rows.forEach((r, i) => { if (r && r.uid && !by.has(r.uid)) { const o = { uid: r.uid, type: r.type, phase: r.phase, parent: r.parent, pos: r.pos, _i: i }; by.set(r.uid, o); list.push(o); } });+  const par = new Map();+  list.forEach(o => { const p = o.parent; par.set(o.uid, typeof p === 'string' && p && p !== o.uid && by.has(p) && by.get(p).type !== 'milestone' ? p : ''); });+  const seen = new Map();+  list.map(o => o.uid).sort().forEach(u => {+    if (seen.get(u) === 2) return;+    const path = [];+    let x = u;+    while (x && seen.get(x) !== 2) {+      if (seen.get(x) === 1) { const loop = path.slice(path.indexOf(x)); par.set(loop.slice().sort()[0], ''); break; }+      seen.set(x, 1); path.push(x); x = par.get(x);+    }+    path.forEach(y => seen.set(y, 2));+  });+  const kids = new Map(), tops = [];+  list.forEach(o => { const p = par.get(o.uid); if (p) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(o); } else tops.push(o); });+  kids.forEach(l => l.sort(sibCmp));+  const depth = new Map(), phaseOf = new Map(), order = [];+  const walk = (o, d, ph) => { depth.set(o.uid, d); phaseOf.set(o.uid, ph); order.push(o.uid); (kids.get(o.uid) || []).forEach(k => walk(k, d + 1, ph)); };+  const buckets = Array.from({ length: Math.max(0, nSections) }, () => []), rest = [];+  tops.forEach(o => (Number.isInteger(o.phase) && o.phase >= 0 && o.phase < buckets.length ? buckets[o.phase] : rest).push(o));+  buckets.forEach(b => b.sort(sibCmp).forEach(o => walk(o, 0, o.phase)));+  rest.sort(sibCmp).forEach(o => walk(o, 0, o.phase));+  const kidsOut = new Map(); kids.forEach((l, p) => kidsOut.set(p, l.map(o => o.uid)));+  return { order, parentOf: par, depth, phaseOf, kids: kidsOut };+}+// How many levels sit under a row (0 for a row with no subtasks).+function treeHeight(t, u) { let h = 0; (t.kids.get(u) || []).forEach(k => { h = Math.max(h, 1 + treeHeight(t, k)); }); return h; }+// Is `u` inside the subtree of `anc` (anc itself excluded)?+function treeUnder(t, u, anc) { let x = t.parentOf.get(u), n = 0; while (x && n++ < 10000) { if (x === anc) return true; x = t.parentOf.get(x); } return false; }+// The fewest rows to move to turn `base` into `target`, each as "after its new neighbour":+// the rows that stay are the longest run already in the right relative order (a longest+// increasing subsequence of their old positions). The page's copy, line for line.+function orderMoves(base, target) {+  const pos = new Map(); base.forEach((k, i) => pos.set(k, i));+  const seq = []; target.forEach((k, i) => { if (pos.has(k)) seq.push(i); });+  const tails = [], tailAt = [], prev = new Array(seq.length);+  seq.forEach((ti, j) => {+    const v = pos.get(target[ti]); let lo = 0, hi = tails.length;+    while (lo < hi) { const m = (lo + hi) >> 1; if (tails[m] < v) lo = m + 1; else hi = m; }+    tails[lo] = v; tailAt[lo] = j; prev[j] = lo > 0 ? tailAt[lo - 1] : -1;+  });+  const keep = new Set();+  for (let j = tails.length ? tailAt[tails.length - 1] : -1; j >= 0; j = prev[j]) keep.add(target[seq[j]]);+  const moves = [];+  target.forEach((k, i) => { if (!keep.has(k)) moves.push({ op: 'move', uid: k, after: i ? target[i - 1] : null }); });+  return moves;+}+// The plan's rows with what decides their place, as the sidecar holds them right now: a row's+// own values (its override entry, else the created row or the data file's row). `set` writes a+// placement field into the row's override entry, a small one ({uid, pos}) when the row has+// none, so the data file's other values still stand; every write is remembered in `changed`,+// which is what the server announces on the row's behalf.+function placeCtx(state, data, prefer, mergedOf) {+  ['items', 'added', 'deleted'].forEach(k => { if (!Array.isArray(state[k])) state[k] = []; });+  const gone = new Set(state.deleted);+  const entries = new Map(); state.items.forEach(o => { if (o && o.uid) entries.set(o.uid, o); });+  const base = new Map();+  (data.items || []).forEach(r => { if (r && r.uid && r.type !== 'group' && !base.has(r.uid)) base.set(r.uid, r); });+  state.added.forEach(a => { if (a && a.uid && !base.has(a.uid)) base.set(a.uid, a); });+  const idTo = new Map(); base.forEach((r, u) => { if (r.id && !idTo.has(r.id)) idTo.set(r.id, u); });+  const n = sectionCount(state, data);+  const C = { state, data, entries, base, gone, n, idTo, changed: new Map() };+  C.live = u => base.has(u) && !gone.has(u);+  C.val = (u, f) => {+    const o = entries.get(u), b = base.get(u);+    if (o && o[f] !== undefined) {+      // A section the sidecar holds counts only inside the list, as the merge applies it.+      if (f !== 'phase' || (Number.isInteger(o.phase) && o.phase >= 0 && o.phase < n)) return o[f];+    }+    return b ? b[f] : undefined;+  };+  // A parent is a uid; a data file may name a task by its id instead.+  C.parentOf = u => { const v = C.val(u, 'parent'); if (typeof v !== 'string' || !v) return ''; return base.has(v) ? v : (idTo.get(v) || v); };+  // A row with no entry yet gets a whole one, made from the row as the plan has it, the way an+  // edit has always made one: a server from before 0.79.0 merges a list onto the entry's own+  // list, so an entry carrying only a key would make it drop the row's dependencies and tags+  // after a rollback. With no plan rows to hand (an import, which writes the data file), only+  // the field is written.+  C.set = (u, f, v) => {+    let o = entries.get(u);+    if (!o) { const m = mergedOf && mergedOf(u); o = m ? overrideOf(m) : { uid: u }; entries.set(u, o); state.items.push(o); }+    o[f] = v;+    let c = C.changed.get(u); if (!c) C.changed.set(u, c = {}); c[f] = v;+  };+  C.liveList = () => {+    // A legacy group row is a section header, never a row of the tree.+    const l = liveKeyList(data, state).filter(u => base.has(u));+    if (!prefer) return l;+    const at = new Map(); prefer.forEach((u, i) => { if (!at.has(u)) at.set(u, i); });+    return l.map((u, i) => [u, at.has(u) ? at.get(u) : prefer.length + i]).sort((a, b) => a[1] - b[1]).map(x => x[0]);+  };+  C.tree = () => treeOf(C.liveList().map(u => ({ uid: u, type: (base.get(u) || {}).type, phase: C.val(u, 'phase'), parent: C.parentOf(u), pos: C.val(u, 'pos') })), n);+  return C;+}+// Why `p` cannot be `u`'s parent, or '' when it can. A deleted parent is allowed: the row then+// takes that row's place (normalizeTree), as when the parent is deleted after the nest.+function parentProblem(C, u, p) {+  if (!C.base.has(p)) return 'no such row';+  if ((C.base.get(p) || {}).type === 'milestone') return 'a milestone cannot hold subtasks';+  if (p === u) return 'a row cannot sit under itself';+  if (!C.live(p)) { let x = p, g = 0; while (x && g++ < 10000) { if (x === u) return 'a row cannot sit under its own subtask'; x = C.parentOf(x); } return ''; }+  const t = C.tree();+  if (treeUnder(t, p, u)) return 'a row cannot sit under its own subtask';+  if ((t.depth.get(p) || 0) + 1 + treeHeight(t, u) > TREE_DEPTH_MAX) return `subtasks go ${TREE_DEPTH_MAX} levels deep at most`;+  return '';+}+// An older page moves rows by "after that row" and changes a row's section by its section field+// alone: it knows nothing of parents or keys. What it asked for is an order (`target`, its+// order grouped by section). The rows that order moved are given a place in the tree that keeps+// them where that page put them: a row keeps its parent while it lands among that parent's+// subtasks, and otherwise becomes the sibling of the row it lands above (the last top-level row+// of its section, at the end). `placed` rows were placed by a newer page in the same batch and+// keep that place; `forced` rows must move (a subtask whose section was changed under it).+function legacyPlace(C, target, opts) {+  const placed = (opts && opts.placed) || new Set(), forced = (opts && opts.forced) || new Set();+  const t0 = C.tree();+  const L = target.filter(u => C.live(u) && !placed.has(u)), inL = new Set(L);+  const re = new Set(orderMoves(t0.order.filter(u => inL.has(u)), L).map(m => m.uid));+  forced.forEach(u => { if (inL.has(u)) re.add(u); });+  // A row with no key yet (an older page's new row) is placed where that page put it too, so a+  // row placed beside it gets a key in the right order.+  L.forEach(u => { if (!posOk(C.val(u, 'pos'))) re.add(u); });+  if (!re.size) return;+  const done = new Set();+  for (const m of L) {+    if (!re.has(m)) continue;+    const t = C.tree(), s = C.val(m, 'phase');+    const S = L.filter(u => u === m || (C.val(u, 'phase') === s && !treeUnder(t, u, m)));+    const i = S.indexOf(m), X = i > 0 ? S[i - 1] : null, Y = i < S.length - 1 ? S[i + 1] : null;+    const cur = t.parentOf.get(m) || '';+    const ok = p => !!p && p !== m && C.live(p) && (C.base.get(p) || {}).type !== 'milestone' && !treeUnder(t, p, m) && t.phaseOf.get(p) === s+      && (t.depth.get(p) || 0) + 1 + treeHeight(t, m) <= TREE_DEPTH_MAX;+    let np = '';+    if (cur && X && (X === cur || treeUnder(t, X, cur)) && ok(cur)) np = cur;+    else if (Y && ok(t.parentOf.get(Y))) np = t.parentOf.get(Y);+    const inGroup = u => u !== m && (np ? t.parentOf.get(u) === np : (!t.parentOf.get(u) && C.val(u, 'phase') === s));+    let prev = null, next = null;+    for (let k = i - 1; k >= 0; k--) if (inGroup(S[k])) { prev = S[k]; break; }+    for (let k = i + 1; k < S.length; k++) if (inGroup(S[k]) && !(re.has(S[k]) && !done.has(S[k]))) { next = S[k]; break; }+    const key = keyBetween(prev ? C.val(prev, 'pos') : null, next ? C.val(next, 'pos') : null);+    if (np !== cur || (np === '' && C.val(m, 'parent'))) C.set(m, 'parent', np);+    if (key !== C.val(m, 'pos')) C.set(m, 'pos', key);+    done.add(m);+  }+}+// The tree kept whole, after any change to it. In order:+//  - a row whose parent is gone (deleted, by anybody, at any time) takes that row's place: its+//    parent, its section, a key just after it, walking up through any parent deleted with it;+//  - a link the tree cannot hold (a milestone, a loop, the row itself, a row that never+//    existed) is cut, and a row nested deeper than TREE_DEPTH_MAX moves up to the deepest+//    level allowed;+//  - a subtask's section is its top-level ancestor's (stored, for pages that read only that);+//  - a row with no key gets one after its siblings, in the order the rows are listed;+//  - two siblings holding one key: the one placed in this batch (`placed`) moves just after.+function normalizeTree(C, opts) {+  const placed = (opts && opts.placed) || new Set();+  const lives = C.liveList();+  const adopt = new Map();+  lives.forEach(u => {+    const p = C.parentOf(u);+    if (!p || C.live(p)) return;+    if (!C.base.has(p)) { C.set(u, 'parent', ''); return; }+    let z = p, g = 0;+    while (g++ < 10000) { const q = C.parentOf(z); if (q && C.base.has(q) && !C.live(q)) z = q; else break; }+    if (!adopt.has(z)) adopt.set(z, []);+    adopt.get(z).push(u);+  });+  adopt.forEach((list, z) => {+    const q = C.parentOf(z), np = q && C.live(q) ? q : '', ph = C.val(z, 'phase');+    const t = C.tree();+    const sib = lives.filter(u => !list.includes(u) && (np ? t.parentOf.get(u) === np : (!t.parentOf.get(u) && t.phaseOf.get(u) === ph)));+    const zk = C.val(z, 'pos'), after = sib.map(u => C.val(u, 'pos')).filter(k => posOk(k) && posOk(zk) && k > zk).sort()[0] || null;+    list.sort((a, b) => sibCmp({ uid: a, pos: C.val(a, 'pos'), _i: 0 }, { uid: b, pos: C.val(b, 'pos'), _i: 0 }));+    const keys = posOk(zk) ? keysBetween(zk, after, list.length) : keysBetween(sib.map(u => C.val(u, 'pos')).filter(posOk).sort().pop() || null, null, list.length);+    list.forEach((u, i) => { C.set(u, 'parent', np); C.set(u, 'pos', keys[i]); if (!np && Number.isInteger(ph) && C.val(u, 'phase') !== ph) C.set(u, 'phase', ph); });+  });+  let t = C.tree();+  lives.forEach(u => { const p = C.parentOf(u); if (p && t.parentOf.get(u) !== p) C.set(u, 'parent', ''); });+  for (let pass = 0; pass < 50; pass++) {+    t = C.tree();+    const deep = lives.filter(u => (t.depth.get(u) || 0) > TREE_DEPTH_MAX && (t.depth.get(t.parentOf.get(u)) || 0) === TREE_DEPTH_MAX);+    if (!deep.length) break;+    deep.forEach(u => C.set(u, 'parent', t.parentOf.get(t.parentOf.get(u)) || ''));+  }+  t = C.tree();+  lives.forEach(u => { if (!t.parentOf.get(u)) return; const ph = t.phaseOf.get(u); if (Number.isInteger(ph) && ph >= 0 && ph < C.n && C.val(u, 'phase') !== ph) C.set(u, 'phase', ph); });+  t = C.tree();+  const groups = new Map();+  t.order.forEach(u => { const p = t.parentOf.get(u), k = p ? 'p:' + p : 's:' + t.phaseOf.get(u); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(u); });+  groups.forEach(g => {+    const keyed = g.filter(u => posOk(C.val(u, 'pos'))), bare = g.filter(u => !posOk(C.val(u, 'pos')));+    let last = keyed.length ? C.val(keyed[keyed.length - 1], 'pos') : null;+    if (bare.length) keysBetween(last, null, bare.length).forEach((k, i) => C.set(bare[i], 'pos', k));+    for (let i = 0; i < keyed.length;) {+      const k = C.val(keyed[i], 'pos');+      let j = i; while (j + 1 < keyed.length && C.val(keyed[j + 1], 'pos') === k) j++;+      if (j > i) {+        const run = keyed.slice(i, j + 1);+        const keep = run.find(u => !placed.has(u)) || run[0];+        const rest = run.filter(u => u !== keep);+        const nxt = j + 1 < keyed.length ? C.val(keyed[j + 1], 'pos') : (bare.length ? C.val(bare[0], 'pos') : null);+        keysBetween(k, nxt, rest.length).forEach((nk, x) => C.set(rest[x], 'pos', nk));+      }+      i = j + 1;+    }+  });+  return C;+}+// The order an older page asked for: the stored order before this batch with its moves replayed+// on it (a new row it has not placed goes at the end of its section), grouped by section.+function legacyTarget(C, moves) {+  const live = C.liveList(), liveSet = new Set(live), phaseOf = u => C.val(u, 'phase');+  let cur = Array.isArray(C.state.order) ? C.state.order : live;+  cur = [...new Set(cur.filter(k => liveSet.has(k)))];+  const inCur = new Set(cur);+  live.forEach(k => {+    if (inCur.has(k)) return;+    const ph = phaseOf(k); let at = -1;+    for (let i = cur.length - 1; i >= 0; i--) if (phaseOf(cur[i]) === ph) { at = i; break; }+    if (at < 0) cur.push(k); else cur.splice(at + 1, 0, k);+    inCur.add(k);+  });+  (moves || []).forEach(m => moveInOrder(cur, m.uid, m.after));+  return groupBySection(cur, phaseOf, C.n);+}+// A whole-state save (/save: a page that predates ops, a script) says nothing about parents or+// keys, which keeps the stored ones; the order it carries says what it moved, and the rows it+// moved get places that keep them where it put them, as an older page's moves do.+function placeSaved(store, old, data) {+  if (!store || typeof store !== 'object' || Array.isArray(store)) return;+  ['items', 'added', 'deleted'].forEach(k => { if (!Array.isArray(store[k])) store[k] = []; });+  const oldEntries = new Map(), oldAdded = new Map();+  if (old && typeof old === 'object' && !Array.isArray(old)) {+    (old.items || []).forEach(o => { if (o && o.uid) oldEntries.set(o.uid, o); });+    (old.added || []).forEach(a => { if (a && a.uid) oldAdded.set(a.uid, a); });+  }+  const listed = new Set();+  store.items.forEach(o => { if (!o || !o.uid) return; listed.add(o.uid); const w = oldEntries.get(o.uid); if (w) ['parent', 'pos'].forEach(f => { if (o[f] === undefined && w[f] !== undefined) o[f] = w[f]; }); });+  store.added.forEach(a => { if (!a || !a.uid) return; const w = oldAdded.get(a.uid); if (w) ['parent', 'pos'].forEach(f => { if (a[f] === undefined && w[f] !== undefined) a[f] = w[f]; }); });+  // The rows as this body has them, for any entry made below: a whole one, as an edit makes it.+  let byUid = null;+  try { byUid = new Map((mergedFrom(JSON.parse(JSON.stringify(data)), JSON.parse(JSON.stringify(store))).items || []).map(r => [r && r.uid, r])); } catch (e) {}+  // A row the body does not list keeps its place.+  oldEntries.forEach((w, u) => { if (listed.has(u) || (w.parent === undefined && w.pos === undefined)) return;+    const m = byUid && byUid.get(u), e = m ? overrideOf(m) : { uid: u };+    ['parent', 'pos'].forEach(f => { if (w[f] !== undefined) e[f] = w[f]; }); store.items.push(e); });+  const C = placeCtx(store, data, null, byUid ? (u => byUid.get(u)) : null);+  if (Array.isArray(store.order) && store.order.length) {+    const t0 = C.tree(), target = groupBySection(store.order.filter(u => typeof u === 'string' && C.live(u)), u => C.val(u, 'phase'), C.n);+    const forced = new Set(target.filter(u => t0.parentOf.get(u) && t0.phaseOf.get(u) !== C.val(u, 'phase')));+    legacyPlace(C, target, { forced });+  }+  normalizeTree(C);+  store.order = C.tree().order;+}+// An imported plan's rows placed (0.79.0), in the data file itself: a parent named by uid or by+// task id (kept as the uid), one the tree cannot hold dropped, a subtask in its parent's+// section, and a key for every row without a valid one, in the order the file lists them.+function placeItems(data) {+  const st = { v: 4, items: [], deleted: [], added: [] };+  const C = placeCtx(st, data, null);+  normalizeTree(C);+  const by = new Map((data.items || []).map(r => [r && r.uid, r]));+  st.items.forEach(o => { const r = by.get(o.uid); if (!r) return;+    if (o.parent !== undefined) { if (o.parent) r.parent = o.parent; else delete r.parent; }+    if (o.pos !== undefined) r.pos = o.pos;+    if (o.phase !== undefined) r.phase = o.phase; });+  (data.items || []).forEach(r => { if (!r || typeof r.parent !== 'string' || !r.parent || by.has(r.parent)) return; const u = C.idTo.get(r.parent); if (u) r.parent = u; else delete r.parent; });+  return data;+} function tidyStateTags(st) {   if (!st || typeof st !== 'object' || Array.isArray(st)) return st;   const side = o => {@@ -2262,8 +2776,33 @@ function applyOverride(r, sv, phaseCount) {   if ('effort' in sv) { if (sv.effort === null) delete r.effort; else { const ef = normalizeEffort(sv.effort); if (ef !== undefined) r.effort = ef; } }   if (sv.name) r.name = sv.name;   if (Number.isInteger(sv.phase) && sv.phase >= 0 && sv.phase < phaseCount) r.phase = sv.phase;+  // Its place (0.79.0). '' : made a top-level row, over whatever the data file says.+  if (sv.parent !== undefined) { if (typeof sv.parent === 'string' && sv.parent) r.parent = sv.parent; else delete r.parent; }+  if (sv.pos !== undefined) { if (posOk(sv.pos)) r.pos = sv.pos; else delete r.pos; }   return r; }+// The merged plan laid out as the tree (0.79.0): each row's parent the one the tree can hold+// (a task id a data file names is read as that task's uid), a subtask in its top-level+// ancestor's section, and the rows in the order every page shows them: sections in list+// order, each top-level row by its key followed by its subtree. A row with no key keeps the+// place it had.+function treeMerged(data) {+  const n = (data.phases || []).length;+  const rows = data.items.filter(r => r && r.uid && r.type !== 'group'), other = data.items.filter(r => !(r && r.uid && r.type !== 'group'));+  const uids = new Set(rows.map(r => r.uid)), ids = new Map();+  rows.forEach(r => { if (r.id && !ids.has(r.id)) ids.set(r.id, r.uid); });+  const parentOf = r => { const v = r.parent; if (typeof v !== 'string' || !v) return ''; return uids.has(v) ? v : (ids.get(v) || v); };+  const t = treeOf(rows.map(r => ({ uid: r.uid, type: r.type, phase: r.phase, parent: parentOf(r), pos: r.pos })), n);+  rows.forEach(r => {+    const p = t.parentOf.get(r.uid);+    if (!p) { delete r.parent; return; }+    r.parent = p;+    const ph = t.phaseOf.get(r.uid); if (Number.isInteger(ph) && ph >= 0 && ph < n) r.phase = ph;+  });+  const by = new Map(rows.map(r => [r.uid, r]));+  data.items = t.order.map(u => by.get(u)).concat(other);+  return data;+} // The merged plan, worked out once per change to either file (0.78.1): it is a function of // the two and nothing else, and every /ops, export and page load asked for it afresh. Each // caller gets its own copy.@@ -2276,10 +2815,14 @@ function mergedData() {   }   return cloneJson(mergedCache.merged); }-function mergedDataFresh() {-  const data = tidyPlanTags(loadData());-  const state = tidyStateTags(upgradeState(loadState(), data));-  if (!state) return finaliseMerged(data);+function mergedDataFresh() { return mergedFrom(loadData(), loadState()); }+// The merge itself, of a data file and a sidecar given to it (0.79.0: /save works out the rows+// as the body it is saving has them). It changes what it is given.+function mergedFrom(rawData, rawState) {+  const data = tidyPlanTags(rawData);+  const state = tidyStateTags(upgradeState(rawState, data));+  // A data file with no sidecar keeps its own row order, unless its rows carry a tree.+  if (!state) return finaliseMerged((data.items || []).some(r => r && (r.parent || r.pos)) ? treeMerged(data) : data);   // Rows created in the app live only in the sidecar, so every reader of the   // merged plan (json, csv, svg, html) has to fold them back in. Appended; the   // saved order puts them where they belong.@@ -2388,6 +2931,7 @@ function mergedDataFresh() {       data.items = reordered;     }   }+  treeMerged(data);   // The merged plan is the app's canonical output, so it speaks the canonical   // vocabulary. Stored data is never touched: this normalises on the way OUT, so   // /export/json, /export/csv, the SVG and the HTML snapshot all agree, and a@@ -2397,54 +2941,20 @@ function mergedDataFresh() {  // ── CSV Parsing (RFC 4180 compliant) ── -function parseCSVRow(line) {-  const fields = [];-  let i = 0, field = '', inQuotes = false;-  while (i < line.length) {-    const ch = line[i];-    if (inQuotes) {-      if (ch === '"') {-        if (i + 1 < line.length && line[i + 1] === '"') {-          field += '"';-          i += 2;-        } else {-          inQuotes = false;-          i++;-        }-      } else {-        field += ch;-        i++;-      }-    } else {-      if (ch === '"') {-        inQuotes = true;-        i++;-      } else if (ch === ',') {-        fields.push(field.trim());-        field = '';-        i++;-      } else {-        field += ch;-        i++;-      }-    }-  }-  fields.push(field.trim());-  return fields;-}-+// One reader for every CSV import: parseCSVTable (under Import: ClickUp) reads the whole text,+// so a quoted cell can hold a line break. This used to split the text into lines first, so+// the app's own CSV export of a row whose notes run over two lines could not come back in:+// the row was cut in two at the break and the import refused the fragment's missing dates.+// Cells are trimmed and un-guarded as before. function parseCSV(text) {-  const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());-  if (lines.length < 2) return [];-  const headers = parseCSVRow(lines[0]).map(h => h.toLowerCase().trim());-  const rows = [];-  for (let i = 1; i < lines.length; i++) {-    const vals = parseCSVRow(lines[i]);+  const table = parseCSVTable(String(text == null ? '' : text).replace(/\r\n?/g, '\n'));+  if (table.length < 2) return [];+  const headers = table[0].map(h => String(h).toLowerCase().trim());+  return table.slice(1).map(vals => {     const obj = {};-    headers.forEach((h, j) => { obj[h] = csvUnguard(vals[j] || ''); });-    rows.push(obj);-  }-  return rows;+    headers.forEach((h, j) => { obj[h] = csvUnguard(String(vals[j] == null ? '' : vals[j]).trim()); });+    return obj;+  }); }  // A spreadsheet treats a cell opening with = + - @ (or a tab or CR) as a formula,@@ -2461,7 +2971,8 @@ function csvUnguard(s) { return (typeof s === 'string' && s.length > 1 && s[0] = function itemsToCSV(items, phases) {   // uid leads, because it is the column that survives everything else in the row   // being edited in the spreadsheet, and /import/csv reads it back.-  const headers = ['uid', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'tags', 'effort', 'type', 'label', 'date', 'striped'];+  // `parent` (0.79.0): the uid of the row this one sits under, read back by /import/csv.+  const headers = ['uid', 'parent', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'tags', 'effort', 'type', 'label', 'date', 'striped'];   const escape = v => {     if (v == null) return '';     const s = csvGuard(String(v));@@ -2512,6 +3023,7 @@ function importJSON(body) {   // 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.   const uids = stampUids(data);+  placeItems(data);   backupPlan('import');   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   // Clear state file since data structure changed@@ -2528,6 +3040,11 @@ function importCSV(body, queryString) {    const rows = parseCSV(body);   if (rows.length === 0) throw new Error('CSV has no data rows');+  // A ClickUp export chosen as plain CSV would fail below on its dates, naming every row.+  // Say what it is instead.+  if ('task id' in rows[0] && 'task name' in rows[0] && !('name' in rows[0])) {+    throw new Error('This is a ClickUp export. Import it with "ClickUp CSV" in the Import menu (POST /import/clickup-csv).');+  }    // Build items from CSV rows   const items = [];@@ -2574,6 +3091,8 @@ function importCSV(body, queryString) {     // A uid column the file carried is kept as-is here and validated in one place     // afterwards, so the dedupe rule cannot disagree with itself per importer.     if (row.uid) { item.uid = String(row.uid).trim(); }+    // The row it sits under (0.79.0), by uid or task id; resolved once every row has a uid.+    if (row.parent) { item.parent = String(row.parent).trim(); }      if (row.start) { item.start = row.start; }     if (row.end) { item.end = row.end; }@@ -2630,6 +3149,7 @@ function importCSV(body, queryString) {   // 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);+  placeItems(data);   backupPlan('import');   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);@@ -2794,9 +3314,11 @@ function importJiraCSV(body) {   // 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());+  // what this issue waits on; outward "Blocks" is what waits on it. The raw rows come from+  // the same whole-text reader as parseCSV's, so row i here is row i there even when a+  // description runs over several lines.+  const rawRows = parseCSVTable(String(body == null ? '' : body).replace(/\r\n?/g, '\n'));+  const heads = (rawRows[0] || []).map(h => String(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();@@ -2858,7 +3380,7 @@ function importJiraCSV(body) {     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] || '');+      const vals = (rawRows[i + 1] || []).map(v => String(v == null ? '' : v).trim());       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]); });     }@@ -2904,6 +3426,902 @@ function importJiraCSV(body) {   return { ok: true, items: validItems.length, uids }; } +// ── Import: ClickUp ──+//+// ClickUp's own files come in as they are, so a team moving a project out of ClickUp does+// not rebuild it by hand. Three doors, one mapper:+//+//   POST /import/clickup-csv   a CSV from ClickUp: a List or Table view's Export view (any of+//                              its date formats) or the workspace export (Settings, Imports+//                              and Exports). ?space= ?folder= ?list= keep only those+//                              containers (names or ids, comma separated), for a workspace+//                              export that holds far more than one project.+//   POST /import/clickup-json  what ClickUp's API answers for a List's tasks, saved to a file:+//                              {"tasks":[...]} from GET /api/v2/list/{id}/task?subtasks=true+//                              &include_closed=true, an array of those pages, or a bare array.+//   POST /import/clickup-api   the server asks ClickUp itself: {"list":"<id>"} (or "lists",+//                              "folder", "space", or a ClickUp link as "url"). The token comes+//                              from CLICKUP_TOKEN or a 0600 file, never from the request, and+//                              it is never written into the plan, the log or an answer.+//+// Every door ends in cuBuildPlan, which maps:+//   a List (and its Folder) -> a section, "Folder · List", with the Space in front only when+//                              the file spans more than one Space+//   tasks                   -> rows, in ClickUp's order+//   subtasks                -> rows right after their parent, depth first, each naming its+//                              parent's uid in `parent` (the subtasks model). A milestone is+//                              never a parent, and three levels under a task is the deepest+//   start and due           -> start and end. Only a due date: the bar starts on it. Only a+//                              start: it ends on it. A parent with neither spans its dated+//                              subtasks. No date at all: a one-day bar on the import's day,+//                              tagged undated and counted in the report and the changelog, so+//                              the made-up date never reads as a real one (Drew, 2026-11-03)+//   assignees               -> owner (the first; the others are named in the notes)+//   status                  -> the five statuses: by ClickUp's status type where the source+//                              gives it, by the name where it does not. A name nobody knows+//                              is in progress, which is what ClickUp's own middle statuses+//                              are, and the report lists it so a wrong guess is seen+//   time estimate           -> effort, in hours+//   tags, description       -> tags, notes+//   the Milestone task type -> a milestone, on its due date+//   dependencies            -> deps, to and from milestones too. The API carries them;+//                              ClickUp's CSV exports do not+// What has no home here (priority, custom fields, checklists, comments, time tracked,+// points, linked tasks) is counted in the report the import answers with, so nobody has to+// guess what stayed behind.++// A full RFC 4180 reader: a quoted field may hold commas, doubled quotes and NEWLINES.+// parseCSV above splits the text into lines first, which cuts a ClickUp task in two at the+// first line break of its description. Rows keep their cells as strings, untrimmed.+function parseCSVTable(text) {+  const s = String(text == null ? '' : text).replace(/^/, '');+  const rows = [];+  let row = [], field = '', quoted = false, i = 0;+  while (i < s.length) {+    const ch = s[i];+    if (quoted) {+      if (ch === '"') {+        if (s[i + 1] === '"') { field += '"'; i += 2; continue; }+        quoted = false; i++; continue;+      }+      field += ch; i++; continue;+    }+    // A quote opens a quoted cell at the start of the cell, spaces before it allowed (some+    // tools write `a, "b,c"`); anywhere else it is just a character.+    if (ch === '"' && field.trim() === '') { field = ''; quoted = true; i++; continue; }+    if (ch === ',') { row.push(field); field = ''; i++; continue; }+    if (ch === '\r' || ch === '\n') {+      row.push(field); rows.push(row); row = []; field = '';+      i += (ch === '\r' && s[i + 1] === '\n') ? 2 : 1;+      continue;+    }+    field += ch; i++;+  }+  if (field !== '' || row.length) { row.push(field); rows.push(row); }+  return rows.filter(r => r.some(c => String(c).trim() !== ''));+}++const CU_DAY_MS = 86400000;+const CU_MAX = { name: 500, owner: 200, desc: 20000, section: 200 };+// Said on every import's report, and in the README and SKILL.md ClickUp sections, until an+// import has been compared with the project it came from in ClickUp. Once one has, and what+// differed is fixed, this becomes '' and those two lines go (Drew, 2026-11-03).+const CU_UNCHECKED = 'Not yet checked against a real ClickUp project. Compare this plan with ClickUp before relying on it.';+// The day an undated task is placed on: ?today=YYYY-MM-DD, which the page sends as its own+// date so a person's evening import lands on their today, else this machine's date.+function cuToday(queryString) {+  const q = url_.parse('?' + (queryString || ''), true).query;+  if (q.today === undefined || q.today === '') { const d = new Date(); return cuYmd(d.getFullYear(), d.getMonth() + 1, d.getDate()); }+  const m = String(q.today).match(/^(\d{4})-(\d{2})-(\d{2})$/);+  const t = m ? cuYmd(m[1], m[2], m[3]) : '';+  if (!t) throw new Error('today must be a date like 2026-11-03.');+  return t;+}+const CU_MONTH = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };+const CU_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;++function cuYmd(y, m, d) {+  y = +y; m = +m; d = +d;+  if (!(y >= 1900 && y <= 2200 && m >= 1 && m <= 12 && d >= 1 && d <= 31)) return '';+  const t = new Date(Date.UTC(y, m - 1, d));+  return t.getUTCMonth() === m - 1 ? t.toISOString().slice(0, 10) : '';+}+function cuIsoOfMs(ms) { const t = new Date(ms); return isNaN(t) ? '' : t.toISOString().slice(0, 10); }+// ClickUp writes the four letters null (and NaN) where a cell has no value.+function cuCell(v) {+  const s = String(v == null ? '' : v).trim();+  return s === 'null' || s === 'NaN' || s === 'undefined' ? '' : s;+}+// "[a, b]" and "a,b" -> ["a", "b"]: assignee, tag and subtask cells. A JSON array where+// ClickUp wrote one (Folder Name/Path). A name holding a comma splits in two; no export+// measured holds one.+function cuList(v) {+  let s = cuCell(v);+  if (!s) return [];+  if (s[0] === '[' && s[s.length - 1] === ']') {+    if (/^\[\s*"/.test(s)) {+      try { const j = JSON.parse(s); if (Array.isArray(j)) return [...new Set(j.map(x => String(x).trim()).filter(Boolean))]; } catch (e) {}+    }+    s = s.slice(1, -1);+  }+  return [...new Set(s.split(',').map(x => x.trim()).filter(x => x && x !== 'null'))];+}+// Task ids from a cell: a list of ids, or ClickUp's "Name: https://app.clickup.com/t/<id>"+// form, which is how a view export writes a relationship.+function cuIds(v) {+  const s = cuCell(v);+  if (!s) return [];+  const linked = [...s.matchAll(/app\.clickup\.com\/t\/(?:\d+\/)?([A-Za-z0-9_-]+)/g)].map(m => m[1]);+  if (linked.length) return [...new Set(linked)];+  return cuList(s).filter(x => CU_ID_RE.test(x));+}+// The zone at the end of a ClickUp date text: "GMT+5:30", "GMT-5", a bare "GMT" (a zero+// offset is written with no sign), "-03:00". Minutes east of UTC, or null.+function cuOffsetOf(text) {+  const s = String(text || '').trim();+  let m = s.match(/(?:GMT|UTC)\s*([+-])(\d{1,2})(?::?(\d{2}))?$/i);+  if (m) return (m[1] === '-' ? -1 : 1) * (+m[2] * 60 + +(m[3] || 0));+  if (/(?:GMT|UTC)$/i.test(s)) return 0;+  m = s.match(/[\d\s]([+-])(\d{2}):?(\d{2})$/);+  if (m) return (m[1] === '-' ? -1 : 1) * (+m[2] * 60 + +m[3]);+  return null;+}+// A date written as text, read as the day it names: "9/23/2025, 12:51:40 PM GMT+5:30" (the+// workspace export, month first), "Friday, March 10th 2023" (a view export's Normal+// format), "2023-03-10T00:00:00.000+00:00" (its ISO format: the day as written).+function cuTextDate(text) {+  const s = String(text || '').trim();+  let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);+  if (m) return cuYmd(m[1], m[2], m[3]);+  m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);+  if (m) return +m[1] > 12 ? cuYmd(m[3], m[2], m[1]) : cuYmd(m[3], m[1], m[2]);+  m = s.match(/(?:^|[\s,])([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})/);+  if (m && CU_MONTH[m[1].slice(0, 3).toLowerCase()]) return cuYmd(m[3], CU_MONTH[m[1].slice(0, 3).toLowerCase()], m[2]);+  m = s.match(/^(\d{1,2})\s+([A-Za-z]{3,9})\.?,?\s+(\d{4})/);+  if (m && CU_MONTH[m[2].slice(0, 3).toLowerCase()]) return cuYmd(m[3], CU_MONTH[m[2].slice(0, 3).toLowerCase()], m[1]);+  return '';+}+// Unix milliseconds from a cell, or null. Ten digits or fewer are seconds.+function cuEpoch(v) {+  const s = cuCell(v).replace(/^"+|"+$/g, '').trim();+  if (!/^-?\d{9,15}$/.test(s)) return null;+  const n = Number(s);+  return s.replace('-', '').length <= 10 ? n * 1000 : n;+}+// ClickUp keeps a date with no time of day at 04:00 in the zone of whoever set it, so the+// offset that puts a value at 04:00 IS that zone. Minutes east of UTC, in (-11h, +14h].+function cuFourAmOffset(ms) {+  let off = 4 * 3600000 - (((ms % CU_DAY_MS) + CU_DAY_MS) % CU_DAY_MS);+  while (off <= -11 * 3600000) off += CU_DAY_MS;+  while (off > 14 * 3600000) off -= CU_DAY_MS;+  return Math.round(off / 60000);+}+// The zone of a file that never names one: the offset that puts its commonest time of day+// at 04:00, since most ClickUp dates are dates with no time.+function cuInferOffset(list) {+  const n = new Map();+  let best = null, cnt = 0;+  for (const ms of list) {+    const k = ((ms % CU_DAY_MS) + CU_DAY_MS) % CU_DAY_MS;+    const c = (n.get(k) || 0) + 1; n.set(k, c);+    if (c > cnt) { best = k; cnt = c; }+  }+  return best === null ? null : cuFourAmOffset(best);+}+function cuTzDay(tz) {+  try {+    const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' });+    return ms => { const p = {}; f.formatToParts(new Date(ms)).forEach(x => { p[x.type] = x.value; }); return cuYmd(p.year, p.month, p.day); };+  } catch (e) { return null; }+}+// Hours from a ClickUp time cell. `unit` is what the column says: 'ms' (the workspace+// export's Time Estimated), 'h' (a view export's "Time Estimate (hours)"), or '' for a+// view export's duration text: "2h 30m", "48 h", "10:05", "1d" (8 hours, as an effort here).+function cuHours(v, unit) {+  const s = cuCell(v).replace(/^"+|"+$/g, '').trim();+  if (!s) return undefined;+  if (/^\d+(\.\d+)?$/.test(s)) {+    const n = Number(s);+    if (unit === 'ms') return n / 3600000;+    if (unit === 'h') return n;+    return n >= 60000 ? n / 3600000 : n;     // an unlabelled number that large is milliseconds+  }+  if (unit === 'ms' || unit === 'h') return undefined;+  let m = s.match(/^(\d+):(\d{2})(?::(\d{2}))?$/);+  if (m) return +m[1] + +m[2] / 60 + +(m[3] || 0) / 3600;+  const per = { w: 40, d: 8, h: 1, m: 1 / 60, s: 1 / 3600 };+  let total = 0, any = false;+  for (const x of s.matchAll(/(\d+(?:\.\d+)?)\s*(w|wk|wks|weeks?|d|days?|h|hr|hrs|hours?|m|min|mins|minutes?|s|sec|secs|seconds?)\b/gi)) {+    total += Number(x[1]) * per[x[2][0].toLowerCase()]; any = true;+  }+  return any ? total : undefined;+}+const CU_PRIORITY = { '1': 'urgent', '2': 'high', '3': 'normal', '4': 'low' };+function cuPriority(v) {+  const s = cuCell(v).toLowerCase();+  if (!s || s === 'none' || s === 'no priority') return '';+  return CU_PRIORITY[s] || s;+}++// ClickUp's status names, per List and free to rename, onto the five statuses here.+// With the status TYPE (the API gives it), the type decides: "open" is the first status,+// "done" and "closed" the last, and "custom" every one in between. Without it (a CSV), the+// name decides, then a date the task was done, and a name nobody knows reads as in+// progress, since that is what an unnamed middle status is in ClickUp.+const CU_STATUS_NAMES = (() => {+  const m = new Map();+  const add = (to, list) => list.forEach(n => m.set(n, to));+  add('not-started', ['to do', 'todo', 'to-do', 'open', 'backlog', 'new', 'not started', 'planned', 'planning', 'ready', 'ready for dev',+    'ready for development', 'ready to start', 'queued', 'icebox', 'idea', 'ideas', 'scoping', 'pending', 'upcoming', 'triage', 'requested',+    'captured', 'defined', 'up next', 'next up', 'next', 'later', 'someday', 'scheduled']);+  add('in-progress', ['in progress', 'in-progress', 'doing', 'active', 'working', 'working on it', 'in review', 'review', 'reviewing', 'in development',+    'development', 'in dev', 'testing', 'in testing', 'qa', 'in qa', 'mr review', 'pr review', 'code review', 'in design', 'design', 'research',+    'drafting', 'draft', 'in process', 'in-process', 'started', 'ongoing', 'ready for review', 'ready for qa', 'needs review', 'validation',+    'verification', 'staging', 'implementation', 'building', 'pending approval', 'awaiting approval', 'feedback', 'revisions', 'wip']);+  add('complete', ['complete', 'completed', 'done', 'closed', 'finished', 'resolved', 'shipped', 'released', 'delivered', 'merged', 'deployed',+    'approved', 'accepted', 'signed off', 'published', 'live', 'fixed', 'verified']);+  add('blocked', ['blocked', 'on hold', 'hold', 'waiting', 'stuck', 'paused', 'parked', 'waiting on parts', 'waiting on customer', 'waiting on client',+    'waiting for parts', 'needs info', 'needs input']);+  add('cancelled', ['cancelled', 'canceled', "won't do", 'wont do', 'not doing', 'abandoned', 'rejected', 'duplicate', 'dropped', 'obsolete',+    'invalid', "won't fix", 'wontfix', 'declined']);+  return m;+})();+const CU_CANCEL_RE = /\b(cancel+ed|cancel|won'?t (do|fix)|not doing|abandoned|rejected|duplicate|dropped|obsolete|declined)\b/;+const CU_BLOCK_RE = /\b(block(ed|er|ing)?|on hold|hold|waiting|stuck|paused|parked|impeded)\b/;+const CU_DONE_RE = /\b(complete[ds]?|done|closed|finish(ed)?|resolved|shipped|released|delivered|merged|deployed|signed off)\b/;+function cuStatus(name, type, doneHint) {+  const n = String(name || '').trim().toLowerCase().replace(/\s+/g, ' ');+  const t = String(type || '').toLowerCase();+  if (t) {+    if (CU_CANCEL_RE.test(n) || CU_STATUS_NAMES.get(n) === 'cancelled') return { to: 'cancelled', by: 'type' };+    if (t === 'done' || t === 'closed') return { to: 'complete', by: 'type' };+    if (t === 'open') return { to: 'not-started', by: 'type' };+    // A middle status is not done in ClickUp, whatever it is called ("approved", "shipped+    // to QA"), so a name that sounds finished does not make it complete.+    const named = CU_STATUS_NAMES.get(n);+    if (named && named !== 'complete') return { to: named, by: 'name' };+    if (CU_BLOCK_RE.test(n)) return { to: 'blocked', by: 'name' };+    return { to: 'in-progress', by: 'type' };+  }+  if (!n) return { to: doneHint ? 'complete' : 'not-started', by: doneHint ? 'date' : 'guess' };+  const named = CU_STATUS_NAMES.get(n);+  if (named) return { to: named, by: 'name' };+  if (CU_CANCEL_RE.test(n)) return { to: 'cancelled', by: 'name' };+  if (CU_BLOCK_RE.test(n)) return { to: 'blocked', by: 'name' };+  if (CU_DONE_RE.test(n)) return { to: 'complete', by: 'name' };+  if (doneHint) return { to: 'complete', by: 'date' };+  return { to: 'in-progress', by: 'guess' };+}++// The CSV's columns. Read: each name a column can go by, in the workspace export and in a+// view export. Skipped: carries nothing a plan holds (who made a task and when, a second+// spelling of a column that is read). Reported: content a plan has no home for, counted+// per task. A column in none of these is a custom field when it is named "Name (type)",+// which is how a view export writes one, and a column nobody knows otherwise; both are+// reported, never dropped in silence.+const CU_CSV_SKIP = new Set(['date created', 'date created text', 'date updated', 'date updated text', 'date closed text', 'date done text',+  'created by', 'parent name', 'parent url', "subtask url's", 'subtask urls', 'home location', 'other locations', 'time estimated text',+  'time spent text', 'rolled up time', 'rolled up time text', 'time estimate rolled up', 'time estimate rolled up (hours)',+  'time logged rolled up', 'time logged rolled up (hours)', 'points estimate rolled up', 'assigned comments', 'assigned comment count',+  'due date text', 'start date text', 'task type', 'task custom id', 'custom task id', 'custom id', 'task link', 'task url', 'url', 'link',+  'date done', 'date closed', 'list id', 'home location id', 'parent id', 'parent task id', 'subtasks ids', "subtask id's", 'subtask ids',+  'task id', 'task name', 'name', 'task content', 'description', 'content', 'status', 'start date', 'due date', 'assignees', 'assignee',+  'tags', 'priority', 'list name', 'list', 'folder name/path', 'folder name', 'folder', 'space name', 'space', 'time estimated',+  'time estimate', 'time estimate (hours)', 'time estimated (hours)', 'waiting on', 'dependencies', 'depends on', 'blocking']);+const CU_CSV_REPORT = {+  'checklists': 'Checklists', 'comments': 'Comments', 'latest comment': 'Comments', 'comment count': 'Comments',+  'attachments': 'Attachments', 'time spent': 'Time tracked', 'time logged': 'Time tracked', 'time logged (hours)': 'Time tracked',+  'time spent (hours)': 'Time tracked', 'points estimate': 'Sprint points', 'sprint points': 'Sprint points', 'sprints': 'Sprints',+  'linked tasks': 'Linked tasks', 'linked docs': 'Linked docs', 'other location ids': 'Tasks also in other Lists',+  'lists': 'Tasks also in other Lists', 'watchers': 'Watchers',+};+// What a reader of the report needs to know about a thing that did not come across.+const CU_LOST_DETAIL = {+  'Tasks also in other Lists': 'each came across once, in its home List',+  'Linked tasks': 'a ClickUp link is not a dependency',+  'Team assignees': 'an owner here is one person',+};+// A reported cell holds something worth counting: not empty, not an empty list or object,+// not a zero count. "Lists" names every List a task is in, so one entry is its home.+function cuHasContent(col, v) {+  let s = cuCell(v).replace(/^\s*"+|"+\s*$/g, '').trim();+  if (!s || s === '[]' || s === '{}' || s === '0' || s === '0 m' || s === '0m') return false;+  if (col === 'lists') return cuList(s).length > 1;+  if (col === 'comment count') return Number(s) > 0;+  if (col === 'checklists') { try { const j = JSON.parse(s); if (j && typeof j === 'object') return Object.keys(j).length > 0; } catch (e) {} }+  return true;+}++function cuReadCSV(text) {+  const rows = parseCSVTable(String(text == null ? '' : text).replace(/\r\n?/g, '\n'));+  if (!rows.length) throw new Error('This file is empty.');+  const header = rows[0].map(h => String(h || '').trim());+  const lower = header.map(h => h.toLowerCase());+  const col = (...names) => { for (const n of names) { const i = lower.indexOf(n); if (i >= 0) return i; } return -1; };+  const C = {+    id: col('task id'), name: col('task name', 'name'), customId: col('task custom id', 'custom task id', 'custom id'),+    desc: col('task content', 'description', 'content'), url: col('task link', 'task url', 'url', 'link'), type: col('task type'),+    status: col('status'), done: col('date done'), closed: col('date closed'),+    start: col('start date'), startText: col('start date text'), due: col('due date'), dueText: col('due date text'),+    parent: col('parent id', 'parent task id'), subtasks: col('subtasks ids', "subtask id's", 'subtask ids'),+    assignees: col('assignees', 'assignee'), tags: col('tags'), priority: col('priority'),+    list: col('list name', 'list'), listId: col('home location id', 'list id'), folderPath: col('folder name/path'),+    folder: col('folder name', 'folder'), space: col('space name', 'space'),+    estMs: col('time estimated'), estH: col('time estimate (hours)', 'time estimated (hours)'), est: col('time estimate'),+    waits: col('waiting on', 'dependencies', 'depends on'), blocks: col('blocking'),+  };+  if (C.id < 0 || C.name < 0) {+    throw new Error('This does not look like a ClickUp export: it has no "Task ID" and "Task Name" columns. In ClickUp, export a List or Table view (Export view, CSV, all columns), or the whole workspace (Settings, Imports and Exports).');+  }+  if (rows.length < 2) throw new Error('This ClickUp export has no tasks under its header, so there is nothing to import.');+  const workspace = C.listId >= 0 || C.folderPath >= 0 || col('task link') >= 0;+  const twins = [C.startText, C.dueText, col('date created text'), col('date updated text')].filter(i => i >= 0);+  const body = rows.slice(1);+  // The exporter's zone, for an epoch cell with no text twin of its own: the commonest zone+  // the file names, else the one its dates with no time of day imply.+  const zones = new Map(), epochs = [];+  body.forEach(r => {+    twins.concat([C.start, C.due]).forEach(i => { const o = cuOffsetOf(cuCell(r[i])); if (o !== null && !/^\d{4}-\d\d-\d\dT00:00:00(\.0+)?[+-]00:?00$/.test(cuCell(r[i]))) zones.set(o, (zones.get(o) || 0) + 1); });+    [C.start, C.due].forEach(i => { const ms = i >= 0 ? cuEpoch(r[i]) : null; if (ms !== null) epochs.push(ms); });+  });+  let fileOffset = null, top = 0;+  zones.forEach((n, o) => { if (n > top) { top = n; fileOffset = o; } });+  if (fileOffset === null && epochs.length) fileOffset = cuInferOffset(epochs);+  let unreadable = 0;+  const dateOf = (r, iv, it) => {+    const v = iv >= 0 ? cuCell(r[iv]) : '';+    if (!v) return '';+    const ms = cuEpoch(v);+    if (ms !== null) {+      let off = it >= 0 ? cuOffsetOf(cuCell(r[it])) : null;+      if (off === null) off = fileOffset === null ? 0 : fileOffset;+      return cuIsoOfMs(ms + off * 60000);+    }+    const d = cuTextDate(v);+    if (!d) unreadable++;+    return d;+  };+  const unitOf = C.estH >= 0 ? 'h' : C.estMs >= 0 ? 'ms' : '';+  const estCol = C.estH >= 0 ? C.estH : C.estMs >= 0 ? C.estMs : C.est;+  // Every column that is not read, by what becomes of it.+  const reported = [], custom = [], unknown = [];+  header.forEach((h, i) => {+    const k = lower[i];+    if (!k) return;+    if (CU_CSV_REPORT[k]) reported.push([i, k, CU_CSV_REPORT[k]]);+    else if (CU_CSV_SKIP.has(k)) return;+    else { const m = h.match(/^(.+?)\s*\(([^()]+)\)$/); if (m) custom.push([i, m[1].trim(), m[2].trim()]); else unknown.push([i, h]); }+  });+  const recs = body.map((r, pos) => {+    const rec = {+      id: cuCell(r[C.id]), customId: C.customId >= 0 ? cuCell(r[C.customId]) : '', name: cuCell(r[C.name]),+      desc: C.desc >= 0 ? String(r[C.desc] == null ? '' : r[C.desc]).trim() : '', url: C.url >= 0 ? cuCell(r[C.url]) : '',+      typeName: C.type >= 0 ? cuCell(r[C.type]) : '',+      status: { name: C.status >= 0 ? cuCell(r[C.status]) : '', type: null },+      done: (C.done >= 0 && !!cuCell(r[C.done])) || (C.closed >= 0 && !!cuCell(r[C.closed])),+      start: dateOf(r, C.start, C.startText), due: dateOf(r, C.due, C.dueText),+      parent: C.parent >= 0 ? cuCell(r[C.parent]) : '', subtaskIds: C.subtasks >= 0 ? cuIds(r[C.subtasks]) : [],+      assignees: C.assignees >= 0 ? cuList(r[C.assignees]) : [], tags: C.tags >= 0 ? cuList(r[C.tags]) : [],+      priority: C.priority >= 0 ? cuPriority(r[C.priority]) : '',+      estimateH: estCol >= 0 ? cuHours(r[estCol], unitOf) : undefined,+      space: C.space >= 0 ? cuCell(r[C.space]) : '',+      folder: C.folderPath >= 0 ? cuList(r[C.folderPath]).flatMap(p => p.split('/')).map(x => x.trim()).filter(Boolean).join(' / ')+        : (C.folder >= 0 && cuCell(r[C.folder]).toLowerCase() !== 'hidden' ? cuCell(r[C.folder]) : ''),+      list: C.list >= 0 ? cuCell(r[C.list]) : '', listId: C.listId >= 0 ? cuCell(r[C.listId]) : '',+      waitsOn: C.waits >= 0 ? cuIds(r[C.waits]) : [], blocks: C.blocks >= 0 ? cuIds(r[C.blocks]) : [],+      pos,+      lost: [...new Set(reported.filter(([i, k]) => cuHasContent(k, r[i])).map(x => x[2]))],+      custom: custom.filter(([i]) => !!cuCell(r[i]) && cuCell(r[i]) !== '[]').map(([, n, t]) => `${n} (${t})`),+      unknown: unknown.filter(([i]) => !!cuCell(r[i])).map(x => x[1]),+    };+    rec.milestone = /^milestones?$/i.test(rec.typeName);+    if (rec.typeName && !rec.milestone && !/^tasks?$/i.test(rec.typeName)) rec.customType = rec.typeName;+    return rec;+  });+  return { recs, meta: { from: workspace ? 'ClickUp workspace export (CSV)' : 'ClickUp view export (CSV)', csv: true,+    hasDeps: C.waits >= 0 || C.blocks >= 0, unreadable } };+}++// The API's task objects (GET /list/{id}/task) as the same records. Dates: a date ClickUp+// says has no time (due_date_time false) is read by its 04:00 rule, exactly; any other one+// in the account's own zone when the API path could ask for it, else in the zone the+// file's dates imply.+function cuReadApiTasks(tasks, opts) {+  const tzDay = opts && opts.tz ? cuTzDay(opts.tz) : null;+  const all = [];+  tasks.forEach(t => [t && t.due_date, t && t.start_date].forEach(v => { const ms = cuEpoch(v); if (ms !== null) all.push(ms); }));+  const inferred = cuInferOffset(all) || 0;+  const dayOf = (v, timed) => {+    const ms = cuEpoch(v);+    if (ms === null) return '';+    if (timed === false) return cuIsoOfMs(ms + cuFourAmOffset(ms) * 60000);+    if (tzDay) return tzDay(ms);+    return cuIsoOfMs(ms + inferred * 60000);+  };+  // Every task's dependencies name both ends: {task_id, depends_on} means task_id waits on+  // depends_on, and the same entry sits on both tasks.+  const waits = new Map();+  tasks.forEach(t => (Array.isArray(t && t.dependencies) ? t.dependencies : []).forEach(d => {+    if (!d || d.task_id == null || d.depends_on == null) return;+    const a = String(d.task_id), b = String(d.depends_on);+    if (!waits.has(a)) waits.set(a, new Set());+    waits.get(a).add(b);+  }));+  const set = v => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length);+  return tasks.map((t, pos) => {+    const id = cuCell(t.id), item = Number(t.custom_item_id);+    const rec = {+      id, customId: cuCell(t.custom_id), name: cuCell(t.name), url: cuCell(t.url),+      desc: String(t.text_content || t.description || '').trim(),+      milestone: item === 1,+      customType: t.custom_item_id != null && t.custom_item_id !== '' && item !== 0 && item !== 1 ? 'custom type ' + t.custom_item_id : '',+      status: { name: t.status && typeof t.status === 'object' ? cuCell(t.status.status) : cuCell(t.status),+        type: t.status && typeof t.status === 'object' ? cuCell(t.status.type) : null },+      done: !!(t.date_done || t.date_closed),+      start: dayOf(t.start_date, t.start_date_time), due: dayOf(t.due_date, t.due_date_time),+      parent: cuCell(t.parent), subtaskIds: [],+      assignees: (Array.isArray(t.assignees) ? t.assignees : []).map(a => a && cuCell(a.username || a.email)).filter(Boolean),+      tags: (Array.isArray(t.tags) ? t.tags : []).map(x => x && cuCell(typeof x === 'string' ? x : x.name)).filter(Boolean),+      priority: t.priority && typeof t.priority === 'object' ? cuPriority(t.priority.priority) : cuPriority(t.priority),+      estimateH: cuHours(t.time_estimate, 'ms'),+      space: '', spaceId: t.space && cuCell(t.space.id),+      folder: t.folder && !t.folder.hidden ? cuCell(t.folder.name) : '',+      list: t.list ? cuCell(t.list.name) : '', listId: t.list ? cuCell(t.list.id) : '',+      waitsOn: [...(waits.get(id) || [])], blocks: [],+      order: parseFloat(t.orderindex), pos,+      lost: [], custom: [], unknown: [],+    };+    if (Array.isArray(t.checklists) && t.checklists.length) rec.lost.push('Checklists');+    if (Number(t.time_spent) > 0) rec.lost.push('Time tracked');+    if (set(t.points)) rec.lost.push('Sprint points');+    if (Array.isArray(t.linked_tasks) && t.linked_tasks.length) rec.lost.push('Linked tasks');+    if (Array.isArray(t.locations) && t.locations.length) rec.lost.push('Tasks also in other Lists');+    if (Array.isArray(t.group_assignees) && t.group_assignees.length) rec.lost.push('Team assignees');+    (Array.isArray(t.custom_fields) ? t.custom_fields : []).forEach(f => {+      if (f && f.name && set(f.value)) rec.custom.push(`${f.name} (${String(f.type || 'field').replace(/_/g, ' ')})`);+    });+    return rec;+  });+}+// The tasks inside what the API answered: one page ({"tasks":[...]}), several pages in an+// array, a bare array of tasks, or one task with its subtasks.+function cuApiTasksOf(j) {+  const out = [];+  const take = t => {+    if (!t || typeof t !== 'object' || t.id == null) return;+    out.push(t);+    if (Array.isArray(t.subtasks)) t.subtasks.forEach(s => { if (s && typeof s === 'object' && s.parent == null) s.parent = t.id; take(s); });+  };+  (Array.isArray(j) ? j : [j]).forEach(p => { if (p && Array.isArray(p.tasks)) p.tasks.forEach(take); else take(p); });+  if (!out.length) {+    throw new Error('No ClickUp tasks in this JSON. Save what ClickUp\'s API answers for GET /api/v2/list/<list id>/task?subtasks=true&include_closed=true (it starts {"tasks":[), or several of those pages in an array.');+  }+  return out;+}++// Keep only the tasks in the containers asked for (?space=, ?folder=, ?list=): names or+// ids, comma separated, any case.+function cuFilter(recs, queryString) {+  const q = url_.parse('?' + (queryString || ''), true).query;+  const want = k => String(q[k] || '').split(',').map(x => x.trim().toLowerCase()).filter(Boolean);+  const sp = want('space'), fo = want('folder'), li = want('list');+  if (!sp.length && !fo.length && !li.length) return recs;+  const hit = (w, ...vals) => !w.length || vals.some(v => v && w.includes(String(v).toLowerCase()));+  const out = recs.filter(r => hit(sp, r.space, r.spaceId) && hit(fo, r.folder) && hit(li, r.list, r.listId));+  if (!out.length) {+    const lists = [...new Set(recs.map(r => [r.space, r.folder, r.list].filter(Boolean).join(' / ')).filter(Boolean))].slice(0, 12);+    throw new Error(`No task in this file is in the Space, Folder or List asked for. The file's Lists: ${lists.join('; ') || 'none named'}.`);+  }+  return out;+}++// The records, whatever door they came through, as a plan and a report of the crossing.+function cuBuildPlan(recsIn, meta) {+  const n = {};+  const bump = (k, by) => { n[k] = (n[k] || 0) + (by === undefined ? 1 : by); };+  // One record per task. The workspace export can repeat a task on a second row that+  // differs only in its assignees and subtask ids; the rows are merged, first row winning.+  const byId = new Map(), recs = [];+  recsIn.forEach((r, i) => {+    if (!r.id) r.id = 'row' + (i + 1);+    const was = byId.get(r.id);+    if (!was) { byId.set(r.id, r); recs.push(r); return; }+    bump('merged');+    was.assignees = [...new Set(was.assignees.concat(r.assignees))];+    was.subtaskIds = [...new Set(was.subtaskIds.concat(r.subtaskIds))];+    was.waitsOn = [...new Set(was.waitsOn.concat(r.waitsOn))];+    was.blocks = [...new Set(was.blocks.concat(r.blocks))];+  });+  // Parents. Parent ID is the truth; a view export without that column still lists each+  // task's subtasks on its parent's row. A parent not in the file leaves its subtask at the+  // top of its List, and a chain that loops is cut where it closes.+  recs.forEach(r => r.subtaskIds.forEach(s => { const c = byId.get(s); if (c && !c.parent && c !== r) c.parent = r.id; }));+  recs.forEach(r => { if (r.parent === r.id || (r.parent && !byId.has(r.parent))) { if (r.parent !== r.id) bump('orphans'); r.parent = ''; } });+  for (const r of recs) {+    let p = r.parent, steps = 0;+    while (p && steps++ <= recs.length) {+      if (p === r.id) { r.parent = ''; bump('cycles'); break; }+      p = byId.get(p).parent;+    }+  }+  // ClickUp's order: a CSV's own row order (the view's sort and grouping as exported); the+  // API's orderindex within each List, Lists in the order they came.+  const listRank = new Map();+  recs.forEach(r => { const k = r.listId || r.list; if (!listRank.has(k)) listRank.set(k, listRank.size); });+  const ord = r => (Number.isFinite(r.order) ? r.order : Infinity);+  const cmp = meta.csv ? (a, b) => a.pos - b.pos+    : (a, b) => (listRank.get(a.listId || a.list) - listRank.get(b.listId || b.list)) || (ord(a) - ord(b)) || (a.pos - b.pos);+  const kids = new Map();+  recs.forEach(r => { if (r.parent) { if (!kids.has(r.parent)) kids.set(r.parent, []); kids.get(r.parent).push(r); } });+  kids.forEach(k => k.sort(cmp));+  const roots = recs.filter(r => !r.parent).sort(cmp);+  // Depth first, iteratively (an export can nest deep): each task, then its subtasks.+  const order = [];+  const stack = roots.slice().reverse().map(r => [r, r, 0]);+  while (stack.length) {+    const [r, root, depth] = stack.pop();+    order.push([r, root, depth]);+    const ks = kids.get(r.id) || [];+    for (let i = ks.length - 1; i >= 0; i--) stack.push([ks[i], root, depth + 1]);+  }+  // Dates, subtasks first so a parent with none of its own can span them.+  const span = new Map();+  for (let k = order.length - 1; k >= 0; k--) {+    const r = order[k][0];+    let own = null;+    if (r.milestone) { const d = r.due || r.start; if (d) own = { date: d }; }+    else if (r.start && r.due) {+      if (r.start <= r.due) own = { start: r.start, end: r.due };+      else { bump('startAfterDue'); own = { start: r.due, end: r.due }; }+    } else if (r.due) { bump('startFromDue'); own = { start: r.due, end: r.due }; }+    else if (r.start) { bump('endFromStart'); own = { start: r.start, end: r.start }; }+    let lo = '', hi = '';+    (kids.get(r.id) || []).forEach(c => { const s = span.get(c.id); if (s) { if (!lo || s[0] < lo) lo = s[0]; if (!hi || s[1] > hi) hi = s[1]; } });+    if (!own && !r.milestone && lo) { own = { start: lo, end: hi }; bump('rolledUp'); }+    const a = own ? (own.date || own.start) : '', b = own ? (own.date || own.end) : '';+    const L = [a, lo].filter(Boolean).sort()[0], H = [b, hi].filter(Boolean).sort().pop();+    if (L) span.set(r.id, [L, H]);+    // No date of its own and none below it (Drew, 2026-11-03): a one-day bar on the day of+    // the import, tagged undated, rather than left out. Only real dates go into `span`, so a+    // parent spans its dated subtasks and never a placeholder.+    if (!own) { r.undated = true; bump('undated'); own = r.milestone ? { date: meta.today } : { start: meta.today, end: meta.today }; }+    r.dates = own;+  }+  // Sections: the List each top-level task is in, named "Folder · List", the Space in front+  // only when the file spans more than one. A subtask goes where its top-level task went.+  const multiSpace = new Set(recs.map(r => r.space).filter(Boolean)).size > 1;+  const secKey = r => r.listId || [r.space, r.folder, r.list].join('\u0001');+  const secName = r => ([multiSpace ? r.space : '', r.folder, r.list].filter(Boolean).join(' · ') || 'ClickUp').slice(0, CU_MAX.section);+  const phases = [], secOf = new Map(), perSec = [], names = new Set();+  const emitted = new Map(), usedIds = new Set();+  const statusTally = new Map(), lost = new Map(), custom = new Map(), unknown = new Map(), prio = new Map(), types = new Map();+  const tally = (m, k) => m.set(k, (m.get(k) || 0) + 1);+  for (const [r, root, depth] of order) {+    const k = secKey(root);+    if (!secOf.has(k)) {+      let nm = secName(root), j = 2;+      while (names.has(nm.toLowerCase())) nm = `${secName(root)} (${j++})`;+      names.add(nm.toLowerCase());+      secOf.set(k, phases.length); phases.push({ name: nm, start: '', end: '' }); perSec.push([]);+    }+    if (depth && secKey(r) !== k) bump('otherList');+    const name = (r.name || '(untitled ClickUp task)').slice(0, CU_MAX.name);+    if ((r.name || '').length > CU_MAX.name) bump('longName');+    // A milestone has an id like a task's (0.78.0), so dependencies can name either.+    const it = { type: r.milestone ? 'milestone' : 'task', id: '', name };+    let id = 'cu-' + String(r.id).replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 64), j = 2;+    while (usedIds.has(id)) id = 'cu-' + String(r.id).replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 64) + '-' + j++;+    usedIds.add(id); it.id = id;+    if (r.milestone) { it.date = r.dates.date; bump('milestones'); }+    else { it.start = r.dates.start; it.end = r.dates.end; bump('tasks'); }+    it.phase = secOf.get(k);+    if (r.assignees.length) it.owner = r.assignees[0].slice(0, CU_MAX.owner);+    const st = cuStatus(r.status.name, r.status.type, r.done);+    it.status = st.to;+    const sk = (r.status.name || '(no status)') + '\u0001' + st.to + '\u0001' + st.by;+    statusTally.set(sk, (statusTally.get(sk) || 0) + 1);+    let desc = r.desc;+    if (r.assignees.length > 1) { bump('moreAssignees'); desc = (desc ? desc + '\n\n' : '') + 'Also assigned in ClickUp: ' + r.assignees.slice(1).join(', ') + '.'; }+    if (desc.length > CU_MAX.desc) { bump('longDesc'); desc = desc.slice(0, CU_MAX.desc); }+    if (desc) it.description = desc;+    // `undated` leads, so the tag cap can never be what drops it.+    const tags = normalizeTags(r.undated ? ['undated'].concat(r.tags) : r.tags);+    if (tags && tags.length) it.tags = tags;+    if (!r.milestone) { const ef = normalizeEffort(r.estimateH); if (ef !== undefined) { it.effort = ef; bump('effort'); } }+    const link = /^https?:\/\//i.test(r.url) ? r.url : (CU_ID_RE.test(r.id) && !/^row\d+$/.test(r.id) ? 'https://app.clickup.com/t/' + r.id : '');+    it.provenance = link ? { label: 'ClickUp ' + (r.customId || r.id), url: link } : 'ClickUp ' + (r.customId || r.id);+    if (r.priority) tally(prio, r.priority);+    r.lost.forEach(x => tally(lost, x));+    r.custom.forEach(x => tally(custom, x));+    r.unknown.forEach(x => tally(unknown, x));+    if (r.customType) tally(types, r.customType);+    perSec[it.phase].push([it, r]);+    emitted.set(r.id, it);+  }+  const total = recs.length;+  if (!emitted.size) throw new Error('Nothing to import: the file holds no ClickUp tasks.');+  // Dependencies, onto the rows that came across: to and from milestones too, since a+  // milestone has an id (0.78.0). A link to a task that did not come across has nothing to+  // point at, and is counted.+  const waits = new Map();+  const addW = (a, b) => { if (!waits.has(a)) waits.set(a, new Set()); waits.get(a).add(b); };+  recs.forEach(r => { r.waitsOn.forEach(d => addW(r.id, d)); r.blocks.forEach(b => addW(b, r.id)); });+  waits.forEach((set, waiter) => {+    const it = emitted.get(waiter);+    if (!it) return;+    const deps = [];+    set.forEach(d => {+      if (d === waiter) return;+      const t = emitted.get(d);+      if (!t) { bump('depOutside'); return; }+      if (!deps.includes(t.id)) deps.push(t.id);+    });+    if (deps.length) { it.deps = deps; bump('deps', deps.length); }+  });+  // Subtasks keep their parent, as the subtasks model has it (MODEL-subtasks.md, issue #5):+  // `parent` is the parent row's uid, set in cuWrite once the uids exist, and `pos` is left+  // out, so the server places each sibling group in the order the rows are listed here. A+  // parent is a task that came across: a subtask of a milestone goes under the nearest task+  // above it, or is a top-level row when there is none. Three levels under a top-level task+  // at most, ClickUp's default; a deeper one sits at the third level, under its ancestor there.+  const parentOf = new Map(), depthOf = new Map();+  for (const [r] of order) {+    const it = emitted.get(r.id);+    if (!it) continue;+    let p = r.parent, par = null;+    while (p) {+      const pi = emitted.get(p);+      if (pi && pi.type === 'task') { par = pi; break; }+      p = byId.get(p).parent;+    }+    if (r.parent && (!par || par !== emitted.get(r.parent))) bump(par ? 'underAncestor' : 'noTaskAbove');+    let d = par ? depthOf.get(par) + 1 : 0;+    if (d > 3) { while (depthOf.get(par) > 2) par = parentOf.get(par); d = 3; bump('tooDeep'); }+    depthOf.set(it, d);+    if (par) { parentOf.set(it, par); bump('subtasks'); }+  }+  // The rows, a section at a time in the order the sections appeared, each section's span+  // from its own rows.+  const items = [], outOf = new Map();+  perSec.forEach((rows, i) => {+    let lo = '', hi = '';+    rows.forEach(([it]) => {+      const a = it.date || it.start, b = it.date || it.end;+      if (!lo || a < lo) lo = a; if (!hi || b > hi) hi = b;+      // Keys in the order the data file uses everywhere else.+      const o = {};+      ['type', 'id', 'name', 'start', 'end', 'date', 'phase', 'owner', 'status', 'description', 'deps', 'tags', 'effort', 'provenance']+        .forEach(k => { if (it[k] !== undefined && it[k] !== '') o[k] = it[k]; });+      items.push(o);+      outOf.set(it, o);+    });+    phases[i].start = lo.slice(0, 7); phases[i].end = hi.slice(0, 7);+  });+  // The parent links, onto the rows as written (each above is a fresh copy, in file order).+  const parentOut = new Map();+  parentOf.forEach((par, child) => parentOut.set(outOf.get(child), outOf.get(par)));+  // A title from the one container everything shares, the narrowest first.+  const one = f => { const s = new Set(recs.map(f)); return s.size === 1 ? [...s][0] : ''; };+  const title = one(r => r.list) || one(r => r.folder) || one(r => r.space) || 'ClickUp import';+  const data = {+    project: { title: title.slice(0, CU_MAX.section), subtitle: `Imported from ${/^ClickUp API/.test(meta.from) ? 'the' : 'a'} ${meta.from}` },+    phases, items,+  };+  // ── The report ──+  const changed = [];+  const say = (k, one, many) => { if (k) changed.push(k === 1 ? one : many.replace(/#/g, k)); };+  say(n.undated, `1 task has no start or due date in ClickUp, so it is a one-day bar on ${fmtDate(meta.today)}, tagged undated.`,+    `# tasks have no start or due date in ClickUp, so each is a one-day bar on ${fmtDate(meta.today)}, tagged undated.`);+  say(n.subtasks, '1 subtask keeps its parent task and comes right after it.', '# subtasks keep their parent task, each right after it.');+  say(n.underAncestor, '1 subtask of a milestone sits under the nearest task above it instead: a milestone has no subtasks here.',+    '# subtasks of milestones sit under the nearest task above them instead: a milestone has no subtasks here.');+  say(n.noTaskAbove, '1 subtask of a milestone is a top-level row: a milestone has no subtasks here, and no task is above it.',+    '# subtasks of milestones are top-level rows: a milestone has no subtasks here, and no task is above them.');+  say(n.tooDeep, '1 subtask is nested more than three levels deep; it sits at the third level, under its ancestor there.',+    '# subtasks are nested more than three levels deep; each sits at the third level, under its ancestor there.');+  say(n.startFromDue, '1 task has a due date and no start date, so its bar starts on its due date.',+    '# tasks have a due date and no start date, so each bar starts on its due date.');+  say(n.endFromStart, '1 task has a start date and no due date, so its bar ends on its start date.',+    '# tasks have a start date and no due date, so each bar ends on its start date.');+  say(n.startAfterDue, '1 task starts after its due date in ClickUp, so its bar starts on its due date.',+    '# tasks start after their due date in ClickUp, so each bar starts on its due date.');+  say(n.rolledUp, '1 parent task has no dates of its own, so it spans its subtasks.',+    '# parent tasks have no dates of their own, so each spans its subtasks.');+  say(n.moreAssignees, '1 task has more than one assignee: the first is the owner and the others are named in its notes.',+    '# tasks have more than one assignee: the first is the owner and the others are named in the notes.');+  say(n.merged, '1 row repeated a task already in the file and was merged into it.', '# rows repeated a task already in the file and were merged into it.');+  say(n.orphans, '1 subtask whose parent is not in the file is at the top level of its List.',+    '# subtasks whose parent is not in the file are at the top level of their List.');+  say(n.otherList, "1 subtask sits in a different List from its parent, so it is in its parent's section.",+    "# subtasks sit in a different List from their parent, so each is in its parent's section.");+  say(n.cycles, '1 parent chain loops back on itself; the loop is cut.', '# parent chains loop back on themselves; each loop is cut.');+  say(n.longDesc, `1 description is longer than ${CU_MAX.desc} characters and was cut there.`, `# descriptions are longer than ${CU_MAX.desc} characters and were cut there.`);+  say(n.longName, `1 name is longer than ${CU_MAX.name} characters and was cut there.`, `# names are longer than ${CU_MAX.name} characters and were cut there.`);+  const statuses = [...statusTally].map(([k, count]) => { const [name, to, by] = k.split('\u0001'); return { name, to, by, count }; })+    .sort((a, b) => STATUS_ORDER.indexOf(a.to) - STATUS_ORDER.indexOf(b.to) || b.count - a.count || a.name.localeCompare(b.name));+  const guessed = statuses.filter(s => s.by === 'guess' && s.name !== '(no status)');+  if (guessed.length) changed.push(`ClickUp ${guessed.length === 1 ? 'status' : 'statuses'} ${guessed.map(s => `"${s.name}"`).join(', ')} ${guessed.length === 1 ? 'is not one this importer knows, so it reads' : 'are not ones this importer knows, so they read'} as in progress. Change ${guessed.length === 1 ? 'it' : 'them'} in the Table if that is wrong.`);+  const dropped = [];+  if (prio.size) {+    const lv = ['urgent', 'high', 'normal', 'low'];+    const det = [...prio].sort((a, b) => (lv.indexOf(a[0]) + 1 || 9) - (lv.indexOf(b[0]) + 1 || 9)).map(([p, c]) => `${p} ${c}`).join(', ');+    dropped.push({ what: 'Priority', count: [...prio.values()].reduce((a, b) => a + b, 0), detail: det });+  }+  [...custom].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).forEach(([f, c]) => dropped.push({ what: `Custom field ${f}`, count: c }));+  [...lost].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).forEach(([w, c]) => dropped.push(CU_LOST_DETAIL[w] ? { what: w, count: c, detail: CU_LOST_DETAIL[w] } : { what: w, count: c }));+  if (types.size) {+    dropped.push({ what: 'Custom task types', count: [...types.values()].reduce((a, b) => a + b, 0),+      detail: [...types.keys()].join(', ') + '; each came across as a task' });+  }+  if (n.depOutside) dropped.push({ what: 'Dependencies on tasks that did not come across', count: n.depOutside, detail: 'outside this file, or left out for want of a date' });+  if (meta.csv && !meta.hasDeps) dropped.push({ what: 'Dependencies', count: 0, detail: "ClickUp's CSV exports do not include them. Import from ClickUp's API (ClickUp API JSON) to bring them across." });+  if (unknown.size) dropped.push({ what: 'Columns this importer does not read', count: [...unknown.values()].reduce((a, b) => Math.max(a, b), 0), detail: [...unknown.keys()].join(', ') });+  if (meta.unreadable) dropped.push({ what: 'Dates that could not be read', count: meta.unreadable });+  const report = {+    from: meta.from, read: total, rows: items.length, tasks: n.tasks || 0, milestones: n.milestones || 0, subtasks: n.subtasks || 0,+    sections: phases.length, deps: n.deps || 0, effort: n.effort || 0,+    undated: n.undated || 0, undatedOn: meta.today,+    statuses, changed, dropped,+  };+  if (CU_UNCHECKED) report.unchecked = CU_UNCHECKED;+  return { data, report, parentOf: parentOut };+}++// One line per thing that did not come across, for the changelog's import event, so an+// agent reading the log later knows what the plan never had.+function cuLogLines(report) {+  return report.dropped.filter(d => d.count).map(d => `${d.what} on ${d.count} task${d.count === 1 ? '' : 's'}`);+}++function cuWrite(built) {+  const data = built.data;+  // The builder never writes an undated row; this is the rule every importer keeps.+  assertDates(data.items);+  // Every milestone has an id already (cu-<ClickUp id>); this is the rule every import keeps.+  stampMilestoneIds(data.items, planIds(data.items));+  // ClickUp's ids mean nothing here, so the plan numbers from the top, as from MS Project.+  const uids = stampUids(data, { fresh: true });+  // Now that every row has its uid, each subtask names its parent by it.+  if (built.parentOf) built.parentOf.forEach((par, child) => { if (par.uid) child.parent = par.uid; });+  // The subtasks model's placement (MODEL-subtasks.md): every row a key in the listed order,+  // and the tree checked, as the JSON and CSV imports do once that model is in this server.+  // Before it is, there is nothing to call and the rows are already in their order.+  if (typeof placeItems === 'function') placeItems(data);+  backupPlan('import');+  fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');+  if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+  // The plan files are kept parsed (0.78.1); an import replaces both, so the copies go.+  forgetPlanFiles();+  return { ok: true, items: data.items.length, phases: data.phases.length, uids, report: built.report, logged: cuLogLines(built.report),+    undated: built.report.undated, undatedOn: built.report.undatedOn };+}++function importClickUpCSV(body, queryString) {+  const today = cuToday(queryString);+  const read = cuReadCSV(body);+  return cuWrite(cuBuildPlan(cuFilter(read.recs, queryString), Object.assign(read.meta, { today })));+}++function importClickUpJSON(body, queryString) {+  const today = cuToday(queryString);+  let j;+  try { j = JSON.parse(body); }+  catch (e) { throw new Error('This file is not JSON. Save what ClickUp\'s API answers for a List\'s tasks, or use ClickUp CSV for a file exported from ClickUp.'); }+  const recs = cuReadApiTasks(cuApiTasksOf(j));+  return cuWrite(cuBuildPlan(cuFilter(recs, queryString), { from: 'ClickUp API (saved JSON)', csv: false, today }));+}++// ── ClickUp's API, straight from the server ──+//+// The token: CLICKUP_TOKEN in the server's environment, else the first line of a file only+// its owner can read: --clickup-token-file <path>, or CLICKUP_TOKEN_FILE, or+// ~/.config/adom-gantt/clickup-token. Read on every call, so it can be added or rotated+// without a restart. It goes out in one header to ClickUp and nowhere else.+const CU_TOKEN_FILE = (() => {+  const i = args.indexOf('--clickup-token-file');+  if (i >= 0 && args[i + 1]) return path.resolve(args[i + 1]);+  if (process.env.CLICKUP_TOKEN_FILE) return path.resolve(process.env.CLICKUP_TOKEN_FILE);+  return path.join(require('os').homedir(), '.config', 'adom-gantt', 'clickup-token');+})();+// Where ClickUp's API is. Only the environment sets it (a test points it at a stand-in on+// loopback); a request never can.+const CU_API = String(process.env.CLICKUP_API_BASE || 'https://api.clickup.com/api/v2').replace(/\/+$/, '');+function clickupToken() {+  const env = String(process.env.CLICKUP_TOKEN || '').trim();+  if (env) return { token: env, from: 'environment' };+  let st;+  try { st = fs.statSync(CU_TOKEN_FILE); } catch (e) {+    return { error: `No ClickUp token. Set CLICKUP_TOKEN in the server's environment, or put the token alone in ${CU_TOKEN_FILE} with chmod 600.` };+  }+  if (st.mode & 0o077) return { error: `The ClickUp token file ${CU_TOKEN_FILE} can be read by other users of this machine, so it is not used. Run chmod 600 on it.` };+  let t = '';+  try { t = fs.readFileSync(CU_TOKEN_FILE, 'utf8').split(/\r?\n/)[0].trim(); } catch (e) {}+  return t ? { token: t, from: 'file' } : { error: `The ClickUp token file ${CU_TOKEN_FILE} is empty.` };+}+// What to fetch: {"list":"901"}, {"lists":["901","902"]}, {"folder":"55"}, {"space":"7"}, or a+// ClickUp link as {"url": ...} (or the bare link as the body): /li/<id> is a List, /f/<id> a+// Folder, /s/<id> a Space.+function cuTarget(body) {+  let p = {};+  const raw = String(body || '').trim();+  if (raw) { try { p = JSON.parse(raw); } catch (e) { p = { url: raw }; } }+  if (typeof p === 'string' || typeof p === 'number') p = { url: String(p) };+  if (!p || typeof p !== 'object') p = {};+  const t = { lists: [], folders: [], spaces: [] };+  const add = (k, v) => { if (v === undefined || v === null || v === '') return; const s = String(v).trim();+    if (!CU_ID_RE.test(s)) throw new Error(`"${s.slice(0, 40)}" is not a ClickUp id. Use the number in the List's link (…/v/li/<id>).`); if (!t[k].includes(s)) t[k].push(s); };+  add('lists', p.list); (Array.isArray(p.lists) ? p.lists : []).forEach(x => add('lists', x));+  add('folders', p.folder); add('spaces', p.space);+  if (p.url) {+    const u = String(p.url).trim();+    let m;+    if ((m = u.match(/\/li\/(\d+)/))) add('lists', m[1]);+    else if ((m = u.match(/\/f\/(\d+)/))) add('folders', m[1]);+    else if ((m = u.match(/\/s\/(\d+)/))) add('spaces', m[1]);+    else if (/^\d+$/.test(u)) add('lists', u);+    else throw new Error('That link does not name a ClickUp List, Folder or Space. Copy the List\'s link in ClickUp (it holds /v/li/ and a number).');+  }+  if (!t.lists.length && !t.folders.length && !t.spaces.length) {+    throw new Error('Say what to import: {"list":"<List id>"}, {"folder":"<Folder id>"}, {"space":"<Space id>"} or {"url":"<a ClickUp List link>"}.');+  }+  return t;+}+async function cuGet(pathAndQuery, token, what) {+  let r;+  try {+    r = await fetch(CU_API + pathAndQuery, { headers: { Authorization: token, Accept: 'application/json' }, signal: AbortSignal.timeout(30000) });+  } catch (e) {+    const err = new Error(`Could not reach ClickUp (${e.name === 'TimeoutError' ? 'no answer in 30 seconds' : 'network error'}).`); err.status = 502; throw err;+  }+  if (!r.ok) {+    const why = r.status === 401 ? 'ClickUp refused the token (401). Check CLICKUP_TOKEN or the token file.'+      : r.status === 404 || r.status === 400 ? `ClickUp has no ${what} that this token can see (${r.status}).`+      : r.status === 429 ? 'ClickUp is limiting how often this token can ask (429). Try again in a minute.'+      : `ClickUp answered ${r.status} for ${what}.`;+    const err = new Error(why); err.status = 502; throw err;+  }+  try { return await r.json(); } catch (e) { const err = new Error(`ClickUp's answer for ${what} was not JSON.`); err.status = 502; throw err; }+}+async function cuFetchTasks(target, token) {+  const lists = target.lists.slice();+  for (const f of target.folders) {+    const j = await cuGet(`/folder/${f}/list?archived=false`, token, `Folder ${f}`);+    (Array.isArray(j.lists) ? j.lists : []).forEach(l => l && l.id != null && lists.push(String(l.id)));+  }+  for (const s of target.spaces) {+    const fo = await cuGet(`/space/${s}/folder?archived=false`, token, `Space ${s}`);+    (Array.isArray(fo.folders) ? fo.folders : []).forEach(f => (Array.isArray(f && f.lists) ? f.lists : []).forEach(l => l && l.id != null && lists.push(String(l.id))));+    const li = await cuGet(`/space/${s}/list?archived=false`, token, `Space ${s}`);+    (Array.isArray(li.lists) ? li.lists : []).forEach(l => l && l.id != null && lists.push(String(l.id)));+  }+  const done = new Set(), tasks = [];+  for (const id of lists) {+    if (done.has(id) || !CU_ID_RE.test(id)) continue;+    done.add(id);+    // 100 tasks a page; 200 pages is 20,000 tasks, far past any one project.+    for (let page = 0; page < 200; page++) {+      const j = await cuGet(`/list/${id}/task?subtasks=true&include_closed=true&page=${page}`, token, `List ${id}`);+      const got = Array.isArray(j.tasks) ? j.tasks : [];+      tasks.push(...got);+      if (j.last_page === true || !got.length || (j.last_page === undefined && got.length < 100)) break;+    }+  }+  // The account's zone, for a date with a time of day. A date with none does not need it.+  let tz = null;+  try { const u = await cuGet('/user', token, 'the signed-in user'); tz = u && u.user && typeof u.user.timezone === 'string' ? u.user.timezone : null; } catch (e) {}+  return { tasks, tz, lists: [...done] };+}+async function importClickUpAPI(body, queryString) {+  const today = cuToday(queryString);+  const target = cuTarget(body);+  const tok = clickupToken();+  if (!tok.token) { const err = new Error(tok.error); err.status = 400; throw err; }+  const got = await cuFetchTasks(target, tok.token);+  if (!got.tasks.length) throw new Error(`ClickUp holds no tasks in ${got.lists.length === 1 ? 'that List' : 'those Lists'}.`);+  const recs = cuReadApiTasks(got.tasks, { tz: got.tz });+  return cuWrite(cuBuildPlan(recs, { from: 'ClickUp API', csv: false, today }));+}+ // ── Export: SVG ──  // The app draws a phase in `var(--ph1..3)`, cycling every three lanes, and returns@@ -3036,7 +4454,11 @@ function generateSVG(dataArg) {     svg += `\n<rect x="${x1}" y="${HEADER_H}" width="${x2 - x1}" height="${totalH - HEADER_H}" fill="${svgPhaseColor(p, pi)}" opacity="0.04"/>`;   }); -  // Items+  // Items. A subtask's name is indented under its task's (0.79.0).+  const depthOf = new Map(), parentUid = new Map(items.map(r => [r && r.uid, r && r.parent]));+  const depth = r => { let d = 0, p = r && r.parent; const seen = new Set(); while (p && parentUid.has(p) && !seen.has(p) && d < 10) { seen.add(p); d++; p = parentUid.get(p); } return d; };+  items.forEach(r => { if (r && r.uid) depthOf.set(r.uid, depth(r)); });+  const indent = r => PAD + 14 * (depthOf.get(r && r.uid) || 0);   let y = HEADER_H;   items.forEach(r => {     const h = r.type === 'group' ? GROUP_H : ROW_H;@@ -3046,7 +4468,7 @@ function generateSVG(dataArg) {       svg += `\n<text class="group-label" x="${PAD}" y="${y + h / 2 + 4}">${esc(r.label)}</text>`;     } else if (r.type === 'milestone') {       const mx = toX(r.date);-      svg += `\n<text class="ms-label" x="${PAD}" y="${y + h / 2 + 4}">&#9670; ${esc(r.name)}</text>`;+      svg += `\n<text class="ms-label" x="${indent(r)}" y="${y + h / 2 + 4}">&#9670; ${esc(r.name)}</text>`;       svg += `\n<rect x="${mx - 5}" y="${y + 12}" width="10" height="10" transform="rotate(45 ${mx} ${y + 17})" fill="#e6edf3" stroke="#0d1117" stroke-width="2"/>`;     } else {       const x1 = toX(r.start);@@ -3065,10 +4487,10 @@ function generateSVG(dataArg) {                              live: 'live', planned: 'planned' };       const stag = r.status ? ` [${statusLabels[r.status] || r.status}]` : ''; -      svg += `\n<text class="bar-label" x="${PAD}" y="${y + h / 2 + 4}">${esc(r.name)}</text>`;+      svg += `\n<text class="bar-label" x="${indent(r)}" y="${y + h / 2 + 4}">${esc(r.name)}</text>`;       if (r.status) {         const tagColor = statusColors[r.status] || '#484f58';-        const textLen = r.name.length * 7 + PAD;+        const textLen = r.name.length * 7 + indent(r);         svg += `\n<text class="status-text" x="${textLen + 8}" y="${y + h / 2 + 4}" fill="${tagColor}">${esc(statusLabels[r.status] || r.status)}</text>`;       } @@ -3320,6 +4742,8 @@ const server = http.createServer(async (req, res) => {         // 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) {} }+        try { placeSaved(store, loadState(), loadData()); } catch (e) { console.error('save: the rows were not placed (' + e.message + ')'); }+        try { carryAddedEdits(store, loadState()); } catch (e) { console.error('save: created rows not carried into their overrides (' + e.message + ')'); }         // 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.@@ -3724,6 +5148,39 @@ const server = http.createServer(async (req, res) => {       return;     } +    // ClickUp: a CSV export, the API's JSON saved to a file, or the API itself. Each answers+    // with a `report` of what changed and what did not come across (see Import: ClickUp).+    if (req.method === 'POST' && (urlPath === '/import/clickup-csv' || urlPath === '/import/clickup-json')) {+      const body = await readBody(req);+      try {+        const result = urlPath === '/import/clickup-csv' ? importClickUpCSV(body, query) : importClickUpJSON(body, query);+        logImport(urlPath.slice('/import/'.length), result, auth.agent ? AGENT_ACTOR : null);+        jsonResp(res, 200, result);+      } catch (e) {+        jsonResp(res, 400, { ok: false, error: e.message });+      }+      return;+    }+    // GET says whether a token is set up (never the token); POST imports.+    if (urlPath === '/import/clickup-api') {+      if (req.method === 'GET') {+        const t = clickupToken();+        jsonResp(res, 200, { ok: true, configured: !!t.token, from: t.token ? t.from : null, tokenFile: CU_TOKEN_FILE, error: t.token ? undefined : t.error });+        return;+      }+      if (req.method === 'POST') {+        const body = await readBody(req);+        try {+          const result = await importClickUpAPI(body, query);+          logImport('clickup-api', result, auth.agent ? AGENT_ACTOR : null);+          jsonResp(res, 200, result);+        } catch (e) {+          jsonResp(res, e.status || 400, { ok: false, error: e.message });+        }+        return;+      }+    }+     // ── Export endpoints ──      // The four exports render from the merged plan on disk on a GET, which is what@@ -3866,7 +5323,7 @@ server.listen(PORT, HOST, () => {   console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://${HOST.includes(':') ? '[' + HOST + ']' : HOST}:${PORT}`);   console.log(`State file: ${STATE_FILE}`);   console.log(`Data dir: ${DATA_DIR}`);-  console.log(`Endpoints: /health /data /state /live /live/ping /access /notify /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+  console.log(`Endpoints: /health /data /state /live /live/ping /access /notify /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv,clickup-csv,clickup-json,clickup-api} /export/{json,csv,svg,html}`);   const integ = integrityStatus();   console.log(integ.ok     ? `Changelog integrity: OK (hash chain verified${integ.chained ? '' : ', not yet chained'})`
test/collab_test.mjs+188−5
@@ -15,7 +15,10 @@ //   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-//                                                  milestones)+//                                                  subtasks milestones)+//   node test/collab_test.mjs --old-page p.html    the 0.77 page the subtasks scenario opens beside+//                                                  this build (default: gantt.html of commit+//                                                  ffdacb4, 0.77.1, from this checkout's git) //   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@@ -25,7 +28,7 @@ // // Budgets scale with --throttle. COLLAB_BUDGET_<NAME>=<n> overrides one (see BUDGETS). import puppeteer from './node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js';-import { spawn } from 'node:child_process';+import { spawn, execFileSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, basename, resolve } from 'node:path';@@ -45,6 +48,7 @@ const APP = resolve(arg('app', join(HERE, '..'))); const PLAN = arg('plan', ''); const REPORT = arg('report', ''); const SEED = +arg('seed', 7000);   // the fuzz's random sequence; each person adds their index+const OLD_PAGE = arg('old-page', ''); const KEEP = flag('keep'); const want = s => !ONLY || ONLY.includes(s); const sleep = ms => new Promise(r => setTimeout(r, ms));@@ -318,15 +322,18 @@ async function convergence(S, people) {   const srv = new Map((await planRows(S, people[0].sid)).filter(r => r.uid).map(r => [r.uid, r]));   const full = await people[0].page.evaluate(() => !!(window.__collab && window.__collab.order));   // From 0.77.0 the tabs report every field, and the order, so all of it is compared.-  const norm = r => JSON.stringify([r.name || '', r.owner || '', r.status || '', r.start || '', r.end || '', r.date || '', r.phase, (r.deps || []).slice().sort(), r.description || r.desc || '',-    ...(full ? [r.note || '', (Array.isArray(r.tags) ? r.tags : []).map(t => String(t).toLowerCase()).sort(), typeof r.effort === 'number' ? r.effort : null] : [])]);+  const norm = (r, tree) => JSON.stringify([r.name || '', r.owner || '', r.status || '', r.start || '', r.end || '', r.date || '', r.phase, (r.deps || []).slice().sort(), r.description || r.desc || '',+    ...(full ? [r.note || '', (Array.isArray(r.tags) ? r.tags : []).map(t => String(t).toLowerCase()).sort(), typeof r.effort === 'number' ? r.effort : null] : []),+    ...(tree ? [r.parent || ''] : [])]);   const srvOrder = [...srv.keys()];   const diffs = [];   for (const P of people) {     const rows = await P.page.evaluate(() => window.__collab.rows());     const mine = new Map(rows.map(r => [r.uid, r]));+    // From 0.79.0 a tab reports each row's parent too (a 0.77 tab does not, and is compared without).+    const tree = rows.length > 0 && 'parent' in rows[0];     if (mine.size !== srv.size) diffs.push(`${P.name}: ${mine.size} rows, server ${srv.size}`);-    for (const [u, s] of srv) { const m = mine.get(u); if (!m) { diffs.push(`${P.name}: missing ${u}`); continue; } if (norm(m) !== norm(s)) diffs.push(`${P.name}: ${u} ${norm(m)} vs server ${norm(s)}`); }+    for (const [u, s] of srv) { const m = mine.get(u); if (!m) { diffs.push(`${P.name}: missing ${u}`); continue; } if (norm(m, tree) !== norm(s, tree)) diffs.push(`${P.name}: ${u} ${norm(m, tree)} vs server ${norm(s, tree)}`); }     if (full) { const o = (await P.page.evaluate(() => window.__collab.order())).filter(u => srv.has(u)); if (o.join() !== srvOrder.join()) diffs.push(`${P.name}: row order differs from the server's`); }   }   return diffs;@@ -913,6 +920,181 @@ async function sectionsScenario(carbon) {   await stopServer(S); } +// ═════════════════════════════════════════════════════════════════════════════+// Subtasks (0.79.0, issue #5). Each row holds its parent and its own place among its siblings+// (a fractional key), so a move or a nest is one row's fields, last writer wins, and there is+// no shared order to fight over. Two people nest and un-nest at once; two people put one row+// under different tasks; two people put two tasks under each other (a loop); a row is nested+// under a task somebody deletes, in both orders; undo of a nest is your own; and a 0.77 tab+// left open on the new server sees the tree as a flat plan, moves rows in it, and nobody fights.+// ═════════════════════════════════════════════════════════════════════════════+async function openOldPerson(S, carbon, i, oldHtml) {+  const P = await openPerson(S, carbon, i);+  await P.page.setRequestInterception(true);+  P.page.on('request', async req => {+    try {+      const u = new URL(req.url());+      if (req.resourceType() === 'document' && (u.pathname === '/' || u.pathname === '/gantt.html')) {+        // The identity the server would put in this person's page, read off this build's page.+        const html = await (await fetch(S.base + '/', { headers: { Cookie: 'adom_gantt_sid=' + P.sid } })).text();+        const m = html.match(/const RAW_USER = (.*);\n/);+        await req.respond({ status: 200, contentType: 'text/html', headers: { 'Cache-Control': 'no-store' }, body: oldHtml.split('__APP_VERSION__').join('0.77.1').split('"__ADOM_USER__"').join(m ? m[1] : 'null') });+      } else await req.continue();+    } catch (e) { try { await req.continue(); } catch (_) {} }+  });+  P.old = true;+  return P;+}+async function subtasksScenario(carbon) {+  scenario = 'subtasks';+  let oldHtml = '';+  try { oldHtml = OLD_PAGE ? readFileSync(OLD_PAGE, 'utf8') : execFileSync('git', ['-C', APP, 'show', 'ffdacb4:gantt.html'], { encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'] }); } catch (e) { oldHtml = ''; }+  const S = await startServer(carbon);+  const people = [];+  for (let i = 0; i < 3; i++) people.push(await openPerson(S, carbon, i));+  if (oldHtml) people.push(await openOldPerson(S, carbon, 3, oldHtml));+  for (const P of people) await goReady(P, S);+  await sleep(1500);+  const [A, Bp, Cp] = people, O = people[3] || null, news = [A, Bp, Cp];+  const can = await A.page.evaluate(() => !!(window.__collab && window.__collab.nest && window.__collab.tree));+  if (!can) { skip('subtasks', 'this build has no subtasks (no __collab.nest)'); for (const P of people) await closePerson(P); await stopServer(S); return; }+  const rows = await A.page.evaluate(() => window.__collab.rows());+  const tasksIn = p => rows.filter(r => r.phase === p && r.id && r.start);+  const secs = [...new Set(rows.map(r => r.phase))].filter(p => tasksIn(p).length >= 6).sort((a, b) => a - b);+  const srv = async () => new Map((await planRows(S, A.sid)).map(r => [r.uid, r]));+  const parentOf = async u => ((await srv()).get(u) || {}).parent || '';+  const tabParent = (P, u) => P.page.evaluate(x => (window.__collab.tree().find(r => r.uid === x) || {}).parent || '', u);+  const pauseAll = list => Promise.all(list.map(P => P.page.evaluate(() => window.__collab.pause())));+  const resumeAll = async list => { await Promise.all(list.map(P => P.page.evaluate(() => window.__collab.resume()))); await quiesce(S, people); };+  const nest = (P, u, p) => P.page.evaluate(([a, b]) => window.__collab.nest(a, b), [u, p]);+  const unnest = (P, u) => P.page.evaluate(a => window.__collab.unnest(a), u);+  people.forEach(P => { P.ops = []; P.page.on('request', r => { if (r.method() === 'POST' && r.url().split('?')[0].endsWith('/ops')) P.ops.push(Date.now()); }); });+  const quiet = async () => { const t0 = Date.now(); await sleep(3000); return people.reduce((n, P) => n + P.ops.filter(x => x >= t0).length, 0); };+  const X = tasksIn(secs[0]), Y = tasksIn(secs[1]);++  // 1. Two people nest and un-nest at the same moment; a third nests elsewhere.+  check('a task is nested under another', await nest(A, X[1].uid, X[0].uid));+  await quiesce(S, people);+  await pauseAll(news);+  await nest(A, X[2].uid, X[0].uid); await unnest(Bp, X[1].uid); await nest(Cp, X[4].uid, X[3].uid);+  await sent(news); await resumeAll(news);+  let sv = await srv();+  check('two people nest and un-nest at once, a third nests elsewhere: all three land', (sv.get(X[2].uid).parent || '') === X[0].uid && !(sv.get(X[1].uid).parent) && (sv.get(X[4].uid).parent || '') === X[3].uid,+    JSON.stringify([X[2].uid, sv.get(X[2].uid).parent, X[1].uid, sv.get(X[1].uid).parent, X[4].uid, sv.get(X[4].uid).parent]));+  let diffs = await convergence(S, people);+  check('and every tab holds exactly what the server holds, parents and the order included', !diffs.length, diffs.slice(0, 3).join(' | '));++  // 2. One row put under two different tasks at once: the later place wins it whole, everywhere.+  await pauseAll(news);+  await nest(A, X[5].uid, X[0].uid); await sent([A]);+  await nest(Bp, X[5].uid, X[3].uid); await sent([Bp]);+  await resumeAll(news);+  const t2 = await Promise.all(news.map(P => tabParent(P, X[5].uid)));+  check('one row nested under two tasks at once: the later nest wins, in every tab', (await parentOf(X[5].uid)) === X[3].uid && t2.every(p => p === X[3].uid), JSON.stringify([await parentOf(X[5].uid), t2]));++  // 3. Two tasks put under each other at once: one lands, the other is refused, no loop.+  await pauseAll(news);+  await nest(A, X[3].uid, X[0].uid); await nest(Bp, X[0].uid, X[3].uid);+  await sent(news); await resumeAll(news);+  const p0 = await parentOf(X[0].uid), p3 = await parentOf(X[3].uid);+  check('two tasks nested under each other at once: one lands, the server refuses the loop', (p0 === X[3].uid) !== (p3 === X[0].uid), JSON.stringify([p0, p3]));+  diffs = await convergence(S, people);+  check('and every tab agrees which one', !diffs.length, diffs.slice(0, 3).join(' | '));++  // 4a. A row nested under a task somebody deleted a moment before: it takes the task's place.+  const P1 = Y[0], R1 = Y[2], P2 = Y[3], R2 = Y[5];+  await A.page.evaluate(() => window.__collab.pause());+  await Bp.page.evaluate(u => window.__collab.del(u), P1.uid); await sent([Bp]);+  await nest(A, R1.uid, P1.uid); await sent([A]);+  await resumeAll([A]);+  sv = await srv();+  check('nesting under a task somebody had just deleted: the row is kept, where the task was', sv.has(R1.uid) && !sv.has(P1.uid) && !(sv.get(R1.uid).parent) && sv.get(R1.uid).phase === secs[1], JSON.stringify(sv.get(R1.uid)));+  // 4b. ...and the other way round: the nest lands first, then the task is deleted by a tab+  // that had not heard of it. The row is not deleted with it: it takes the task's place.+  await Bp.page.evaluate(() => window.__collab.pause());+  await nest(A, R2.uid, P2.uid); await sent([A]);+  await Bp.page.evaluate(u => window.__collab.del(u), P2.uid); await sent([Bp]);+  await resumeAll([Bp]);+  sv = await srv();+  check('a task deleted by a tab that had not seen a row nested under it: the row is kept, where the task was', sv.has(R2.uid) && !sv.has(P2.uid) && !(sv.get(R2.uid).parent), JSON.stringify(sv.get(R2.uid)));+  // 4c. A task deleted with its subtasks, by a tab that knew them: all of them go, one undo brings them back.+  const Z = tasksIn(secs[2]);+  await nest(Cp, Z[1].uid, Z[0].uid); await quiesce(S, people);+  await Cp.page.evaluate(u => window.__collab.del(u), Z[0].uid); await quiesce(S, people);+  sv = await srv();+  check('deleting a task deletes its subtasks with it (ClickUp)', !sv.has(Z[0].uid) && !sv.has(Z[1].uid));+  await Cp.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);+  sv = await srv();+  check('and one Ctrl+Z brings the task and its subtask back, still nested', sv.has(Z[0].uid) && (sv.get(Z[1].uid) || {}).parent === Z[0].uid, JSON.stringify(sv.get(Z[1].uid)));++  // 5. Undo of a nest is your own.+  const before = (await planRows(S, A.sid)).map(r => r.uid);+  await nest(A, Z[3].uid, Z[2].uid); await quiesce(S, people);+  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);+  const after = (await planRows(S, A.sid)).map(r => r.uid);+  check('Ctrl+Z on a nest puts the row back where it was, in every tab', !(await parentOf(Z[3].uid)) && after.indexOf(Z[3].uid) === before.indexOf(Z[3].uid) && (await Promise.all(news.map(P => tabParent(P, Z[3].uid)))).every(p => !p),+    JSON.stringify([before.indexOf(Z[3].uid), after.indexOf(Z[3].uid)]));+  await nest(A, Z[4].uid, Z[2].uid); await quiesce(S, people);+  await nest(Bp, Z[4].uid, Z[3].uid); await quiesce(S, people);+  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);+  const kept = await A.page.evaluate(() => window.__collab.kept());+  check('Ctrl+Z on a nest that a teammate has moved since leaves their place, and says whose', (await parentOf(Z[4].uid)) === Z[3].uid && kept.some(k => k.uid === Z[4].uid && k.f === 'place' && k.who === PEOPLE[1]), JSON.stringify([await parentOf(Z[4].uid), kept]));++  // 6. Nobody fights.+  const posts = await quiet();+  check('after all of that, nobody sends anything (no tab corrects another)', posts === 0, posts + ' batches in 3 s');+  const rs = await Promise.all(news.map(P => P.page.evaluate(() => window.__collab.resyncs ? window.__collab.resyncs() : 0)));+  rec('resyncs', rs);++  // 7. A 0.77 tab left open on this server.+  if (!O) skip('0.77 tab beside this build', 'no 0.77 page (git show ffdacb4:gantt.html failed; pass --old-page)');+  else {+    const oo = () => O.page.evaluate(() => window.__collab.order());+    const so = async () => (await planRows(S, A.sid)).map(r => r.uid);+    const sOrd = await so(), oOrd = (await oo()).filter(u => sOrd.includes(u));+    check('a 0.77 tab shows the plan in the server\'s order: every subtask right under its task', oOrd.join() === sOrd.join(), JSON.stringify(oOrd.slice(0, 12)));+    // It moves a task with subtasks below the next top-level row: the subtasks go with it.+    const t7 = await A.page.evaluate(() => window.__collab.tree());+    const par = t7.find(r => !r.parent && t7.some(k => k.parent === r.uid) && r.phase === secs[0]);+    const kidsOf = (t, u) => t.filter(r => r.parent === u).map(r => r.uid);+    const sib = par && t7.filter(r => !r.parent && r.phase === par.phase && t7.indexOf(r) > t7.indexOf(par))[0];+    if (par && sib) {+      const k0 = kidsOf(t7, par.uid);+      await O.page.evaluate(([u, a]) => window.__collab.move(u, a), [par.uid, sib.uid]); await quiesce(S, people);+      const o2 = await so(), t8 = await A.page.evaluate(() => window.__collab.tree());+      check('a 0.77 tab moves a task that has subtasks: it lands where that tab put it, its subtasks with it', o2.indexOf(par.uid) > o2.indexOf(sib.uid) && kidsOf(t8, par.uid).join() === k0.join() && k0.every(u => o2.indexOf(u) > o2.indexOf(par.uid)), JSON.stringify([o2.indexOf(sib.uid), o2.indexOf(par.uid), k0, kidsOf(t8, par.uid)]));+      // It moves a subtask to another section by its section field: it leaves its task.+      const kid = k0[0];+      await O.page.evaluate(([u, p]) => window.__collab.set(u, 'phase', p), [kid, secs[1]]); await quiesce(S, people);+      sv = await srv();+      check('a 0.77 tab moves a subtask to another section: it leaves its task and lands in that section', !(sv.get(kid).parent) && sv.get(kid).phase === secs[1], JSON.stringify(sv.get(kid)));+      // It deletes a task with subtasks, knowing nothing of them: they are kept, in its place.+      const t9 = await A.page.evaluate(() => window.__collab.tree());+      const par2 = t9.find(r => t9.some(k => k.parent === r.uid));+      const k2 = par2 ? kidsOf(t9, par2.uid) : [];+      if (par2) {+        await O.page.evaluate(u => window.__collab.del(u), par2.uid); await quiesce(S, people);+        sv = await srv();+        check('a 0.77 tab deletes a task with subtasks: the task goes, its subtasks stay, in its place', !sv.has(par2.uid) && k2.length && k2.every(u => sv.has(u) && (sv.get(u).parent || '') !== par2.uid), JSON.stringify(k2.map(u => sv.get(u))));+      }+      // And a newer tab nests a row while it watches.+      const t10 = await A.page.evaluate(() => window.__collab.tree());+      const host = t10.find(r => !r.parent && r.phase === secs[2] && rows.find(x => x.uid === r.uid && x.id)), guest = t10.filter(r => !r.parent && r.phase === secs[2] && r.uid !== (host || {}).uid && rows.find(x => x.uid === r.uid && x.id)).slice(-1)[0];+      if (host && guest) { await nest(Cp, guest.uid, host.uid); await quiesce(S, people);+        const s3 = await so(), o3 = (await oo()).filter(u => s3.includes(u));+        check('a 0.77 tab sees a row a newer tab nests come straight under its task', o3.join() === s3.join() && s3.indexOf(guest.uid) > s3.indexOf(host.uid), JSON.stringify([s3.indexOf(host.uid), s3.indexOf(guest.uid)])); }+    } else skip('0.77 tab moves', 'no task with subtasks and a sibling after it');+    const p2 = await quiet();+    check('with a 0.77 tab open, nobody fights over the order', p2 === 0, p2 + ' batches in 3 s');+  }+  diffs = await convergence(S, people);+  check('every tab, the 0.77 one included, holds exactly what the server holds', !diffs.length, diffs.slice(0, 3).join(' | '));+  const errs = people.flatMap(P => P.errors);+  check('no page errors', errs.length === 0, errs.slice(0, 3).join(' | '));+  for (const P of people) await closePerson(P);+  await stopServer(S);+}+ // ═════════════════════════════════════════════════════════════════════════════ // 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@@ -1078,6 +1260,7 @@ try {   if (want('docs')) await docsScenario(carbon);   if (want('fuzz')) await fuzzScenario(carbon);   if (want('sections')) await sectionsScenario(carbon);+  if (want('subtasks')) await subtasksScenario(carbon);   if (want('milestones')) await milestonesScenario(carbon);   exitCode = fail ? 1 : 0; } catch (e) {
test/fixtures/clickup-tasks.jsonadded+1236
@@ -0,0 +1,1236 @@+{+  "tasks": [+    {+      "id": "86c3a0001",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Install the feeder bank",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_complete",+        "status": "complete",+        "color": "#6bc950",+        "orderindex": 3,+        "type": "done"+      },+      "orderindex": "2.00000000000000000000000000000000",+      "date_created": "1791803600000",+      "date_updated": "1791803660000",+      "date_closed": "1791803720000",+      "date_done": "1791803720000",+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [+        {+          "name": "install",+          "tag_fg": "#800000",+          "tag_bg": "#800000",+          "creator": 81000001+        }+      ],+      "parent": null,+      "top_level_parent": null,+      "priority": {+        "color": "#ffcc00",+        "id": "2",+        "orderindex": "2",+        "priority": "high"+      },+      "due_date": "1793786400000",+      "start_date": "1793613600000",+      "points": null,+      "time_estimate": 28800000,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [+        {+          "task_id": "86c3a0001",+          "depends_on": "86c3a0002",+          "type": 1,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        },+        {+          "task_id": "86c3a0003",+          "depends_on": "86c3a0001",+          "type": 0,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        }+      ],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0001",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0003",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Load the first program",+      "text_content": "Start from the line 1 program.\nCheck nozzle sizes against the BOM.",+      "description": "Start from the line 1 program.\nCheck nozzle sizes against the BOM.",+      "status": {+        "id": "sc901300000501_inprogress",+        "status": "in progress",+        "color": "#4194f6",+        "orderindex": 1,+        "type": "custom"+      },+      "orderindex": "3.00000000000000000000000000000000",+      "date_created": "1791807200000",+      "date_updated": "1791807260000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        },+        {+          "id": 81000002,+          "username": null,+          "color": "#7b68ee",+          "initials": "O",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": null,+      "top_level_parent": null,+      "priority": null,+      "due_date": "1794304800000",+      "start_date": "1793872800000",+      "points": null,+      "time_estimate": 57600000,+      "time_spent": 0,+      "custom_fields": [+        {+          "id": "f1a2b3c4-0001",+          "name": "Machine",+          "type": "drop_down",+          "type_config": {+            "options": [+              {+                "id": "o1",+                "name": "NeoDen 9",+                "orderindex": 0+              }+            ]+          },+          "date_created": "1791000000000",+          "hide_from_guests": false,+          "value": 0,+          "required": false+        },+        {+          "id": "f1a2b3c4-0002",+          "name": "Budget",+          "type": "currency",+          "type_config": {+            "currency_type": "USD"+          },+          "date_created": "1791000000000",+          "hide_from_guests": false,+          "value": "1200",+          "required": false+        },+        {+          "id": "f1a2b3c4-0003",+          "name": "Notes",+          "type": "text",+          "type_config": {},+          "date_created": "1791000000000",+          "hide_from_guests": false,+          "required": false+        }+      ],+      "dependencies": [+        {+          "task_id": "86c3a0003",+          "depends_on": "86c3a0001",+          "type": 1,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        },+        {+          "task_id": "86c3a0006",+          "depends_on": "86c3a0003",+          "type": 0,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        }+      ],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0003",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0002",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Level and anchor the machine",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_Closed",+        "status": "Closed",+        "color": "#008844",+        "orderindex": 4,+        "type": "closed"+      },+      "orderindex": "1.00000000000000000000000000000000",+      "date_created": "1791810800000",+      "date_updated": "1791810860000",+      "date_closed": "1791810920000",+      "date_done": "1791810920000",+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000003,+          "username": "Jun Park",+          "color": "#7b68ee",+          "initials": "JP",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": null,+      "top_level_parent": null,+      "priority": null,+      "due_date": "1793350800000",+      "start_date": "1793178000000",+      "points": null,+      "time_estimate": 14400000,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [+        {+          "task_id": "86c3a0001",+          "depends_on": "86c3a0002",+          "type": 0,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        }+      ],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0002",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0006",+      "custom_id": null,+      "custom_item_id": 1,+      "name": "First panel off the line",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_todo",+        "status": "to do",+        "color": "#d3d3d3",+        "orderindex": 0,+        "type": "open"+      },+      "orderindex": "4.00000000000000000000000000000000",+      "date_created": "1791814400000",+      "date_updated": "1791814460000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": null,+      "top_level_parent": null,+      "priority": null,+      "due_date": "1794477600000",+      "due_date_time": false,+      "start_date": null,+      "points": null,+      "time_estimate": null,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [+        {+          "task_id": "86c3a0006",+          "depends_on": "86c3a0003",+          "type": 1,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        },+        {+          "task_id": "86c3a0007",+          "depends_on": "86c3a0006",+          "type": 0,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        }+      ],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0006",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0007",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Run the 50-panel qualification",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_blocked",+        "status": "blocked",+        "color": "#e50000",+        "orderindex": 2,+        "type": "custom"+      },+      "orderindex": "5.00000000000000000000000000000000",+      "date_created": "1791818000000",+      "date_updated": "1791818060000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000003,+          "username": "Jun Park",+          "color": "#7b68ee",+          "initials": "JP",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [+        {+          "id": "cl1",+          "task_id": "86c3a0007",+          "name": "Qualification",+          "orderindex": 0,+          "resolved": 0,+          "unresolved": 2,+          "items": [+            {+              "id": "i1",+              "name": "Paste volume",+              "resolved": false+            },+            {+              "id": "i2",+              "name": "AOI pass rate",+              "resolved": false+            }+          ]+        }+      ],+      "tags": [],+      "parent": null,+      "top_level_parent": null,+      "priority": {+        "color": "#ffcc00",+        "id": "1",+        "orderindex": "1",+        "priority": "urgent"+      },+      "due_date": "1795208400000",+      "due_date_time": true,+      "start_date": "1794823200000",+      "start_date_time": false,+      "points": 5,+      "time_estimate": 72000000,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [+        {+          "task_id": "86c3a0007",+          "depends_on": "86c3a0006",+          "type": 1,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        },+        {+          "task_id": "86c3a0007",+          "depends_on": "86c3zzzzz",+          "type": 1,+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001",+          "chain_id": null+        }+      ],+      "linked_tasks": [+        {+          "task_id": "86c3a0001",+          "link_id": "86c3a0007",+          "date_created": "1792000000000",+          "userid": "81000001",+          "workspace_id": "9013000001"+        }+      ],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0007",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0008",+      "custom_id": null,+      "custom_item_id": 1001,+      "name": "Supplier visit from Sydney",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_todo",+        "status": "to do",+        "color": "#d3d3d3",+        "orderindex": 0,+        "type": "open"+      },+      "orderindex": "6.00000000000000000000000000000000",+      "date_created": "1791821600000",+      "date_updated": "1791821660000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000003,+          "username": "Jun Park",+          "color": "#7b68ee",+          "initials": "JP",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [+        {+          "id": "g1",+          "name": "Line team",+          "color": "#7b68ee",+          "initials": "LT",+          "email": null+        }+      ],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": null,+      "top_level_parent": null,+      "priority": null,+      "due_date": "1795453200000",+      "due_date_time": false,+      "start_date": "1795366800000",+      "start_date_time": false,+      "points": null,+      "time_estimate": null,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [],+      "linked_tasks": [],+      "locations": [+        {+          "id": "901300000777",+          "name": "Customer visits"+        }+      ],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0008",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0011",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Weekly nozzle cleaning",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_open",+        "status": "open",+        "color": "#d3d3d3",+        "orderindex": 0,+        "type": "open"+      },+      "orderindex": "1.00000000000000000000000000000000",+      "date_created": "1791825200000",+      "date_updated": "1791825260000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000002,+          "username": null,+          "color": "#7b68ee",+          "initials": "O",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [+        {+          "name": "maintenance",+          "tag_fg": "#800000",+          "tag_bg": "#800000",+          "creator": 81000001+        }+      ],+      "parent": null,+      "top_level_parent": null,+      "priority": null,+      "due_date": "1794218400000",+      "start_date": "1794218400000",+      "points": null,+      "time_estimate": 3600000,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0011",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000502",+        "name": "Line 2 maintenance",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0009",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Write the line handbook",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_todo",+        "status": "to do",+        "color": "#d3d3d3",+        "orderindex": 0,+        "type": "open"+      },+      "orderindex": "7.00000000000000000000000000000000",+      "date_created": "1791828800000",+      "date_updated": "1791828860000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": null,+      "top_level_parent": null,+      "priority": null,+      "due_date": null,+      "start_date": null,+      "points": null,+      "time_estimate": null,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0009",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0010",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Second-shift staffing plan",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_cancelled",+        "status": "cancelled",+        "color": "#008844",+        "orderindex": 4,+        "type": "closed"+      },+      "orderindex": "8.00000000000000000000000000000000",+      "date_created": "1791832400000",+      "date_updated": "1791832460000",+      "date_closed": "1791832520000",+      "date_done": "1791832520000",+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": null,+      "top_level_parent": null,+      "priority": null,+      "due_date": "1794564000000",+      "start_date": "1794218400000",+      "points": null,+      "time_estimate": null,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0010",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0004",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Import the CAD centroid file",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_todo",+        "status": "to do",+        "color": "#d3d3d3",+        "orderindex": 0,+        "type": "open"+      },+      "orderindex": "1.00000000000000000000000000000000",+      "date_created": "1791836000000",+      "date_updated": "1791836060000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000002,+          "username": null,+          "color": "#7b68ee",+          "initials": "O",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": "86c3a0003",+      "top_level_parent": "86c3a0003",+      "priority": null,+      "due_date": "1793959200000",+      "due_date_time": false,+      "start_date": null,+      "points": null,+      "time_estimate": 3600000,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0004",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    },+    {+      "id": "86c3a0005",+      "custom_id": null,+      "custom_item_id": 0,+      "name": "Teach fiducials",+      "text_content": "",+      "description": "",+      "status": {+        "id": "sc901300000501_review",+        "status": "review",+        "color": "#f9d900",+        "orderindex": 2,+        "type": "custom"+      },+      "orderindex": "0.50000000000000000000000000000000",+      "date_created": "1791839600000",+      "date_updated": "1791839660000",+      "date_closed": null,+      "date_done": null,+      "archived": false,+      "creator": {+        "id": 81000001,+        "username": "Nadia Haddad",+        "color": "#7b68ee",+        "initials": "NH",+        "email": "[email protected]",+        "profilePicture": null+      },+      "assignees": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "group_assignees": [],+      "watchers": [+        {+          "id": 81000001,+          "username": "Nadia Haddad",+          "color": "#7b68ee",+          "initials": "NH",+          "email": "[email protected]",+          "profilePicture": null+        }+      ],+      "checklists": [],+      "tags": [],+      "parent": "86c3a0003",+      "top_level_parent": "86c3a0003",+      "priority": null,+      "due_date": "1794045600000",+      "start_date": "1793959200000",+      "points": null,+      "time_estimate": 7200000,+      "time_spent": 0,+      "custom_fields": [],+      "dependencies": [],+      "linked_tasks": [],+      "locations": [],+      "team_id": "9013000001",+      "url": "https://app.clickup.com/t/86c3a0005",+      "sharing": {+        "public": false,+        "public_share_expires_on": null,+        "public_fields": [],+        "token": null,+        "seo_optimized": false+      },+      "permission_level": "create",+      "list": {+        "id": "901300000501",+        "name": "Line 2 bring-up",+        "access": true+      },+      "project": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "folder": {+        "id": "90130000100",+        "name": "Pick and Place",+        "hidden": false,+        "access": true+      },+      "space": {+        "id": "90130000010"+      }+    }+  ],+  "last_page": true+}
test/fixtures/clickup-view.csvadded+14
@@ -0,0 +1,14 @@+"Task Type","Task ID","Task Name","Parent ID","Parent Name","Parent URL","Status","Task Content","Assignee","Priority","Latest Comment","Comment Count","Assigned Comment Count","Due Date","Due Date Text","Start Date","Start Date Text","Date Created","Date Created Text","Date Updated","Date Updated Text","Date Closed","Date Closed Text","Date Done","Created By","Space","Folder","List","Subtask ID's","Subtask URL's","tags","Lists","Sprints","Linked Tasks","Linked Docs","Time Logged (hours)","Time Logged Rolled Up (hours)","Time Estimate (hours)","Time Estimate Rolled Up (hours)","Points Estimate","Points Estimate Rolled Up","Story Points (number)","Vendor (drop down)","Risk Notes (text)"+"Task","86c1v0a1b","Remove the old fixture plate","","","","complete","Unbolt the plate, keep the dowel pins.+Photograph the cable runs first, ""before"" and ""after"".+","[Tom Becker]","normal","",0,0,"2026-10-07T00:00:00.000+00:00","Wednesday, October 7th 2026","2026-10-05T00:00:00.000+00:00","Monday, October 5th 2026","2026-09-11T09:01:00.000-05:00","Friday, September 11th 2026, 9:01:00 am -05:00","2026-09-11T09:01:00.000-05:00","Friday, September 11th 2026, 9:01:00 am -05:00","2026-10-07T15:20:00.000-05:00","Wednesday, October 7th 2026, 3:20:00 pm -05:00","2026-10-07T15:20:00.000-05:00","Tom Becker","Operations","Workcell 11","Mechanical","[]","[]","[teardown]","[Mechanical]","[]","","","","",6,6,"",0,"","",""+"Task","86c1v0b2c","Machine the new fixture plate","","","","in progress","","[Tom Becker, Sara Lind]","HIGH","",0,0,"2026-10-16T00:00:00.000+00:00","Friday, October 16th 2026","2026-10-08T00:00:00.000+00:00","Thursday, October 8th 2026","2026-09-12T09:02:00.000-05:00","Saturday, September 12th 2026, 9:02:00 am -05:00","2026-09-12T09:02:00.000-05:00","Saturday, September 12th 2026, 9:02:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Mechanical","[86c1v0b3d, 86c1v0b4e]","[https://app.clickup.com/t/86c1v0b3d, https://app.clickup.com/t/86c1v0b4e]","[]","[Mechanical]","[]","Mount the cell frame: https://app.clickup.com/t/86c1v0c5f)","","","",20,20,"",0,5,"Proto Labs",""+"Task","86c1v0b3d","Order 6061 stock","86c1v0b2c","Machine the new fixture plate","https://app.clickup.com/t/86c1v0b2c","complete","","[Sara Lind]","none","",0,0,"2026-10-09T00:00:00.000+00:00","Friday, October 9th 2026","","","2026-09-13T09:03:00.000-05:00","Sunday, September 13th 2026, 9:03:00 am -05:00","2026-09-13T09:03:00.000-05:00","Sunday, September 13th 2026, 9:03:00 am -05:00","2026-10-09T11:00:00.000-05:00","Friday, October 9th 2026, 11:00:00 am -05:00","2026-10-09T11:00:00.000-05:00","Tom Becker","Operations","Workcell 11","Mechanical","[]","[]","[]","[Mechanical]","[]","","","","",1,1,"",0,"","",""+"Task","86c1v0b4e","First article inspection","86c1v0b2c","Machine the new fixture plate","https://app.clickup.com/t/86c1v0b2c","awaiting cmm","","[Sara Lind]","none","",0,0,"2026-10-16T00:00:00.000+00:00","Friday, October 16th 2026","2026-10-15T00:00:00.000+00:00","Thursday, October 15th 2026","2026-09-14T09:04:00.000-05:00","Monday, September 14th 2026, 9:04:00 am -05:00","2026-09-14T09:04:00.000-05:00","Monday, September 14th 2026, 9:04:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Mechanical","[]","[]","[]","[Mechanical]","[]","","","","",3,3,"",0,"","",""+"Task","86c1v0c5f","Mount the cell frame","","","","blocked","","[Sara Lind]","urgent","",0,0,"2026-10-23T00:00:00.000+00:00","Friday, October 23rd 2026","2026-10-19T00:00:00.000+00:00","Monday, October 19th 2026","2026-09-15T09:05:00.000-05:00","Tuesday, September 15th 2026, 9:05:00 am -05:00","2026-09-15T09:05:00.000-05:00","Tuesday, September 15th 2026, 9:05:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Mechanical","[]","[]","[]","[Mechanical, Controls]","[]","","","","","","",3,3,"","","Anchor bolts back-ordered"+"Milestone","86c1v0d6g","Retrofit complete","","","","to do","","[]","none","",0,0,"2026-10-30T17:00:00.000-05:00","Friday, October 30th 2026, 5:00:00 pm -05:00","","","2026-09-16T09:06:00.000-05:00","Wednesday, September 16th 2026, 9:06:00 am -05:00","2026-09-16T09:06:00.000-05:00","Wednesday, September 16th 2026, 9:06:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Mechanical","[]","[]","[]","[Mechanical]","[]","","","","","","","",0,"","",""+"Task","86c1w1a7h","Rewire the safety circuit","","","","in progress","","[Ken Ito]","high","Waiting on the relay spec from the vendor.",2,0,"2026-10-21T00:00:00.000+00:00","Wednesday, October 21st 2026","2026-10-12T00:00:00.000+00:00","Monday, October 12th 2026","2026-09-17T09:07:00.000-05:00","Thursday, September 17th 2026, 9:07:00 am -05:00","2026-09-17T09:07:00.000-05:00","Thursday, September 17th 2026, 9:07:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Controls","[]","[]","[]","[Controls]","[]","","",6.5,6.5,16,16,"",0,"","",""+"Task","86c1w1b8j","PLC program update","","","","to do","","[]","low","",0,0,"2026-11-04T00:00:00.000+00:00","Wednesday, November 4th 2026","2026-10-22T00:00:00.000+00:00","Thursday, October 22nd 2026","2026-09-18T09:08:00.000-05:00","Friday, September 18th 2026, 9:08:00 am -05:00","2026-09-18T09:08:00.000-05:00","Friday, September 18th 2026, 9:08:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Controls","[]","[]","[]","[Controls]","[]","","","","",24,24,"",0,8,"",""+"Task","86c1w1c9k","Safety sign-off","","","","with inspector","","[Ken Ito]","none","",0,0,"2026-11-06T00:00:00.000+00:00","Friday, November 6th 2026","","","2026-09-19T09:09:00.000-05:00","Saturday, September 19th 2026, 9:09:00 am -05:00","2026-09-19T09:09:00.000-05:00","Saturday, September 19th 2026, 9:09:00 am -05:00","2026-11-06T15:20:00.000-06:00","Friday, November 6th 2026, 3:20:00 pm -06:00","2026-11-06T15:20:00.000-06:00","Tom Becker","Operations","Workcell 11","Controls","[]","[]","[]","[Controls]","[]","","","","","","","",0,"","",""+"Task","86c1w1d0m","Operator training","","","","to do","","[Ken Ito]","none","",0,0,"","","","","2026-09-20T09:10:00.000-05:00","Sunday, September 20th 2026, 9:10:00 am -05:00","2026-09-20T09:10:00.000-05:00","Sunday, September 20th 2026, 9:10:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Controls","[]","[]","[]","[Controls]","[]","","","","",4,4,"",0,"","",""+"Task","86c1w1e1n","Paint the guard rails","","","","cancelled","","[Tom Becker]","none","",0,0,"2026-10-28T00:00:00.000+00:00","Wednesday, October 28th 2026","2026-10-26T00:00:00.000+00:00","Monday, October 26th 2026","2026-09-21T09:11:00.000-05:00","Monday, September 21st 2026, 9:11:00 am -05:00","2026-09-21T09:11:00.000-05:00","Monday, September 21st 2026, 9:11:00 am -05:00","","","","Tom Becker","Operations","Workcell 11","Controls","[]","[]","[]","[Controls]","[]","","","","","","","",0,"","",""
test/fixtures/clickup-workspace.csvadded+23
@@ -0,0 +1,23 @@+Task ID,Task Link,Task Type,Task Name,Task Content,Status,Date Created,Date Created Text,Due Date,Due Date Text,Start Date,Start Date Text,Parent ID,Subtasks IDs,Attachments,Assignees,Tags,Priority,List Name,Folder Name/Path,Space Name,Time Estimated,Time Estimated Text,Checklists,Comments,Assigned Comments,Time Spent,Time Spent Text,Rolled Up Time,Rolled Up Time Text,Home Location ID,Home Location,Other Location IDs,Other Locations+86b4p2a1c,https://app.clickup.com/t/86b4p2a1c,Task,Place components,,complete,1789386221000,"9/14/2026, 5:13:41 PM GMT+5:30",1792362600000,"10/19/2026, 4:00:00 AM GMT+5:30",1791757800000,"10/12/2026, 4:00:00 AM GMT+5:30",86b4p2a0b,,[],[Maya Chen],[],null,EVT Build,"[""Sensor Board""]",Hardware,43200000,12 h ,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p1x7k,https://app.clickup.com/t/86b4p1x7k,Task,Finalize schematic,"Lock the netlist for EVT.+Review with Owen, then freeze the ""rev B"" schematic, and tag it.",complete,1789411482000,"9/15/2026, 12:14:42 AM GMT+5:30",1791498600000,"10/9/2026, 4:00:00 AM GMT+5:30",1791153000000,"10/5/2026, 4:00:00 AM GMT+5:30",null,,[],[Maya Chen],[schematic],2,EVT Build,"[""Sensor Board""]",Hardware,57600000,16 h ,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p1y2m,https://app.clickup.com/t/86b4p1y2m,Task,Order long-lead parts,,in progress,1789436743000,"9/15/2026, 7:15:43 AM GMT+5:30",1792103400000,"10/16/2026, 4:00:00 AM GMT+5:30",,,null,,[],"[Owen Price,Raj Patel]","[long-lead,purchasing]",1,EVT Build,"[""Sensor Board""]",Hardware,14400000,4 h ,"{""Parts"":[""MCU"",""IMU"",""Board-to-board connector""]}",[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p2a0b,https://app.clickup.com/t/86b4p2a0b,Task,Lay out the board,,in review,1789462004000,"9/15/2026, 2:16:44 PM GMT+5:30",1793313000000,"10/30/2026, 4:00:00 AM GMT+5:30",1791757800000,"10/12/2026, 4:00:00 AM GMT+5:30",null,"86b4p2a1c,86b4p2a2d",[],[Maya Chen],[],3,EVT Build,"[""Sensor Board""]",Hardware,144000000,40 h ,{},"[{""text"":""Keep the IMU away from the buck converter.\n"",""by"":""[email protected]"",""assigned"":false,""date"":""10/20/2026, 3:04:05 PM GMT+5:30"",""resolved"":false}]",0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p2a2d,https://app.clickup.com/t/86b4p2a2d,Task,Route power planes,,in progress,1789487265000,"9/15/2026, 9:17:45 PM GMT+5:30",1793053800000,"10/27/2026, 4:00:00 AM GMT+5:30",1792449000000,"10/20/2026, 4:00:00 AM GMT+5:30",86b4p2a0b,86b4p2a3e,[],[Maya Chen],[],null,EVT Build,"[""Sensor Board""]",Hardware,57600000,16 h ,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p2a3e,https://app.clickup.com/t/86b4p2a3e,Task,Check return paths,,to do,1789512526000,"9/16/2026, 4:18:46 AM GMT+5:30",1793053800000,"10/27/2026, 4:00:00 AM GMT+5:30",,,86b4p2a2d,,[],[Owen Price],[],null,EVT Build,"[""Sensor Board""]",Hardware,7200000,2 h ,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p3c4f,https://app.clickup.com/t/86b4p3c4f,Task,EVT build at the fab,,waiting on parts,1789537787000,"9/16/2026, 11:19:47 AM GMT+5:30",1794522600000,"11/13/2026, 4:00:00 AM GMT+5:30",1793572200000,"11/2/2026, 4:00:00 AM GMT+5:30",null,,[],[Raj Patel],[],2,EVT Build,"[""Sensor Board""]",Hardware,,,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,[901200000201],[Hardware > Launch]+86b4p3d5g,https://app.clickup.com/t/86b4p3d5g,Task,Bring-up and test,,to do,1789563048000,"9/16/2026, 6:20:48 PM GMT+5:30",,,,,null,"86b4p3d6h,86b4p3d7j",[],[Lena Fischer],[],null,EVT Build,"[""Sensor Board""]",Hardware,,,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p3d6h,https://app.clickup.com/t/86b4p3d6h,Task,Power-on smoke test,,to do,1789588309000,"9/17/2026, 1:21:49 AM GMT+5:30",1794781800000,"11/16/2026, 4:00:00 AM GMT+5:30",1794781800000,"11/16/2026, 4:00:00 AM GMT+5:30",86b4p3d5g,,[],[Lena Fischer],[],null,EVT Build,"[""Sensor Board""]",Hardware,10800000,3 h ,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p3d7j,https://app.clickup.com/t/86b4p3d7j,Task,Sensor calibration,,to do,1789613570000,"9/17/2026, 8:22:50 AM GMT+5:30",1795127400000,"11/20/2026, 4:00:00 AM GMT+5:30",1794868200000,"11/17/2026, 4:00:00 AM GMT+5:30",86b4p3d5g,,[],[[email protected]],[],null,EVT Build,"[""Sensor Board""]",Hardware,28800000,8 h ,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p3e8k,https://app.clickup.com/t/86b4p3e8k,Milestone,EVT complete,,to do,1789638831000,"9/17/2026, 3:23:51 PM GMT+5:30",1795127400000,"11/20/2026, 4:00:00 AM GMT+5:30",,,null,,[],[],[],null,EVT Build,"[""Sensor Board""]",Hardware,,,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p3f9m,https://app.clickup.com/t/86b4p3f9m,Task,Write the EVT report,,backlog,1789664092000,"9/17/2026, 10:24:52 PM GMT+5:30",,,,,null,,[],[Maya Chen],[],null,EVT Build,"[""Sensor Board""]",Hardware,,,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p0z1a,https://app.clickup.com/t/86b4p0z1a,Task,Old enclosure concept,,won't do,1789689353000,"9/18/2026, 5:25:53 AM GMT+5:30",1790893800000,"10/2/2026, 4:00:00 AM GMT+5:30",1790548200000,"9/28/2026, 4:00:00 AM GMT+5:30",null,,[],[Raj Patel],[],null,EVT Build,"[""Sensor Board""]",Hardware,,,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p1y2m,https://app.clickup.com/t/86b4p1y2m,Task,Order long-lead parts,,in progress,1789714614000,"9/18/2026, 12:26:54 PM GMT+5:30",1792103400000,"10/16/2026, 4:00:00 AM GMT+5:30",,,null,,[],"[Raj Patel,Owen Price]","[long-lead,purchasing]",1,EVT Build,"[""Sensor Board""]",Hardware,14400000,4 h ,"{""Parts"":[""MCU"",""IMU"",""Board-to-board connector""]}",[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p4g1n,https://app.clickup.com/t/86b4p4g1n,Task,Rework footprint for J3,,in progress,1789739875000,"9/18/2026, 7:27:55 PM GMT+5:30",1792535400000,"10/21/2026, 4:00:00 AM GMT+5:30",,,86b4qmx9z,,[],[Maya Chen],[],null,EVT Build,"[""Sensor Board""]",Hardware,,,{},[],0," """"",,NaN,,901200000101,Hardware > Sensor Board > EVT Build,,+86b4p5h2p,https://app.clickup.com/t/86b4p5h2p,Task,Bootloader,,in progress,1789765136000,"9/19/2026, 2:28:56 AM GMT+5:30",1792708200000,"10/23/2026, 4:00:00 AM GMT+5:30",1791153000000,"10/5/2026, 4:00:00 AM GMT+5:30",null,,[],[Dev Raman],[firmware],null,Firmware,"[""Sensor Board""]",Hardware,86400000,24 h ,{},[],0," """"",,NaN,,901200000102,Hardware > Sensor Board > Firmware,,+86b4p5h3q,https://app.clickup.com/t/86b4p5h3q,Task,Sensor driver,,to do,1789790397000,"9/19/2026, 9:29:57 AM GMT+5:30",1793917800000,"11/6/2026, 4:00:00 AM GMT+5:30",1792967400000,"10/26/2026, 4:00:00 AM GMT+5:30",null,,[],[Dev Raman],[firmware],2,Firmware,"[""Sensor Board""]",Hardware,115200000,32 h ,{},[],0," ""5400000""",90 m,5400000,90 m,901200000102,Hardware > Sensor Board > Firmware,,+86b4p5h4r,https://app.clickup.com/t/86b4p5h4r,Task,Power management,,spec,1789815658000,"9/19/2026, 4:30:58 PM GMT+5:30",1794571200000,"11/13/2026, 5:30:00 PM GMT+5:30",,,null,,[],[Dev Raman],[],4,Firmware,"[""Sensor Board""]",Hardware,,,{},[],0," """"",,NaN,,901200000102,Hardware > Sensor Board > Firmware,,+86b4p6j5s,https://app.clickup.com/t/86b4p6j5s,Task,Pilot run plan,,Open,1789840919000,"9/19/2026, 11:31:59 PM GMT+5:30",1796337000000,"12/4/2026, 4:00:00 AM GMT+5:30",1795386600000,"11/23/2026, 4:00:00 AM GMT+5:30",null,,"[{""title"":""pilot-checklist.xlsx"",""url"":""https://example.invalid/attachment/1""}]",[Owen Price],[],null,Launch,,Hardware,21600000,6 h ,{},[],0," """"",,NaN,,901200000201,Hardware > Launch,,+86b4p6j6t,https://app.clickup.com/t/86b4p6j6t,Milestone,Ship to pilot customer,,Open,1789866180000,"9/20/2026, 6:33:00 AM GMT+5:30",1796941800000,"12/11/2026, 4:00:00 AM GMT+5:30",,,null,,[],[],[],null,Launch,,Hardware,,,{},[],0," """"",,NaN,,901200000201,Hardware > Launch,,+86b4p6j7u,https://app.clickup.com/t/86b4p6j7u,Task,Customer training,,planned,1789891441000,"9/20/2026, 1:34:01 PM GMT+5:30",,,1796596200000,"12/7/2026, 4:00:00 AM GMT+5:30",null,,[],[Lena Fischer],[],null,Launch,,Hardware,21600000,6 h ,{},[],0," """"",,NaN,,901200000201,Hardware > Launch,,
test/server_test.mjs+469−3
@@ -4,7 +4,10 @@ // over HTTP. Exits non-zero on any failure. // //   node test/server_test.mjs-import { spawn } from 'node:child_process';+//   node test/server_test.mjs --only clickup   only the sections whose name contains a term+//                                              (comma-separated): "core" is everything that+//                                              predates the ClickUp import, "clickup import" it+import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';@@ -13,6 +16,8 @@ import { fileURLToPath } from 'node:url';  const HERE = path.dirname(fileURLToPath(import.meta.url)); const SERVER = path.join(HERE, '..', 'server.js');+const ARGV = process.argv.slice(2);+const ONLY = ARGV.includes('--only') ? String(ARGV[ARGV.indexOf('--only') + 1] || '').split(',').filter(Boolean) : null; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gantt-srv-test-')); const procs = []; let pass = 0, fail = 0;@@ -841,9 +846,144 @@ async function main() {   stop(R);   const U = await start('sections-part', secPlan(), side(['00003', '00001'], 7), 8940);   st = JSON.parse(fs.readFileSync(path.join(U.dir, 'plan-state.json'), 'utf8'));-  ok(eq(st.order, ['00003', '00001']) && st.stateRev === 8, 'an order that does not list every row is left for the next save that touches it to complete', [st.order, st.stateRev]);+  // 0.79.0: the order is derived from every row's key, so it is complete from the start; an+  // order that did not list every row was never the one the merge applied, and the rows keep+  // the order the merge did apply (the data file's).+  ok(eq(st.order, ['00001', '00002', '00003', '00004', '00005']) && st.stateRev === 8, 'an order that does not list every row: the rows keep the order the merge showed, and the stored order is complete from then on', [st.order, st.stateRev]);   stop(U);   }+  // ── G: subtasks (0.79.0): a parent and a key on each row; the order is derived ──+  {+  console.log('G. subtasks: parents, keys and the derived order');+  const treePlan = () => ({ project: { title: 'Tree', subtitle: 'server_test' },+    phases: ['A', 'B', 'C'].map(name => ({ name, start: '', end: '' })),+    items: [['00001', 't1', 0], ['00002', 't2', 0], ['00003', 't3', 0], ['00004', '', 0, 'milestone'], ['00005', 'b1', 1], ['00006', 'b2', 1], ['00007', 'c1', 2]]+      .map(([uid, id, phase, type]) => type === 'milestone' ? { type, uid, name: 'Gate', date: '2026-10-20', phase }+        : { type: 'task', uid, id, name: 'Row ' + id, start: '2026-10-05', end: '2026-10-09', phase, ...(uid === '00007' ? { deps: ['t1'], tags: ['Fab'] } : {}) }) });+  const Q = await start('tree', treePlan(), null, 8940);+  const auth = { Authorization: 'Bearer ' + Q.token };+  let opsN = 0;+  const ops = async list => (await post(Q, '/ops', { client: 't', batch: 'f' + (++opsN), ops: list }, auth)).j || {};+  const stored = () => JSON.parse(fs.readFileSync(path.join(Q.dir, 'plan-state.json'), 'utf8'));+  const exp = async () => (await fetch(Q.base + '/export/json', { headers: auth })).json();+  const row = async uid => (await exp()).items.find(r => r.uid === uid);+  const keyOf = uid => { const o = (stored().items || []).find(x => x.uid === uid); return o && o.pos; };+  let st = stored();+  const keyed = ['00001', '00002', '00003', '00004', '00005', '00006', '00007'].map(keyOf);+  ok(st.stateRev === 1 && keyed.every(k => typeof k === 'string' && /^[A-Za-z][0-9A-Za-z]*$/.test(k)) && eq(st.order, ['00001', '00002', '00003', '00004', '00005', '00006', '00007']),+    'a plan with no sidecar gets one at start: every row a key, in the order it was shown, as one revision', [st.stateRev, keyed, st.order]);+  ok(keyed[0] < keyed[1] && keyed[1] < keyed[2] && keyed[2] < keyed[3] && keyed[4] < keyed[5], 'the keys sort as the rows were listed, per section', keyed);+  const e1 = (stored().items || []).find(x => x.uid === '00001') || {}, e7 = (stored().items || []).find(x => x.uid === '00007') || {};+  ok(e1.pos && e1.name === 'Row t1' && 'owner' in e1 && eq(e7.deps, ['t1']) && eq(e7.tags, ['Fab']), 'a row the start gives a key gets a whole entry, its lists included, the way an edit makes one (a 0.77 server merges a list onto the entry\'s own after a rollback)', [e1, e7]);+  let j0 = await ops([{ op: 'row', uid: '00007', add: { deps: ['t2'], tags: ['Ops'] } }]);+  let r0 = await row('00007');+  ok(eq(r0.deps, ['t1', 't2']) && eq(r0.tags, ['Fab', 'Ops']), 'a list added to on a row the sidecar had only placed keeps the data file\'s own items (found by the 0.77 docs and fuzz scenarios)', [r0.deps, r0.tags, j0.ops]);+  let j = await ops([{ op: 'row', uid: '00002', set: { parent: '00001', pos: 'a0' } }]);+  let r = await row('00002');+  ok(j.ok && r.parent === '00001' && r.pos === 'a0' && eq(j.ops, [{ op: 'row', uid: '00002', set: { parent: '00001', pos: 'a0' } }]), 'a row nested under a task holds its parent and key; the flat order did not change, so it is not announced', j.ops);+  j = await ops([{ op: 'row', uid: '00003', set: { parent: '00004', pos: 'a5', owner: 'Ana' } }]);+  r = await row('00003');+  ok(!r.parent && r.owner === 'Ana' && (j.refused || []).some(x => /00003: place/.test(x)), 'a milestone cannot hold subtasks: the place is refused, the rest of the op lands', [r.parent, r.owner, j.refused]);+  j = await ops([{ op: 'row', uid: '00001', set: { parent: '00002', pos: 'a0' } }]);+  ok(!(await row('00001')).parent && (j.refused || []).length === 1, 'a row cannot go under its own subtask (no loops)', j);+  j = await ops([{ op: 'row', uid: '00003', set: { parent: '00002', pos: 'a0' } }, { op: 'row', uid: '00005', set: { parent: '00003', pos: 'a0' } }]);+  r = await row('00005');+  ok(r.parent === '00003' && r.phase === 0 && (j.ops || []).some(o => o.op === 'row' && o.uid === '00005' && o.set.phase === 0), 'three levels deep: a row nested from another section takes its new top-level task\'s section, and says so', [r.parent, r.phase, j.ops]);+  j = await ops([{ op: 'row', uid: '00006', set: { parent: '00005', pos: 'a0' } }]);+  ok(!(await row('00006')).parent && (j.refused || []).length === 1, 'a fourth level is refused (ClickUp\'s default depth)', j);+  ex = await exp();+  ok(eq(ex.items.map(x => x.uid), ['00001', '00002', '00003', '00005', '00004', '00006', '00007']), 'the merged plan lists each task followed by its subtasks, depth first', ex.items.map(x => x.uid));+  j = await ops([{ op: 'row', uid: '00001', set: { parent: '', pos: 'a0', phase: 2 } }]);+  ex = await exp();+  ok(['00001', '00002', '00003', '00005'].every(u => ex.items.find(x => x.uid === u).phase === 2) && ['00002', '00003', '00005'].every(u => (j.ops || []).some(o => o.op === 'row' && o.uid === u && o.set.phase === 2)),+    'a parent moved to another section takes its whole subtree, and every subtask\'s section is announced', j.ops);+  j = await ops([{ op: 'del', uid: '00002' }]);+  r = await row('00003');+  ok(r.parent === '00001' && (await row('00005')).parent === '00003' && (j.ops || []).some(o => o.op === 'row' && o.uid === '00003' && o.set.parent === '00001'),+    'a parent deleted on its own (a page that knew nothing of its subtasks): its subtask takes its place, with its own subtree', j.ops);+  j = await ops([{ op: 'row', uid: '00006', set: { parent: '00002', pos: 'a1' } }]);+  r = await row('00006');+  ok(r.parent === '00001' && r.phase === 2, 'nesting under a row somebody deleted puts the row where the deleted row was', [r.parent, r.phase, j.ops]);+  j = await ops([{ op: 'add', row: { uid: '00002' } }]);+  ok((await row('00002')) && (await row('00003')).parent === '00001', 'putting the deleted parent back does not take back the rows that took its place', j.ops);+  // A 0.77 page: moves by "after that row", sections by the section field alone.+  ex = await exp();+  const order = ex.items.map(x => x.uid);+  j = await ops([{ op: 'move', uid: '00005', after: '00007' }]);+  r = await row('00005');+  ok(r.parent !== '00003' && eq((await exp()).items.map(x => x.uid).indexOf('00005'), (await exp()).items.map(x => x.uid).indexOf('00007') + 1), 'a 0.77 page\'s move out of a task\'s subtasks lands where it put it, out from under the task', [order, (await exp()).items.map(x => x.uid + ':' + (x.parent || ''))]);+  const kids = (await exp()).items.filter(x => x.parent === '00001').map(x => x.uid);+  if (kids.length >= 2) {+    j = await ops([{ op: 'move', uid: kids[kids.length - 1], after: '00001' }]);+    const k2 = (await exp()).items.filter(x => x.parent === '00001').map(x => x.uid);+    ok(k2[0] === kids[kids.length - 1] && k2.length === kids.length, 'a 0.77 page\'s move inside a task\'s subtasks keeps the row under the task', [kids, k2]);+  } else ok(false, 'setup: two subtasks under 00001', kids);+  const revQ = stored().stateRev, flat = (await exp()).items.map(x => x.uid);+  j = await ops([{ op: 'move', uid: flat[3], after: flat[2] }]);+  ok(j.stateRev === revQ && eq(j.ops, []), 'a 0.77 move that changes nothing still makes no revision', j);+  j = await ops([{ op: 'row', uid: '00003', set: { phase: 1 } }]);+  r = await row('00003');+  ok(!r.parent && r.phase === 1 && (await row('00005')).phase !== undefined, 'a 0.77 page moving a subtask to another section takes it out from under its task, into that section', [r.parent, r.phase, j.ops]);+  // Two rows given the same key at once: the second moves just after the first.+  const tops = (await exp()).items.filter(x => !x.parent), sec = [0, 1, 2].find(p => tops.filter(x => x.phase === p).length >= 2);+  const top = tops.filter(x => x.phase === sec).map(x => x.uid);+  j = await ops([{ op: 'row', uid: top[0], set: { parent: '', pos: 'a9', phase: sec } }, { op: 'row', uid: top[1], set: { parent: '', pos: 'a9', phase: sec } }]);+  const k0 = keyOf(top[0]), k1 = keyOf(top[1]);+  ok(top.length >= 2 && k0 && k1 && k0 !== k1 && (j.ops || []).some(o => o.uid === top[1] && o.set.pos === k1), 'two siblings given one key: the one placed later gets a key of its own, announced', [top, k0, k1]);+  // A whole-state save from a page or script that predates parents and keys keeps them.+  const before = await exp();+  const raw = stored();+  const old = { v: 4, items: (raw.items || []).map(o => { const c = Object.assign({}, o); delete c.parent; delete c.pos; return c; }), added: raw.added, deleted: raw.deleted, phases: raw.phases, order: before.items.map(x => x.uid) };+  const sv = await post(Q, '/save', old, auth);+  const after = await exp();+  ok(sv.status === 200 && eq(after.items.map(x => x.uid + ':' + (x.parent || '')), before.items.map(x => x.uid + ':' + (x.parent || ''))), 'a whole-state save that knows nothing of parents and keys keeps every row\'s place', [sv.status, after.items.map(x => x.uid + ':' + (x.parent || ''))]);+  // ...and the order it carries says what it moved: a top-level row moved to the top of its+  // section lands there, its subtasks with it.+  const tl = after.items.filter(x => !x.parent), sec2 = [0, 1, 2].find(p => tl.filter(x => x.phase === p).length >= 2);+  const mover = tl.filter(x => x.phase === sec2).slice(-1)[0], kidsM = after.items.filter(x => x.parent === mover.uid).map(x => x.uid);+  const ord2 = after.items.map(x => x.uid).filter(u => u !== mover.uid), first = ord2.findIndex(u => (after.items.find(x => x.uid === u) || {}).phase === sec2);+  ord2.splice(first, 0, mover.uid);+  const raw2 = stored();+  const sv2 = await post(Q, '/save', { v: 4, items: (raw2.items || []).map(o => { const c = Object.assign({}, o); delete c.parent; delete c.pos; return c; }), added: raw2.added, deleted: raw2.deleted, phases: raw2.phases, order: ord2 }, auth);+  const after2 = await exp(), firstIn = after2.items.find(x => x.phase === sec2 && !x.parent);+  ok(sv2.status === 200 && firstIn && firstIn.uid === mover.uid && kidsM.every(u => (after2.items.find(x => x.uid === u) || {}).parent === mover.uid), 'a whole-state save that moved a row: the row lands where that save put it, its subtasks with it', [sv2.status, after2.items.map(x => x.uid + ':' + (x.parent || '') + '@' + x.phase)]);+  const csv = await (await fetch(Q.base + '/export/csv', { headers: auth })).text();+  const lines = csv.split('\n'), hd = lines[0].split(',');+  const cRow = lines.find(l => l.startsWith('00005,'));+  ok(hd[0] === 'uid' && hd[1] === 'parent' && cRow && cRow.split(',')[1] === ((await row('00005')).parent || ''), 'the CSV export carries a parent column, the parent\'s uid', [lines[0], cRow]);+  stop(Q);+  // An import names parents by uid or task id; one it cannot hold is dropped, never an error.+  const I = await start('tree-import', treePlan(), null, 8940);+  const imp = 'uid,parent,id,name,start,end,phase,type,date\n00010,,p1,Parent,2026-10-05,2026-10-09,A,task,\n00011,p1,k1,Kid,2026-10-05,2026-10-09,B,task,\n00012,00011,g1,Grandkid,2026-10-05,2026-10-09,A,task,\n00013,00099,x1,Orphan,2026-10-05,2026-10-09,A,task,\n00014,,m1,Gate,,,A,milestone,2026-10-20\n00015,00014,k2,Under a gate,2026-10-05,2026-10-09,A,task,';+  const ir = await post(I, '/import/csv', imp, Object.assign({ 'Content-Type': 'text/csv' }, { Authorization: 'Bearer ' + I.token }));+  const ie = await (await fetch(I.base + '/export/json', { headers: { Authorization: 'Bearer ' + I.token } })).json();+  const g = u => ie.items.find(x => x.uid === u) || {};+  ok(ir.status === 200 && g('00011').parent === '00010' && g('00012').parent === '00011' && g('00011').phase === g('00010').phase && !g('00013').parent && !g('00015').parent,+    'a CSV import reads parents by task id or uid, puts a subtask in its parent\'s section, and drops a parent that is missing or a milestone', [ir.status, ie.items.map(x => x.uid + '<' + (x.parent || '') + '@' + x.phase)]);+  ok(ie.items.every(x => typeof x.pos === 'string' && x.pos), 'and every imported row has a key', ie.items.map(x => x.pos));+  stop(I);+  // A rollback: 0.77.1's server (from git, when this is a checkout) on a sidecar this one wrote.+  // It merges a list op onto the row's entry, so the entries this server makes to place rows+  // must carry the row's lists, or the data file's dependencies and tags would be dropped.+  let oldSrv = '';+  try { oldSrv = execFileSync('git', ['-C', path.join(HERE, '..'), 'show', 'ffdacb4:server.js'], { encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'] }); } catch (e) {}+  if (!oldSrv) console.log('  skip a rollback to 0.77.1: no git checkout to read its server from');+  else {+    const R1 = await start('tree-rollback', treePlan(), null, 8940);+    stop(R1); await new Promise(r => setTimeout(r, 400));+    const oldDir = path.join(TMP, 'old-0771'); fs.mkdirSync(oldDir, { recursive: true }); fs.writeFileSync(path.join(oldDir, 'server.js'), oldSrv);+    const port = await freePort(8940);+    const op = spawn(process.execPath, [path.join(oldDir, 'server.js'), '--data', R1.data, '--port', String(port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'] });+    procs.push(op);+    const base = `http://127.0.0.1:${port}`;+    for (let i = 0; i < 100; i++) { try { if ((await fetch(base + '/health')).ok) break; } catch (e) {} await new Promise(r => setTimeout(r, 100)); }+    const tok = fs.readFileSync(path.join(R1.dir, 'plan-agent-token'), 'utf8').trim();+    await fetch(base + '/ops', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + tok }, body: JSON.stringify({ ops: [{ op: 'row', uid: '00007', add: { deps: ['t2'], tags: ['Ops'] } }] }) });+    const rb = ((await (await fetch(base + '/export/json', { headers: { Authorization: 'Bearer ' + tok } })).json()).items || []).find(x => x.uid === '00007') || {};+    ok(eq(rb.deps, ['t1', 't2']) && eq(rb.tags, ['Fab', 'Ops']), 'after a rollback to 0.77.1, a list op on a row this server placed keeps the row\'s own items', [rb.deps, rb.tags]);+    try { op.kill('SIGTERM'); } catch (e) {}+  }+  }    // ── 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@@ -952,6 +1092,18 @@ async function main() {   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 description over two lines ahead of the links: the links are read from the same rows+  // as everything else, so they still land on the issue they belong to.+  const jira2 = 'Summary,Issue key,Issue Type,Status,Created,Due Date,Inward issue link (Blocks),Inward issue link (Blocks),Outward issue link (Blocks),Description\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,,,,"Run the bench test.\nThen the soak."\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', jira2, { 'Content-Type': 'text/csv' });+  ex = await exp();+  const rg2 = ex.items.find(r => r.name === 'Release gate') || {}, ti2 = ex.items.find(r => r.name === 'Test it') || {};+  ok(imp.status === 200 && eq(rg2.deps, ['gt-1', 'gt-2']) && eq((ex.items.find(r => r.name === 'Ship it') || {}).deps, ['gt-3']) && ti2.description === 'Run the bench test.\nThen the soak.',+    'Jira: a two-line description does not shift the links onto the wrong issue', [imp.status, imp.t && imp.t.slice(0, 200), rg2, ti2]);    // A milestone appended to the data file under a running server, and one saved by a script.   const dd = onDisk();@@ -965,7 +1117,321 @@ async function main() {   } } -try { await main(); }+// ── ClickUp import: the three doors, the report, and the token rules ──+// The fixtures are synthetic files in ClickUp's real shapes (test/fixtures): a workspace+// export written from GMT+5:30, a Folder's view export with ISO dates, and the API's+// answer for a List. The live API is a stand-in on loopback.+async function clickupSection() {+  console.log('F. ClickUp import');+  const FIX = path.join(HERE, 'fixtures');+  const read = f => fs.readFileSync(path.join(FIX, f), 'utf8');+  const names = (d, ph) => d.items.filter(r => ph === undefined || r.phase === ph).map(r => r.name);+  // A plan's rows with the uids taken out and each parent named instead, so two imports of+  // the same tasks compare equal although each numbered its rows afresh.+  const shape = d => JSON.stringify(d.items.map(({ uid, parent, ...x }) => Object.assign(x, parent ? { parent: (d.items.find(y => y.uid === parent) || {}).name } : {})));+  const C = await start('clickup', basePlan(false), null, 8940);+  const auth = { Authorization: 'Bearer ' + C.token };+  const before = await getJ(C, '/export/json');+  const oldUids = before.items.map(r => r.uid);++  // The workspace export (Settings, Imports and Exports).+  const DAY = '2026-11-03';   // the import's day, as the page sends it+  let r = await post(C, '/import/clickup-csv?today=' + DAY, read('clickup-workspace.csv'), Object.assign({ 'Content-Type': 'text/csv' }, auth));+  ok(r.status === 200 && r.j && r.j.ok && r.j.items === 20 && r.j.phases === 3, '/import/clickup-csv takes a ClickUp workspace export: 20 rows in 3 sections', r.t.slice(0, 300));+  let ex = await getJ(C, '/export/json');+  ok(eq(ex.phases.map(p => p.name), ['Sensor Board · EVT Build', 'Sensor Board · Firmware', 'Launch']), 'a section per List, "Folder · List", in the order they came', ex.phases.map(p => p.name));+  ok(eq(ex.phases.map(p => [p.start, p.end]), [['2026-09', '2026-11'], ['2026-10', '2026-11'], ['2026-11', '2026-12']]), 'each section spans its own rows', ex.phases);+  ok(ex.project.title === 'Hardware' && /workspace export/.test(ex.project.subtitle), 'the plan is named after the one Space everything shares', ex.project);+  ok(eq(names(ex, 0), ['Finalize schematic', 'Order long-lead parts', 'Lay out the board', 'Place components', 'Route power planes', 'Check return paths',+    'EVT build at the fab', 'Bring-up and test', 'Power-on smoke test', 'Sensor calibration', 'EVT complete', 'Write the EVT report', 'Old enclosure concept', 'Rework footprint for J3']),+    'file order, each subtask right after its parent and depth first, though the file lists a subtask before its parent; an orphan stays where it was', names(ex, 0));+  const w = n => byName(ex, n) || {};+  ok(w('Finalize schematic').start === '2026-10-05' && w('Finalize schematic').end === '2026-10-09', 'a date with no time is the exporter\'s day: 04:00 at GMT+5:30 is the evening before in UTC', w('Finalize schematic'));+  ok(w('Order long-lead parts').start === '2026-10-16' && w('Order long-lead parts').end === '2026-10-16', 'only a due date: the bar starts on it');+  ok(w('Customer training').start === '2026-12-07' && w('Customer training').end === '2026-12-07', 'only a start date: the bar ends on it');+  ok(w('Power management').start === '2026-11-13', 'a due date with a time of day keeps its day', w('Power management'));+  ok(w('Bring-up and test').start === '2026-11-16' && w('Bring-up and test').end === '2026-11-20', 'a parent with no dates spans its subtasks', w('Bring-up and test'));+  ok(w('Place components').parent === w('Lay out the board').uid && w('Route power planes').parent === w('Lay out the board').uid && w('Check return paths').parent === w('Route power planes').uid+    && w('Sensor calibration').parent === w('Bring-up and test').uid && !('parent' in w('Lay out the board')) && !('parent' in w('Rework footprint for J3')) && typeof w('Place components').pos === 'string',+    'each subtask names its parent row by uid (the subtasks model) and gets its place key from the server; a top-level row and an orphan name no parent', ex.items.map(x => [x.name, x.parent]));+  ok(w('Write the EVT report').start === DAY && w('Write the EVT report').end === DAY && eq(w('Write the EVT report').tags, ['undated']) && w('Write the EVT report').status === 'not-started',+    'a task with no date at all is a one-day bar on the import\'s day, tagged undated', w('Write the EVT report'));+  ok(names(ex).filter(n => n === 'Order long-lead parts').length === 1 && w('Order long-lead parts').owner === 'Owen Price'+    && w('Order long-lead parts').description === 'Also assigned in ClickUp: Raj Patel.', 'a task repeated on a second row is one row; the first assignee owns it, the rest are in its notes', w('Order long-lead parts'));+  ok(w('Sensor calibration').owner === '[email protected]', 'an assignee ClickUp writes as an email is the owner as written');+  ok(w('Finalize schematic').description === 'Lock the netlist for EVT.\nReview with Owen, then freeze the "rev B" schematic, and tag it.', 'a description with a line break, commas and quotes comes across whole', w('Finalize schematic').description);+  ok(eq(['Place components', 'Order long-lead parts', 'Check return paths', 'EVT build at the fab', 'Old enclosure concept', 'Power management', 'Pilot run plan'].map(n => w(n).status),+    ['complete', 'in-progress', 'not-started', 'blocked', 'cancelled', 'in-progress', 'not-started']), 'statuses: complete, in progress, to do, waiting on parts, won\'t do, an unknown name, Open');+  ok(w('Lay out the board').effort === 40 && w('Check return paths').effort === 2 && !('effort' in w('EVT build at the fab')), 'time estimates are effort in hours; none is none');+  ok(eq(w('Bootloader').tags, ['firmware']) && eq(w('Order long-lead parts').tags, ['long-lead', 'purchasing']), 'tags come across');+  ok(w('EVT complete').type === 'milestone' && w('EVT complete').date === '2026-11-20' && w('EVT complete').id === 'cu-86b4p3e8k' && !('effort' in w('EVT complete')), 'the Milestone task type is a milestone on its due date, with an id like a task\'s', w('EVT complete'));+  ok(eq(w('Bootloader').provenance, { label: 'ClickUp 86b4p5h2p', url: 'https://app.clickup.com/t/86b4p5h2p' }) && w('Bootloader').id === 'cu-86b4p5h2p', 'each row links back to its ClickUp task');+  ok(new Set(ex.items.map(x => x.uid)).size === 20 && !ex.items.some(x => oldUids.includes(x.uid)), 'every row gets a fresh uid, none of the replaced plan\'s', ex.items.map(x => x.uid));+  let rp = r.j.report || {};+  ok(rp.from === 'ClickUp workspace export (CSV)' && rp.read === 20 && rp.tasks === 18 && rp.milestones === 2 && rp.subtasks === 5 && rp.undated === 1 && rp.undatedOn === DAY+    && !('left' in rp) && !('leftCount' in rp), 'the report counts what came across, the undated row among it; nothing is left out', rp);+  ok(/^Not yet checked against a real ClickUp project\./.test(rp.unchecked || ''), 'and it says, calmly, that the importer has not been checked against a real ClickUp project', rp.unchecked);+  const drop = (rr, what) => (rr.dropped || []).find(d => d.what === what) || {};+  ok(drop(rp, 'Priority').count === 6 && drop(rp, 'Priority').detail === 'urgent 1, high 3, normal 1, low 1', 'priorities are counted, by level', rp.dropped);+  ok(['Checklists', 'Comments', 'Attachments', 'Time tracked', 'Tasks also in other Lists'].every(x => drop(rp, x).count === 1), 'so are checklists, comments, attachments, time tracked and tasks in other Lists', rp.dropped);+  ok(drop(rp, 'Dependencies').count === 0 && /do not include them/.test(drop(rp, 'Dependencies').detail || ''), 'and it says a CSV export carries no dependencies, and where to get them');+  ok((rp.statuses || []).some(s => s.name === 'spec' && s.to === 'in-progress' && s.by === 'guess') && rp.changed.some(s => /"spec"/.test(s)), 'an unknown status name is read as in progress and said to be a guess', rp.statuses);+  ok(['1 task has no start or due date in ClickUp, so it is a one-day bar on 03-Nov-2026, tagged undated.', '5 subtasks keep their parent task, each right after it', '4 tasks have a due date and no start date', '1 task has a start date and no due date, so its bar ends on its start date',+    '1 parent task has no dates of its own', '1 row repeated a task', '1 subtask whose parent is not in the file'].every(s => rp.changed.some(c => c.startsWith(s))), 'the report says what changed on the way in', rp.changed);+  let md = await getT(C, '/changelog?format=md');+  ok(md.includes('REPLACED the whole plan by importing 20 item(s) in 3 phase(s) from clickup-csv. 1 row(s) with no date of their own were placed on 03-Nov-2026 and tagged undated. Not carried over: Priority on 6 tasks;') && md.includes('adom-gantt agent REPLACED'),+    'the changelog\'s import line names the undated rows and what did not come across, as the agent', md.split('\n').filter(l => /REPLACED/.test(l)));+  ok((await getJ(C, '/health')).integrity.ok !== false, 'the chain is whole after the import');+  ok(fs.readdirSync(path.join(C.dir, 'plan-backups')).some(d => d.endsWith('-import')), 'the plan it replaced was backed up first');+  // The plan goes out as CSV and comes back in, a note with a line break included: the plain+  // CSV import used to split the text into lines first and cut such a row in two.+  r = await post(C, '/import/csv', await getT(C, '/export/csv'), Object.assign({ 'Content-Type': 'text/csv' }, auth));+  const back = await getJ(C, '/export/json');+  ok(r.status === 200 && back.items.length === 20 && (byName(back, 'Finalize schematic') || {}).description === w('Finalize schematic').description+    && eq(back.items.map(x => x.uid), ex.items.map(x => x.uid)), 'the imported plan goes out as CSV and back in whole, on the same uids, a two-line note included', [r.status, r.t.slice(0, 200)]);++  // A view export of a Folder (Export view, all columns, ISO dates, US Central).+  r = await post(C, '/import/clickup-csv', read('clickup-view.csv'), auth);+  ex = await getJ(C, '/export/json');+  rp = (r.j && r.j.report) || {};+  ok(r.status === 200 && eq(ex.phases.map(p => p.name), ['Workcell 11 · Mechanical', 'Workcell 11 · Controls']) && ex.project.title === 'Workcell 11', 'a view export: its Folder and Lists name the sections', [r.status, ex.phases]);+  ok(eq(names(ex, 0), ['Remove the old fixture plate', 'Machine the new fixture plate', 'Order 6061 stock', 'First article inspection', 'Mount the cell frame', 'Retrofit complete'])+    && byName(ex, 'Order 6061 stock').parent === byName(ex, 'Machine the new fixture plate').uid, 'Parent ID places the subtasks and names their parent', names(ex, 0));+  const v = n => byName(ex, n) || {};+  ok(v('Remove the old fixture plate').effort === 6 && v('Remove the old fixture plate').start === '2026-10-05' && v('Remove the old fixture plate').description.split('\n').length === 2, 'hours from "Time Estimate (hours)", ISO days as written, a two-line description', v('Remove the old fixture plate'));+  ok(v('Retrofit complete').type === 'milestone' && v('Retrofit complete').date === '2026-10-30', 'a milestone due at 5 pm keeps its day');+  ok(v('Safety sign-off').status === 'complete' && (rp.statuses || []).some(s => s.name === 'with inspector' && s.by === 'date'), 'an unknown status on a task with a done date is complete, and says why');+  ok(v('First article inspection').status === 'in-progress' && !('owner' in v('PLC program update')) && v('Machine the new fixture plate').description === 'Also assigned in ClickUp: Sara Lind.', 'a guess, an unassigned task, a second assignee');+  ok(drop(rp, 'Custom field Story Points (number)').count === 2 && drop(rp, 'Custom field Vendor (drop down)').count === 1 && drop(rp, 'Custom field Risk Notes (text)').count === 1, 'each custom field is named, with the tasks that had a value', rp.dropped);+  ok(drop(rp, 'Priority').detail === 'urgent 1, high 2, normal 1, low 1' && ['Linked tasks', 'Time tracked', 'Sprint points', 'Comments', 'Tasks also in other Lists'].every(x => drop(rp, x).count === 1), 'priority (any case), linked tasks, time logged, points, comments, other Lists', rp.dropped);+  const here = (d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)(new Date());+  ok(rp.undated === 1 && v('Operator training').start === here && eq(v('Operator training').tags, ['undated']), 'with no ?today the undated task lands on this machine\'s date', [rp.undated, v('Operator training'), here]);++  // A view export in ClickUp's Normal date format, with no Parent ID column: the parent's+  // own "Subtask ID's" places its subtasks.+  r = await post(C, '/import/clickup-csv', '"Task ID","Task Name","Status","Due Date","Start Date","Subtask ID\'s","List"\n'+    + '"a1","Parent","to do","Friday, March 10th 2023","Monday, March 6th 2023","[a2]","Sprint 4"\n"a3","Other","in progress","Friday, March 17th 2023","","[]","Sprint 4"\n'+    + '"a2","Child","to do","Wednesday, March 8th 2023","","[]","Sprint 4"\n', auth);+  ex = await getJ(C, '/export/json');+  ok(r.status === 200 && eq(names(ex), ['Parent', 'Child', 'Other']) && byName(ex, 'Child').parent === byName(ex, 'Parent').uid && byName(ex, 'Parent').start === '2023-03-06' && byName(ex, 'Parent').end === '2023-03-10' && byName(ex, 'Child').end === '2023-03-08' && ex.phases[0].name === 'Sprint 4',+    'Normal dates are read as written, and Subtask ID\'s places a subtask when Parent ID is not exported', [r.status, ex.items]);+  // POSIX dates with no zone anywhere: the zone that puts the commonest time of day at 04:00.+  const syd = (d) => String(Date.UTC(2026, 10, d, 4, 0) - 11 * 3600000);+  r = await post(C, '/import/clickup-csv', `Task ID,Task Name,Status,Due Date,List\nb1,Due in Sydney,to do,${syd(24)},L\nb2,Also Sydney,to do,${syd(25)},L\n`, auth);+  ex = await getJ(C, '/export/json');+  ok(r.status === 200 && byName(ex, 'Due in Sydney').end === '2026-11-24' && byName(ex, 'Also Sydney').end === '2026-11-25', 'epoch dates with no zone named read as the days they were set for (UTC would be a day early)', ex.items);+  // Dependency columns, should an export carry them: "Waiting On" and "Blocking", ids or links.+  r = await post(C, '/import/clickup-csv', 'Task ID,Task Name,Status,Start Date,Due Date,Waiting On,Blocking,List,Budget,Risk (text)\n'+    + 'c1,First,to do,2026-10-05,2026-10-06,,[c3],L,,\nc2,Second,to do,2026-10-07,2026-10-08,"First: https://app.clickup.com/t/c1)",,L,1200,\n'+    + 'c3,Third,to do,2026-10-09,2026-10-10,[c2],,L,,high\nc4,Fourth,to do,2026-10-11,2026-10-12,[zz9],,L,,\n', auth);+  ex = await getJ(C, '/export/json');+  rp = (r.j && r.j.report) || {};+  ok(r.status === 200 && eq(byName(ex, 'Second').deps, ['cu-c1']) && eq([...(byName(ex, 'Third').deps || [])].sort(), ['cu-c1', 'cu-c2']) && !byName(ex, 'Fourth').deps && rp.deps === 3,+    'Waiting On and Blocking become deps, a link read for its task id', ex.items.map(x => [x.name, x.deps]));+  ok(drop(rp, 'Dependencies on tasks that did not come across').count === 1 && !drop(rp, 'Dependencies').what && drop(rp, 'Columns this importer does not read').detail === 'Budget' && drop(rp, 'Custom field Risk (text)').count === 1,+    'a link to a task not in the file is reported; an unknown column and a custom field are named', rp.dropped);++  // ?space= ?folder= ?list= keep part of a workspace export.+  r = await post(C, '/import/clickup-csv?list=firmware', read('clickup-workspace.csv'), auth);+  ex = await getJ(C, '/export/json');+  ok(r.status === 200 && eq(ex.phases.map(p => p.name), ['Sensor Board · Firmware']) && ex.items.length === 3 && ex.project.title === 'Firmware', '?list= keeps one List (any case)', [r.status, ex.phases]);+  r = await post(C, '/import/clickup-csv?folder=Sensor%20Board', read('clickup-workspace.csv'), auth);+  ok(r.status === 200 && r.j.phases === 2, '?folder= keeps a Folder\'s Lists', r.j);+  r = await post(C, '/import/clickup-csv?space=Nope', read('clickup-workspace.csv'), auth);+  ok(r.status === 400 && /No task in this file is in/.test(r.j.error) && /Hardware \/ Launch/.test(r.j.error), 'a filter that matches nothing is refused, naming the file\'s Lists', r.j);++  // Refusals: each names the problem, and the plan stays as it was.+  const kept = await getT(C, '/export/json');+  r = await post(C, '/import/clickup-csv', 'name,start,end\nA,2026-10-05,2026-10-09\n', auth);+  ok(r.status === 400 && /does not look like a ClickUp export/.test(r.j.error), 'a file that is not a ClickUp export is refused, saying what is expected', r.j);+  r = await post(C, '/import/clickup-csv', 'Task ID,Task Name,Status\n', auth);+  ok(r.status === 400 && /no tasks under its header/.test(r.j.error), 'a header with no rows is refused');+  r = await post(C, '/import/csv', read('clickup-workspace.csv'), auth);+  ok(r.status === 400 && /This is a ClickUp export/.test(r.j.error), 'a ClickUp export sent to the plain CSV import says which import to use', r.j);+  r = await post(C, '/import/clickup-json', '{not json', auth);+  const r2 = await post(C, '/import/clickup-json', { tasks: [] }, auth);+  ok(r.status === 400 && /not JSON/.test(r.j.error) && r2.status === 400 && /No ClickUp tasks/.test(r2.j.error), 'the JSON door refuses what is not the API\'s answer', [r.j, r2.j]);+  r = await post(C, '/import/clickup-csv?today=2026-13-40', read('clickup-workspace.csv'), auth);+  ok(r.status === 400 && /today must be a date like/.test(r.j.error), 'a ?today that is not a date is refused', r.j);+  ok((await getT(C, '/export/json')) === kept, 'and none of the refusals touched the plan');+  // A project kept with no dates at all comes in too (Drew, 2026-11-03), every row on the day.+  r = await post(C, '/import/clickup-csv?today=' + DAY, 'Task ID,Task Name,Status,List,Tags\nd1,No dates,to do,L,[ops]\nd2,Nor here,to do,L,\n', auth);+  ex = await getJ(C, '/export/json');+  ok(r.status === 200 && r.j.report.undated === 2 && ex.items.every(x => x.start === DAY && x.end === DAY) && eq(byName(ex, 'No dates').tags, ['undated', 'ops']) && eq(byName(ex, 'Nor here').tags, ['undated']),+    'a file with no dated task imports, each row a one-day bar on the day, tagged undated first and keeping its own tags', [r.status, ex.items]);++  // The API's answer, saved: ClickUp's order, dependencies, statuses by type, dates.+  r = await post(C, '/import/clickup-json?today=' + DAY, read('clickup-tasks.json'), auth);+  ex = await getJ(C, '/export/json');+  rp = (r.j && r.j.report) || {};+  const jsonPlan = shape(ex);+  ok(r.status === 200 && eq(ex.phases.map(p => p.name), ['Pick and Place · Line 2 bring-up', 'Pick and Place · Line 2 maintenance']) && /the ClickUp API \(saved JSON\)/.test(ex.project.subtitle), '/import/clickup-json takes the API\'s answer for a List', [r.status, ex.phases]);+  ok(eq(names(ex, 0), ['Level and anchor the machine', 'Install the feeder bank', 'Load the first program', 'Teach fiducials', 'Import the CAD centroid file',+    'First panel off the line', 'Run the 50-panel qualification', 'Supplier visit from Sydney', 'Write the line handbook', 'Second-shift staffing plan']), 'rows in orderindex order, subtasks in theirs, whatever order the file lists them', names(ex, 0));+  ok(byName(ex, 'Teach fiducials').parent === byName(ex, 'Load the first program').uid && byName(ex, 'Import the CAD centroid file').parent === byName(ex, 'Load the first program').uid, 'the API\'s parent becomes the row\'s parent');+  const a = n => byName(ex, n) || {};+  ok(eq(a('Install the feeder bank').deps, ['cu-86c3a0002']) && eq(a('Load the first program').deps, ['cu-86c3a0001']) && eq(a('First panel off the line').deps, ['cu-86c3a0003'])+    && eq(a('Run the 50-panel qualification').deps, ['cu-86c3a0006']) && a('First panel off the line').id === 'cu-86c3a0006' && rp.deps === 4,+    'dependencies, read from either end, become deps: a milestone may wait on a task and a task on a milestone', ex.items.map(x => [x.name, x.id, x.deps]));+  ok(drop(rp, 'Dependencies on tasks that did not come across').count === 1 && !drop(rp, 'Dependencies').what && !rp.dropped.some(d => /milestone/i.test(d.what)), 'only the wait on a task outside the file is counted as not carried', rp.dropped);+  ok(eq(['Level and anchor the machine', 'Install the feeder bank', 'Load the first program', 'Teach fiducials', 'Import the CAD centroid file', 'Run the 50-panel qualification', 'Second-shift staffing plan'].map(n => a(n).status),+    ['complete', 'complete', 'in-progress', 'in-progress', 'not-started', 'blocked', 'cancelled']), 'statuses by ClickUp\'s type: closed, done, custom, open; a closed one named cancelled is cancelled');+  ok(a('Supplier visit from Sydney').start === '2026-11-23' && a('Supplier visit from Sydney').end === '2026-11-24', 'a date with no time set in Sydney keeps its Sydney day', a('Supplier visit from Sydney'));+  ok(a('Run the 50-panel qualification').end === '2026-11-20' && a('Level and anchor the machine').start === '2026-10-28', 'a due date at 3 pm Central keeps its day; a date set under summer time keeps its day', [a('Run the 50-panel qualification'), a('Level and anchor the machine')]);+  ok(a('Load the first program').effort === 16 && a('Import the CAD centroid file').owner === '[email protected]' && /Also assigned in ClickUp: omar\.s@example\.com\./.test(a('Load the first program').description), 'time_estimate in hours; an assignee with no name is their email');+  ok(drop(rp, 'Custom field Machine (drop down)').count === 1 && drop(rp, 'Custom field Budget (currency)').count === 1 && !drop(rp, 'Custom field Notes (text)').what, 'custom fields with a value are named; one with none is not', rp.dropped);+  ok(drop(rp, 'Custom task types').count === 1 && ['Checklists', 'Linked tasks', 'Sprint points', 'Team assignees', 'Tasks also in other Lists'].every(x => drop(rp, x).count === 1), 'custom task types, checklists, links, points, team assignees and other Lists are counted', rp.dropped);+  ok(rp.undated === 1 && a('Write the line handbook').start === DAY && eq(a('Write the line handbook').tags, ['undated']), 'the undated task is a one-day bar on the day, tagged undated');+  const pages = JSON.parse(read('clickup-tasks.json')).tasks;+  r = await post(C, '/import/clickup-json?today=' + DAY, [{ tasks: pages.slice(0, 5), last_page: false }, { tasks: pages.slice(5), last_page: true }], auth);+  ex = await getJ(C, '/export/json');+  ok(r.status === 200 && shape(ex) === jsonPlan, 'several pages in an array make the same plan as one');+  const at = (m, d) => String(Date.UTC(2026, m - 1, d, 9, 0));+  r = await post(C, '/import/clickup-json', { tasks: [+    { id: 'e1', name: 'Approved, not built', status: { status: 'approved', type: 'custom' }, start_date: at(10, 5), due_date: at(10, 6), list: { id: '1', name: 'L' } },+    { id: 'e2', name: 'Out the door', status: { status: 'shipped', type: 'done' }, due_date: at(10, 7), list: { id: '1', name: 'L' } },+    { id: 'e3', name: 'Next up', status: { status: 'ready for dev', type: 'custom' }, due_date: at(10, 8), list: { id: '1', name: 'L' } } ] }, auth);+  ex = await getJ(C, '/export/json');+  ok(r.status === 200 && eq(names(ex).map(nm => byName(ex, nm).status), ['in-progress', 'complete', 'not-started']),+    'a middle status that sounds finished follows ClickUp\'s type and is in progress; a done one named anything is complete', ex.items);+  // The subtasks model's limits: a milestone is never a parent, and three levels is the deepest.+  const L = { id: '1', name: 'L' }, open = { status: 'to do', type: 'open' };+  const T = (id, name, parent, extra) => Object.assign({ id, name, status: open, start_date: at(10, 5), due_date: at(10, 9), list: L }, parent ? { parent } : {}, extra || {});+  r = await post(C, '/import/clickup-json', { tasks: [T('g0', 'Top'), T('g1', 'Level 1', 'g0'), T('g2', 'Level 2', 'g1'), T('g3', 'Level 3', 'g2'), T('g4', 'Level 4', 'g3'),+    T('m0', 'Gate', null, { custom_item_id: 1, start_date: null }), T('m1', 'Under the gate', 'm0'),+    T('m2', 'Gate under a task', 'g0', { custom_item_id: 1, start_date: null }), T('m3', 'Under the inner gate', 'm2')] }, auth);+  ex = await getJ(C, '/export/json');+  rp = (r.j && r.j.report) || {};+  const up = nm => { const x = byName(ex, nm) || {}; return x.parent ? (ex.items.find(y => y.uid === x.parent) || {}).name : null; };+  ok(r.status === 200 && eq(['Level 1', 'Level 2', 'Level 3', 'Level 4', 'Gate under a task', 'Under the inner gate', 'Under the gate', 'Gate', 'Top'].map(up),+    ['Top', 'Level 1', 'Level 2', 'Level 2', 'Top', 'Top', null, null, null]),+    'a fourth level sits at the third; a milestone may be a subtask but never a parent: its subtask goes to the nearest task above, or the top', ex.items.map(x => [x.name, up(x.name)]));+  ok(rp.subtasks === 6 && ['1 subtask is nested more than three levels deep', '1 subtask of a milestone sits under the nearest task above it', '1 subtask of a milestone is a top-level row'].every(t => rp.changed.some(c => c.startsWith(t))),+    'the report says which subtasks moved, and why', rp.changed);+  // Undated rows in a tree (Drew, 2026-11-03): a parent with no dates spans its dated subtasks;+  // a subtask with none is a one-day bar on the day, tagged, and so is a parent whose subtasks+  // have none either, and a milestone with none. The tag survives a full list of ClickUp tags.+  const U = (id, name, parent, extra) => Object.assign({ id, name, status: open, list: L }, parent ? { parent } : {}, extra || {});+  const twelve = Array.from({ length: 12 }, (_, i) => ({ name: 'k' + i }));+  r = await post(C, '/import/clickup-json?today=' + DAY, { tasks: [+    U('u0', 'Spans its dated child'), U('u1', 'Dated child', 'u0', { start_date: at(10, 12), due_date: at(10, 14) }), U('u2', 'Undated child', 'u0'),+    U('u3', 'Undated parent'), U('u4', 'Undated grandchild', 'u3'), U('u5', 'Undated gate', null, { custom_item_id: 1 }), U('u6', 'Many tags', null, { tags: twelve })] }, auth);+  ex = await getJ(C, '/export/json');+  const x = nm => byName(ex, nm) || {};+  ok(r.status === 200 && x('Spans its dated child').start === '2026-10-12' && x('Spans its dated child').end === '2026-10-14' && !x('Spans its dated child').tags+    && x('Undated child').start === DAY && eq(x('Undated child').tags, ['undated']) && x('Undated parent').start === DAY && eq(x('Undated parent').tags, ['undated'])+    && x('Undated grandchild').start === DAY && x('Undated grandchild').parent === x('Undated parent').uid && x('Undated gate').date === DAY && eq(x('Undated gate').tags, ['undated'])+    && x('Many tags').tags.length === 12 && x('Many tags').tags[0] === 'undated' && r.j.report.undated === 5,+    'a parent spans its dated subtasks only; undated subtasks, parents and milestones land on the day, tagged; the tag survives the tag cap', ex.items.map(y => [y.name, y.start || y.date, y.end, y.tags]));+  stop(C);++  // The live API, against a stand-in for ClickUp on loopback.+  const http = await import('node:http');+  const TOKEN = 'pk_81000001_FAKETOKENFORTHESUITE0123456789AB';+  const seen = [];+  const L1 = pages.filter(t => t.list.id === '901300000501'), L2 = pages.filter(t => t.list.id === '901300000502');+  const fake = http.createServer((req, res) => {+    seen.push({ url: req.url, auth: req.headers.authorization });+    const send = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };+    if (req.headers.authorization !== TOKEN) return send(401, { err: 'Token invalid', ECODE: 'OAUTH_025' });+    const u = new URL(req.url, 'http://x');+    if (u.pathname === '/api/v2/user') return send(200, { user: { id: 81000001, username: 'Nadia Haddad', timezone: 'America/Chicago' } });+    if (u.pathname === '/api/v2/folder/90130000100/list') return send(200, { lists: [{ id: '901300000501', name: 'Line 2 bring-up' }, { id: '901300000502', name: 'Line 2 maintenance' }] });+    const m = u.pathname.match(/^\/api\/v2\/list\/(\d+)\/task$/);+    if (m && u.searchParams.get('subtasks') === 'true' && u.searchParams.get('include_closed') === 'true') {+      const page = +u.searchParams.get('page');+      if (m[1] === '901300000501') return send(200, page === 0 ? { tasks: L1.slice(0, 6), last_page: false } : { tasks: L1.slice(6), last_page: true });+      if (m[1] === '901300000502') return send(200, { tasks: L2, last_page: true });+    }+    return send(404, { err: 'Route not found' });+  });+  await new Promise(res => fake.listen(0, '127.0.0.1', res));+  try {+    const tokFile = path.join(TMP, 'clickup-token');+    fs.writeFileSync(tokFile, TOKEN + '\n', { mode: 0o600 });+    fs.chmodSync(tokFile, 0o600);+    const K = await start('clickup-api', basePlan(false), null, 8940,+      { CLICKUP_API_BASE: `http://127.0.0.1:${fake.address().port}/api/v2`, CLICKUP_TOKEN: '', CLICKUP_TOKEN_FILE: '' }, { args: ['--clickup-token-file', tokFile] });+    const kauth = { Authorization: 'Bearer ' + K.token };+    let g = await (await fetch(K.base + '/import/clickup-api', { headers: kauth })).json();+    ok(g.configured === true && g.from === 'file' && !JSON.stringify(g).includes(TOKEN), 'GET /import/clickup-api says a token is set up, from the file, and never shows it', g);+    r = await post(K, '/import/clickup-api?today=2026-11-03', { folder: '90130000100' }, kauth);+    ex = await getJ(K, '/export/json');+    ok(r.status === 200 && shape(ex) === jsonPlan && /the ClickUp API$/.test(ex.project.subtitle), 'a Folder from the live API makes the same plan as the saved JSON', [r.status, r.t.slice(0, 200)]);+    ok(seen.some(s => /\/list\/901300000501\/task\?.*page=1/.test(s.url)) && seen.every(s => s.auth === TOKEN), 'it follows the pages, and sends the token in ClickUp\'s header', seen.map(s => s.url));+    const files = fs.readdirSync(K.dir).filter(f => fs.statSync(path.join(K.dir, f)).isFile()).map(f => fs.readFileSync(path.join(K.dir, f), 'utf8')).join('\n');+    ok(!files.includes(TOKEN) && !r.t.includes(TOKEN) && !(await getT(K, '/changelog')).includes(TOKEN), 'the token is in no file beside the plan, no answer and no log line');+    r = await post(K, '/import/clickup-api', { url: 'https://app.clickup.com/9013000001/v/li/901300000502' }, kauth);+    ex = await getJ(K, '/export/json');+    ok(r.status === 200 && eq(names(ex), ['Weekly nozzle cleaning']), 'a List\'s link imports that List', [r.status, names(ex)]);+    r = await post(K, '/import/clickup-api', { list: '../../user' }, kauth);+    ok(r.status === 400 && /is not a ClickUp id/.test(r.j.error), 'an id that is not one is refused before anything is asked of ClickUp', r.j);+    r = await post(K, '/import/clickup-api', {}, kauth);+    ok(r.status === 400 && /Say what to import/.test(r.j.error), 'a request that names nothing is refused, saying how');+    fs.chmodSync(tokFile, 0o644);+    g = await (await fetch(K.base + '/import/clickup-api', { headers: kauth })).json();+    r = await post(K, '/import/clickup-api', { list: '901300000502' }, kauth);+    ok(g.configured === false && /chmod 600/.test(g.error) && r.status === 400 && /chmod 600/.test(r.j.error), 'a token file other users can read is not used', [g, r.j]);+    fs.chmodSync(tokFile, 0o600);+    fs.writeFileSync(tokFile, 'pk_wrong\n');+    r = await post(K, '/import/clickup-api', { list: '901300000502' }, kauth);+    ok(r.status === 502 && /ClickUp refused the token \(401\)/.test(r.j.error), 'a token ClickUp refuses is a 502 that says so', r.j);+    fs.rmSync(tokFile);+    r = await post(K, '/import/clickup-api', { list: '901300000502' }, kauth);+    ok(r.status === 400 && /No ClickUp token/.test(r.j.error), 'no token at all is a 400 that says where one goes', r.j);+    ok(eq(names(await getJ(K, '/export/json')), ['Weekly nozzle cleaning']), 'and none of those touched the plan');+    stop(K);+  } finally { fake.close(); }+}++// ── A whole-state save that changes a created row (0.79.0) ──+// The sidecar holds a created row twice: its entry in `added` and, once anybody has edited it, a+// full override in `items` on top. On 2026-11-03 an agent's whole-state save changed a created+// row's dependencies and dates in `added` and sent the overrides back as it had read them, so the+// override (written by an earlier owner edit) hid both changes. A save now carries such a change+// into the override, and only where the override still held the field's previous value.+async function savedCreatedSection() {+  console.log('H. a whole-state save that changes a created row');+  const plan = { project: { title: 'Created rows', subtitle: 'server_test' }, phases: [{ name: 'A', start: '', end: '' }],+    items: [{ type: 'task', uid: '00001', id: 't1', name: 'First', start: '2026-10-05', end: '2026-10-09', phase: 0 }] };+  const created = { type: 'task', uid: '00010', id: 'c1', name: 'Created', start: '2026-10-05', end: '2026-10-09', phase: 0, owner: 'Drew', deps: [] };+  // The full override an owner edit writes: every field, with the owner changed to Ana on purpose.+  const ov = { uid: '00010', owner: 'Ana', status: '', desc: '', prov: '', name: 'Created', phase: 0, deps: [], s: '2026-10-05', e: '2026-10-09', note: '', effort: null };+  const side = { v: 4, items: [ov], deleted: [], added: [created], order: ['00001', '00010'], stateRev: 5, phases: [{ i: 0, name: 'A', start: '', end: '', tags: [] }] };+  const S = await start('save-created', plan, side, 8941);+  const auth = { Authorization: 'Bearer ' + S.token };+  const row = async () => ((await (await fetch(S.base + '/export/json', { headers: auth })).json()).items || []).find(r => r.uid === '00010') || {};+  const stored = () => JSON.parse(fs.readFileSync(path.join(S.dir, 'plan-state.json'), 'utf8'));+  ok((await row()).owner === 'Ana' && eq((await row()).deps || [], []), 'before: the override stands over the created row (owner Ana, no dependencies)', await row());+  const bak = path.join(S.dir, 'plan-state.json.pre-pos.bak');+  ok(fs.existsSync(bak) && eq(JSON.parse(fs.readFileSync(bak, 'utf8')).items, side.items) && stored().items[0].pos, 'the first start gives every row its place key and keeps the sidecar as it was in .pre-pos.bak', fs.existsSync(bak));+  // What the agent did: read the state, change the created row in `added`, send everything back.+  let st = await getJ(S, '/state');+  let a = st.added.find(x => x.uid === '00010');+  a.deps = ['t1']; a.end = '2026-10-16'; a.owner = 'Bo';+  let r = await post(S, '/save', Object.assign({}, st, { baseRev: st.stateRev }), auth);+  let x = await row();+  ok(r.status === 200 && eq(x.deps, ['t1']) && x.end === '2026-10-16', 'a whole-state save that gives a created row a dependency and a new end keeps both, though the override still held the old values', x);+  ok(x.owner === 'Ana', 'an override that says something else on purpose (the owner) still stands', x.owner);+  const o = stored().items.find(y => y.uid === '00010');+  ok(eq(o.deps, ['t1']) && o.e === '2026-10-16' && o.owner === 'Ana', 'the override took the new dependency and end, and kept its own owner', o);+  // A save that changes the override itself keeps the override's own value.+  st = await getJ(S, '/state');+  st.items.find(y => y.uid === '00010').e = '2026-10-20';+  st.added.find(y => y.uid === '00010').end = '2026-10-23';+  r = await post(S, '/save', Object.assign({}, st, { baseRev: st.stateRev }), auth);+  ok(r.status === 200 && (await row()).end === '2026-10-20', 'a save that changes the override itself keeps the override\'s value', (await row()).end);+  // Sent back exactly as read, nothing moves.+  const before = JSON.stringify(await row());+  st = await getJ(S, '/state');+  r = await post(S, '/save', Object.assign({}, st, { baseRev: st.stateRev }), auth);+  ok(r.status === 200 && JSON.stringify(await row()) === before, 'a save sent back exactly as read changes nothing');+  stop(S);+}++// A section runs when no --only is given or its name contains one of the --only terms.+const SECTIONS = [['core', main], ['clickup import', clickupSection], ['save created rows', savedCreatedSection]];+try { for (const [name, fn] of SECTIONS) if (!ONLY || ONLY.some(o => name.includes(o))) await fn(); } catch (e) { fail++; console.log('  FAIL threw: ' + (e && e.stack || e)); } finally {   procs.forEach(p => { try { p.kill('SIGTERM'); } catch (e) {} });
test/ui_test.mjs+482−1
@@ -245,6 +245,9 @@ const ALLOWED_FAILURES = [   // The notify flow pastes a URL that is not a Chat webhook into the owner's field: the   // server refuses it with a 400, which is the point of the check.   { path: /^\/notify$/, status: 400 },+  // The clickup flow offers a file that is not a ClickUp export to ClickUp CSV: the server+  // refuses it with a 400 naming what it expected, which is the point of the check.+  { path: /^\/import\/clickup-csv$/, status: 400 }, ]; async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today = TODAY, clear = true, ctx = null } = {}) {   const page = await (ctx || browser).newPage();@@ -894,7 +897,9 @@ async function main() {       await page.close();     }     if (wantAny('editor', 'new item fields', 'table filters')) await editorFlows(browser, S);+    if (wantAny('editor layout')) await ownServers(editorLayoutFlow, browser);     if (wantAny('export menu', 'import menu')) await menuFlows(browser, S);+    if (wantAny('clickup import')) await ownServers(clickupFlow, browser);     if (wantAny('sections')) await ownServers(sectionsFlow, browser);     if (wantAny('tags')) await ownServers(tagsFlow, browser);     if (wantAny('row tags')) await ownServers(rowTagsFlow, browser);@@ -908,6 +913,8 @@ 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('subtasks')) await ownServers(subtasksFlow, browser);+    if (wantAny('quick add')) await ownServers(quickAddFlow, browser);     if (wantAny('milestone deps')) await ownServers(milestoneDepsFlow, browser);     if (wantAny('row select')) await ownServers(rowSelectFlow, browser); @@ -1171,6 +1178,9 @@ const MANIFEST = [   { sel: '.dd-item', within: 'exportMenu', label: 'help rows (inert text)', safe: true, key: c => (!c.ds.fmt && c.cursor === 'default') ? 'help-text' : undefined, pin: ['help-text'] },   { sel: '.dd-item', within: 'importMenu', label: 'import JSON / CSV', terminal: true, by: 'import menu', key: c => c.ds.fmt, pin: ['json', 'csv'] },   { sel: '.dd-item', within: 'importMenu', label: 'import MS Project XML / Jira CSV', untested: 'the import flow checks the file chooser opens for these with the right accept filter, but no MS Project or Jira file is parsed', key: c => c.ds.fmt, pin: ['msproject-xml', 'jira-csv'] },+  { sel: '.dd-item', within: 'importMenu', label: 'import ClickUp CSV / ClickUp API JSON', terminal: true, by: 'clickup import', key: c => c.ds.fmt, pin: ['clickup-csv', 'clickup-json'] },+  // Renders only after a ClickUp import, in the tab that made it; the clickup flow imports.+  { sel: '#irClose', label: 'ClickUp import report: close', terminal: true, by: 'clickup import', required: false },   { sel: '.sec-sw', label: 'section colour', terminal: true, by: 'sections', key: c => c.title, pin: ['Colour'] },   // 0.61.0: the up/down arrows and Edit are gone (a row is dragged, or moved with Alt+Up/Down,   // and its name and aside are edited by a click on them). Remove is the one row button left.@@ -1295,6 +1305,15 @@ const MANIFEST = [   { sel: '#btnCopyIds', label: 'table: Copy IDs', required: false, untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' },   // The table's hover-only delete.   { sel: '.row-del', label: 'table row delete (hover only)', terminal: true, by: 'live: delete' },+  // Subtasks and quick add (0.79.0). The chevron shows only on a plan with subtasks, which the+  // walk's plan has none of; the header's + and the blank row's field only on hover and while+  // adding. The + on a task row renders in the walk; there is no "+ Add task" line (0.79.0).+  { sel: '.sub-tog', label: 'a task\'s chevron: fold or unfold its subtasks', terminal: true, by: 'subtasks', required: false },+  { sel: '.row-add', label: 'a task\'s +: add a subtask in place (hover only)', terminal: true, by: 'quick add' },+  { sel: '.lane-add', label: 'a section header\'s +: add a task in place (hover only)', terminal: true, by: 'quick add', required: false },+  // The Table's quick add is the + in a row's Section cell, on a row's hover (0.79.0).+  { sel: '.sec-add', label: 'table: a row\'s Section cell +, add a task to that section (hover only)', terminal: true, by: 'quick add' },+  { sel: '.qa-in', label: 'quick add: the new row\'s name', terminal: true, by: 'quick add', required: false },   // The row handle: selects the row and nothing else, a view state, so the walk clicks it.   // Shown on hover, and on a selected row; the `row select` flow proves what it does.   { sel: '.row-sel', within: 'labels', label: 'chart row: the handle, selects the row (hover only)', safe: true },@@ -1433,6 +1452,10 @@ async function coverageGate(browser, url) {       await page.mouse.move(r.x, r.y); }],     ['item editor', async () => { await view(page, 'gantt'); await openItemEditor(page, 'Lane 1 task 1'); }],     ['batch panel', async () => { await view(page, 'gantt'); await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']); }],+    // 0.79.0: the item editor opened from the Table (its Deps cell) is the same panel, walked+    // there too, for a task and for a milestone.+    ['table: item editor', async () => { await view(page, 'table'); await page.click('#tableView tr[data-key="t:finale"] td.c-deps'); }],+    ['table: milestone editor', async () => { await view(page, 'table'); await page.click(`#tableView tr[data-key="m:${kick.uid}"] td.c-deps`); }],     ['table', async () => { await view(page, 'table'); }],     ['table row hover', async () => { await view(page, 'table');       const r = await page.evaluate(() => { const tr = document.querySelectorAll('#tableView tbody tr[data-key]')[3]; tr.scrollIntoView({ block: 'center' }); const b = tr.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });@@ -1457,7 +1480,7 @@ async function coverageGate(browser, url) {     // A state that renders nothing new would make every check about it vacuous.   }   // The added states must really have opened what they claim to.-  for (const [st, sel] of [['setup: rename', '.sec-nm'], ['setup: aside', '.sec-ld'], ['setup: tag rename', '.tag-nm'], ['setup: tag colour', '.tag-hex'], ['legend: tag filter on', '#tagClear'], ['bar editor', '#imNote'], ['milestone editor', '#imApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']])+  for (const [st, sel] of [['setup: rename', '.sec-nm'], ['setup: aside', '.sec-ld'], ['setup: tag rename', '.tag-nm'], ['setup: tag colour', '.tag-hex'], ['legend: tag filter on', '#tagClear'], ['bar editor', '#imNote'], ['milestone editor', '#imApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table: item editor', '#imAddDep'], ['table: milestone editor', '#imAddDep'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']])     check(`state "${st}" opened (its ${sel} rendered)`, seen.has(entryId(MANIFEST.find(d => d.sel === sel))), `controls seen in that state: ${stateControls[st]}`);   await view(page, 'gantt');   check('every rendered control is declared (no orphans)', orphans.length === 0, [...new Set(orphans)].slice(0, 8).join(' | '));@@ -1934,6 +1957,165 @@ async function editorFlows(browser, S) {   await page.close(); } +// ── The edit panels' one layout (0.79.0) ──+// Drew, 2026-11-03: at 120% on a 1366px window the item editor was 915px wide, cut a long+// name and long notes to one line, had a title the size of its labels and kept its own+// 100% type while the rest of the app grew. Every panel that edits a task (the item editor+// on a task and on a milestone, from the chart and from the Table, New, the batch panel) is+// one layout now: 400px at 100% in the app's text size, fields that wrap and grow, a real+// title, and buttons that never scroll away and never sit under the selection chip.+async function editorLayoutFlow(browser) {+  const plan = seedPlan();+  const waitsOn = plan.items.filter(r => r.type === 'task' && /^p[0-2]t\d$/.test(r.id || '')).map(r => r.id).slice(0, 14);+  const LONG = 'Design, fabricate and qualify the vacuum fixture plates for the pick-and-place line, including the second-source review and the first-article inspection';+  const NOTES = 'The first supplier quoted six weeks and could not promise the flatness we need, so a second source is qualified in parallel. Inspection needs the CMM for two days and the result goes to quality before the readiness review.';+  const LINK = { label: 'Chat: the fixture plates thread with the supplier, review notes', url: 'https://example.com/thread/plates' };+  plan.items.push({ type: 'task', id: 'long', uid: '09001', name: LONG, description: NOTES, provenance: LINK, start: '2026-11-30', end: '2026-12-18', phase: 4, status: 'in-progress', owner: 'Drew', note: '6m', tags: ['long-lead', 'fixtures'], deps: waitsOn });+  plan.items.push({ type: 'milestone', id: 'ready', uid: '09002', name: 'Production readiness review: every fixture, feeder and program signed off', description: NOTES, provenance: LINK, date: '2026-12-21', phase: 4, deps: ['long', 'p4t0', 'p4t1'] });+  plan.project.nextUid = 9003;+  const X = await startServer(plan, 'layout');+  const esc3 = async page => { for (let i = 0; i < 3; i++) await page.keyboard.press('Escape'); await settle(page, 120); };+  // What a panel measures: its box, its type sizes, its growing fields, its rows.+  const geo = page => page.evaluate(() => {+    const ed = document.getElementById('editor'), r = ed.getBoundingClientRect(), body = ed.querySelector('.ed-body');+    const fz = s => { const e = ed.querySelector(s); return e ? parseFloat(getComputedStyle(e).fontSize) : 0; };+    const ta = id => { const t = document.getElementById(id); if (!t) return null; return { h: t.getBoundingClientRect().height, sh: t.scrollHeight, ch: t.clientHeight, lh: parseFloat(getComputedStyle(t).lineHeight) }; };+    const top = id => { const e = document.getElementById(id); return e ? Math.round(e.getBoundingClientRect().top) : null; };+    const out = [...ed.querySelectorAll('*')].filter(e => { const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0 && (b.left < r.left - 0.5 || b.right > r.right + 0.5); }).map(e => e.id || e.className || e.tagName);+    return { shown: ed.classList.contains('show'), w: r.width, l: r.left, rt: r.right, t: r.top, b: r.bottom, vw: innerWidth, vh: innerHeight,+      band: parseFloat(getComputedStyle(ed).getPropertyValue('--ed-bottom')), scrolls: !!body && body.scrollHeight > body.clientHeight + 1,+      title: fz('.ed-title'), label: fz('.ed-f > label'), field: fz('.ed-f textarea, .ed-f input'), btn: fz('.de-actions .de-btn'),+      pad: body ? getComputedStyle(body).paddingLeft : '', out, name: ta('imName'), desc: ta('imDesc'),+      rows: { owner: top('imOwner'), status: top('imStatus'), start: top('imStart'), end: top('imEnd') } };+  });+  // Every button in the panel's foot is the element a click at its centre lands on.+  // (No panel, or a panel with no buttons, is a failure too, so this cannot pass vacuously.)+  const buttonsOnTop = page => page.evaluate(() => { const bs = [...document.querySelectorAll('#editor.show .de-actions button')].filter(b => b.offsetParent);+    if (bs.length < 2) return ['the panel and its buttons are not there'];+    return bs.map(b => { const r = b.getBoundingClientRect(), t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);+      return t === b || b.contains(t) ? '' : `${b.id} under ${t ? (t.id || t.className) : 'nothing'}`; }).filter(Boolean); });+  // The font size of every element in the panel, in document order, to compare across sizes.+  const fonts = page => page.evaluate(() => [...document.querySelectorAll('#editor *')].filter(e => e.offsetParent !== null).map(e => (e.id || e.className || e.tagName) + '=' + getComputedStyle(e).fontSize));+  const fsTo = async (page, steps) => { await esc3(page); await page.click('#fsPct'); for (let i = 0; i < Math.abs(steps); i++) await page.click(steps > 0 ? '#fsUp' : '#fsDown'); await settle(page, 200); };++  section = 'editor: layout';+  let page = await openPage(browser, X.url, { w: 1366, h: 768 });+  await fsTo(page, 2);                                     // 120%, Drew's size+  await clickBar(page, 'long');+  let g = await geo(page);+  check('at 120% on a 1366px window the item editor is 480px wide (it was 915)', g.shown && Math.abs(g.w - 480) < 1, JSON.stringify({ w: g.w }));+  check('it sits inside the window and clear of the bottom band', g.l >= 7.5 && g.rt <= g.vw - 7.5 && g.t >= 7.5 && g.b <= g.vh - g.band + 0.5, JSON.stringify(g));+  check('nothing in it runs past its sides', g.out.length === 0, g.out.slice(0, 5).join(', '));+  check('taller than the window allows, it scrolls inside itself', g.scrolls);+  check('and its buttons stay on screen and on top', (await buttonsOnTop(page)).length === 0, (await buttonsOnTop(page)).join(', '));+  check(`the title reads as a title: ${g.title}px over ${g.label}px labels`, Math.abs(g.title - 19.2) < 0.3 && Math.abs(g.label - 13.2) < 0.3 && g.title >= g.label * 1.4, JSON.stringify({ title: g.title, label: g.label }));+  check('a long name opens whole, wrapped over several lines', g.name.sh <= g.name.ch + 1 && g.name.h > 2.5 * g.name.lh, JSON.stringify(g.name));+  check('long notes open whole, wrapped', g.desc.sh <= g.desc.ch + 1 && g.desc.h > 3 * g.desc.lh, JSON.stringify(g.desc));+  check('fields that share a row line up (owner with status, start with deadline)', g.rows.owner === g.rows.status && g.rows.start === g.rows.end, JSON.stringify(g.rows));+  const lk = await page.evaluate(() => { const a = document.getElementById('imProvLink'); return a ? { h: a.getBoundingClientRect().height, text: a.textContent, over: a.scrollWidth > a.clientWidth + 1 } : null; });+  check('a long linked provenance wraps instead of being cut', !!lk && !lk.over && lk.h > 30, JSON.stringify(lk));+  const deps = await page.$$eval('#imDepList .dep-rm', ls => ls.map(x => x.dataset.dir + ':' + x.dataset.dep));+  check('all 14 dependencies are listed, and the milestone waiting on it, in the body that scrolls', deps.filter(d => d.startsWith('in:')).length === 14 && deps.includes('out:ready'), deps.join(' '));+  await shot(page, 'editor-layout-1366-120');++  section = 'editor: growing fields';+  const h = id => page.$eval('#' + id, e => Math.round(e.getBoundingClientRect().height));+  const one = await h('imOwner');+  await page.click('#imName'); await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+  await page.keyboard.type('Short name'); await settle(page, 80);+  check('a short name is one line, as high as the one-line fields', await h('imName') === one, `${await h('imName')} vs ${one}`);+  const more = ' that keeps on going until it cannot fit on one line, and then on two';+  await page.keyboard.type(more); await settle(page, 80);+  const grown = await h('imName');+  const lines = await page.$eval('#imName', e => { const cs = getComputedStyle(e); return Math.round((e.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)) / parseFloat(cs.lineHeight)); });+  check('typing past the edge grows the field a line at a time, hiding nothing', lines === 2 && grown > one && await page.$eval('#imName', e => e.scrollHeight <= e.clientHeight + 1), `${one} -> ${grown}, ${lines} lines`);+  for (let i = 0; i < more.length; i++) await page.keyboard.press('Backspace');+  await settle(page, 80);+  check('deleting it shrinks the field back to one line', await h('imName') === one, `${await h('imName')} vs ${one}`);+  await page.$eval('#imName', e => { e.value = 'pasted\nover two lines'; e.dispatchEvent(new Event('input', { bubbles: true })); });+  check('a pasted line break becomes a space: the name is still one line of text', await page.$eval('#imName', e => e.value) === 'pasted over two lines');+  await page.keyboard.press('Escape'); await settle(page, 300);+  check('Escape cancels the panel and saves none of it', !(await editorShown(page)) && rowOf(await savedPlan(X.url), r => r.id === 'long').name === LONG);+  await clickBar(page, 'p0t2');+  await page.click('#imDesc'); await page.keyboard.type('a note typed here'); await page.keyboard.press('Enter'); await settle(page, 300);+  const t2 = rowOf(await savedPlan(X.url), r => r.id === 'p0t2');+  check('Enter in a growing field applies the panel, with no line break in the value', !(await editorShown(page)) && t2.description === 'a note typed here', JSON.stringify(t2.description));+  await undo(page);+  check('and one undo takes it back', !rowOf(await savedPlan(X.url), r => r.id === 'p0t2').description);++  section = 'editor: text size';+  await fsTo(page, 0);+  await clickBar(page, 'long');+  const base = await fonts(page); g = await geo(page);+  check('at 100% the panel is 400px wide', Math.abs(g.w - 400) < 1, String(g.w));+  for (const [steps, fs] of [[-2, 0.85], [-1, 0.925], [1, 1.1], [3, 1.35], [4, 1.5]]) {+    await fsTo(page, steps);+    await clickBar(page, 'long');+    g = await geo(page);+    const now = await fonts(page);+    const off = now.map((x, i) => { const a = parseFloat(base[i].split('=').pop()), b = parseFloat(x.split('=').pop()); return Math.abs(b - a * fs) > 0.05 ? `${x} (${a}px at 100%)` : ''; }).filter(Boolean);+    check(`at ${Math.round(fs * 1000) / 10}%: ${Math.round(400 * fs)}px wide, and every type size in it is ${fs} times its 100% size`, Math.abs(g.w - 400 * fs) < 1 && now.length === base.length && off.length === 0,+      `w ${g.w}; ${now.length}/${base.length} elements; ${off.slice(0, 4).join(', ')}`);+    check(`at ${Math.round(fs * 1000) / 10}%: inside the window, buttons on top, nothing past its sides`, g.l >= 7.5 && g.rt <= g.vw - 7.5 && g.t >= 7.5 && g.b <= g.vh - g.band + 0.5 && g.out.length === 0 && (await buttonsOnTop(page)).length === 0, JSON.stringify({ l: g.l, rt: g.rt, t: g.t, b: g.b, out: g.out.slice(0, 3) }));+  }+  // The pickers follow it too: the phase list opens over the panel in the panel's type.+  await page.click('#imPhase'); await settle(page, 120);+  const pl = await page.evaluate(() => { const l = document.querySelector('.cell-list'), s = document.getElementById('imPhase'); return l ? { fs: getComputedStyle(l).fontSize, w: l.getBoundingClientRect().width, sw: s.getBoundingClientRect().width, right: l.getBoundingClientRect().right, vw: innerWidth } : null; });+  check('the panel\'s pick lists open in its type size, as wide as the field, inside the window', !!pl && pl.fs === '18px' && pl.w >= pl.sw - 1 && pl.right <= pl.vw, JSON.stringify(pl));+  await esc3(page);++  section = 'editor: one look';+  await fsTo(page, 2);+  const look = () => page.evaluate(() => { const ed = document.getElementById('editor'); if (!ed.classList.contains('show')) return null; const fz = s => parseFloat(getComputedStyle(ed.querySelector(s)).fontSize);+    return { w: Math.round(ed.getBoundingClientRect().width), title: fz('.ed-title'), label: fz('.ed-f > label'), btn: fz('.de-actions .de-btn'), pad: getComputedStyle(ed.querySelector('.ed-body')).paddingLeft, primary: !!ed.querySelector('.ed-foot .de-actions .de-btn.primary') }; });+  const kinds = {};+  await clickBar(page, 'long'); kinds['task, chart'] = await look(); await esc3(page);+  await clickMilestone(page, '09002'); kinds['milestone, chart'] = await look(); await esc3(page);+  await view(page, 'table');+  await page.click('#tableView tr[data-key="t:long"] td.c-deps'); await settle(page, 200); kinds['task, Table'] = await look(); await esc3(page);+  await page.click('#tableView tr[data-key="m:09002"] td.c-deps'); await settle(page, 200); kinds['milestone, Table'] = await look(); await esc3(page);+  await view(page, 'gantt');+  await page.click('#btnNew'); await settle(page, 150); kinds['New'] = await look(); await esc3(page);+  // The first Ctrl+click opens the item editor on that row; Escape closes it and keeps the+  // selection, so the panel cannot take the second click on a 768px window at 120%.+  await clearSel(page); await clickBar(page, 'p0t0', ['Control']); await page.keyboard.press('Escape'); await settle(page, 120); await clickBar(page, 'p0t1', ['Control']); kinds['batch'] = await look();+  const ref = JSON.stringify(kinds['task, chart']);+  check('every panel that edits a task has one width, title, label and button size, padding and button row', Object.keys(kinds).length === 6 && Object.values(kinds).every(k => k && k.w > 0 && JSON.stringify(k) === ref), JSON.stringify(kinds));++  section = 'editor: buttons clear';+  // The selection chip is up while the batch panel is (two rows are selected). Dragged by its+  // title into the chip's corner, the panel stops above the band, and every button is on top.+  const dragTo = async (x, y) => { const hd = await page.$eval('#editor .ed-title', e => { const r = e.getBoundingClientRect(); return { x: r.left + 20, y: r.top + r.height / 2 }; });+    await page.mouse.move(hd.x, hd.y); await page.mouse.down(); await page.mouse.move(x, y, { steps: 6 }); await page.mouse.up(); await settle(page, 150); };+  await dragTo(10, 760);+  const near = await page.evaluate(() => { const ed = document.getElementById('editor'), e = ed.getBoundingClientRect(), c = document.getElementById('selCount'), cr = c.getBoundingClientRect();+    return { open: ed.classList.contains('show') && e.height > 0, chip: c.classList.contains('show'), apart: !(cr.left < e.right && e.left < cr.right && cr.top < e.bottom && e.top < cr.bottom), b: Math.round(e.bottom), vh: innerHeight }; });+  // The pointer is released below the panel, which stopped at the band: that release is the+  // end of a drag, not a click away, and the panel stays open (it closed until 0.79.0).+  check('dragged into the selection chip\'s corner, the panel stops above it and stays open', near.open && near.chip && near.apart && near.b <= near.vh - 60, JSON.stringify(near));+  check('and the chip covers none of its buttons', (await buttonsOnTop(page)).length === 0, (await buttonsOnTop(page)).join(', '));+  await page.type('#bTag', 'clearance'); await page.click('#bTagAdd'); await settle(page, 120);+  await dragTo(1360, 760);+  const ind = await page.evaluate(() => document.getElementById('saveInd').classList.contains('show') && document.getElementById('editor').classList.contains('show'));+  check('with the save indicator up in the other corner, no button is under it either', ind && (await buttonsOnTop(page)).length === 0, `${ind} ${(await buttonsOnTop(page)).join(', ')}`);+  await esc3(page); await undo(page);+  await page.close();++  section = 'editor: phone';+  page = await openPage(browser, X.url, { w: 390, h: 844, mobile: true });+  await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', JSON.stringify({ fs: 1.2 }))); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);+  await clickBar(page, 'long');+  g = await geo(page);+  const cols = await page.evaluate(() => { const b = document.querySelector('#editor .ed-body .ed-grid').getBoundingClientRect();+    return [...document.querySelectorAll('#editor .ed-f.half, #editor .ed-f.third, #editor .ed-f.twothirds')].filter(f => f.offsetParent).map(f => Math.round(f.getBoundingClientRect().width - b.width)); });+  check('on a 390px phone at 120% the panel takes the window less 8px a side', Math.abs(g.w - 374) < 1 && g.l >= 7.5 && g.rt <= 382.5, JSON.stringify({ w: g.w, l: g.l }));+  check('and its paired fields stack, one to a row, rather than squeezing a date', cols.length > 0 && cols.every(d => Math.abs(d) < 1), JSON.stringify(cols));+  check('nothing runs past its sides, its buttons are on top', g.out.length === 0 && (await buttonsOnTop(page)).length === 0, g.out.slice(0, 4).join(', '));+  await assertNoSidewaysScroll(page, 'phone with the editor open');+  await shot(page, 'editor-layout-390-120');+  await page.close();+}+ async function menuFlows(browser, S) {   section = 'export menu';   const page = await openPage(browser, S.url);@@ -2010,6 +2192,106 @@ async function menuFlows(browser, S) {   await ip.close(); } +// ClickUp (test/fixtures, synthetic files in ClickUp's real shapes): both menu entries and+// their choosers, a workspace export through the chooser, the plan the server then holds,+// the report card shown once after the reload, the API JSON with its dependencies drawn,+// and a file that is not a ClickUp export refused with the reason.+async function clickupFlow(browser) {+  section = 'clickup import';+  const FIX = join(HERE, 'fixtures');+  const I = await startServer({ project: { title: 'Before ClickUp', appName: 'clickup-target' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],+    items: [{ type: 'task', id: 'x', name: 'Before import', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] }, 'clickup');+  const ip = await openPage(browser, I.url);+  const pick = async (fmt, file) => {+    await ip.click('#btnImport'); await settle(ip, 100);+    const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click(`#importMenu .dd-item[data-fmt="${fmt}"]`)]);+    const accept = await ip.$eval('#importFile', e => e.accept);+    if (!file) { await ch.cancel(); return accept; }+    await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([join(FIX, file)])]);+    await settle(ip, 400);+    return accept;+  };+  const cardText = () => ip.evaluate(() => { const b = document.getElementById('importReport'); return b && window.__uiVis(b) ? b.innerText : ''; });+  const by = (p, name) => p.items.find(r => r.name === name) || {};++  await ip.click('#btnImport'); await settle(ip, 100);+  const menu = await ip.$$eval('#importMenu .dd-item', e => e.map(x => x.textContent.trim()));+  check('the Import menu offers ClickUp CSV and ClickUp API JSON', menu.includes('ClickUp CSV') && menu.includes('ClickUp API JSON'), menu.join(' | '));+  await ip.keyboard.press('Escape'); await settle(ip, 100);+  check('ClickUp API JSON opens a file chooser for .json', await pick('clickup-json') === '.json');++  // A workspace export, exported from GMT+5:30: a date with no time is 04:00 there, which is+  // the evening before in UTC, so a reader that took the UTC day would be a day early.+  check('ClickUp CSV opens a file chooser for .csv', await pick('clickup-csv', 'clickup-workspace.csv') === '.csv');+  let plan = await exportPlan(I.url);+  check('the workspace export replaces the plan: a section per List, in the order they came', JSON.stringify(plan.phases.map(p => p.name)) === '["Sensor Board · EVT Build","Sensor Board · Firmware","Launch"]', plan.phases.map(p => p.name).join(' | '));+  check('20 rows, the old plan gone; the undated task is a one-day bar on the page\'s today, tagged undated',+    plan.items.length === 20 && !by(plan, 'Before import').name && by(plan, 'Write the EVT report').start === TODAY && by(plan, 'Write the EVT report').end === TODAY+    && JSON.stringify(by(plan, 'Write the EVT report').tags) === '["undated"]', JSON.stringify([plan.items.length, by(plan, 'Write the EVT report')]));+  const evt = plan.items.filter(r => r.phase === 0).map(r => r.name);+  check('ClickUp\'s order, each subtask right after its parent (the file lists one before it)', JSON.stringify(evt.slice(2, 6)) === '["Lay out the board","Place components","Route power planes","Check return paths"]', evt.join(' | '));+  check('and each subtask names its parent row (the subtasks model)', by(plan, 'Place components').parent === by(plan, 'Lay out the board').uid && by(plan, 'Check return paths').parent === by(plan, 'Route power planes').uid && !by(plan, 'Lay out the board').parent);+  const lt = by(plan, 'Order long-lead parts');+  check('owner is the first assignee, the others are in the notes; status, effort and tags come across',+    lt.owner === 'Owen Price' && /Also assigned in ClickUp: Raj Patel\./.test(lt.description || '') && lt.status === 'in-progress' && lt.effort === 4 && JSON.stringify(lt.tags) === '["long-lead","purchasing"]', JSON.stringify(lt));+  check('a task with only a due date starts on it', lt.start === '2026-10-16' && lt.end === '2026-10-16', `${lt.start} ${lt.end}`);+  check('dates are the exporter\'s days, not UTC\'s', by(plan, 'Finalize schematic').start === '2026-10-05' && by(plan, 'Finalize schematic').end === '2026-10-09', JSON.stringify(by(plan, 'Finalize schematic')));+  check('a parent with no dates spans its subtasks', by(plan, 'Bring-up and test').start === '2026-11-16' && by(plan, 'Bring-up and test').end === '2026-11-20');+  check('a ClickUp milestone is a milestone on its due date', by(plan, 'EVT complete').type === 'milestone' && by(plan, 'EVT complete').date === '2026-11-20');+  check('each row links back to its ClickUp task', JSON.stringify(by(plan, 'Bootloader').provenance) === '{"label":"ClickUp 86b4p5h2p","url":"https://app.clickup.com/t/86b4p5h2p"}');+  let card = await cardText();+  check('after the reload the import report is on screen', !!card);+  check('it says what changed on the way in', /5 subtasks keep their parent task/.test(card) && /4 tasks have a due date and no start date/.test(card), card.slice(0, 300));+  check('it says what did not come across, priorities and dependencies included', /Priority on 6 tasks: urgent 1, high 3, normal 1, low 1/.test(card) && /CSV exports do not include them/.test(card), card);+  check('it shows how each ClickUp status was read, the guess marked', /spec\s*→\s*in progress \(1, a guess\)/.test(card) && /waiting on parts\s*→\s*blocked/.test(card), card);+  check('it counts the undated row, on the day it was placed', /1 undated/.test(card) && /1 task has no start or due date in ClickUp, so it is a one-day bar on 29-Sep-2026, tagged undated/.test(card), card.slice(0, 400));+  check('and says, quietly, that the importer has not been checked against a real ClickUp project', /Not yet checked against a real ClickUp project\. Compare this plan with ClickUp before relying on it\./.test(card), card.slice(0, 400));+  check('and the chart shows the imported rows', await ip.evaluate(() => [...document.querySelectorAll('.label-row')].some(e => e.textContent.includes('Route power planes'))));+  await shot(ip, 'clickup-report');+  await ip.click('#irClose'); await settle(ip, 100);+  check('the x closes the report', !(await ip.$eval('#importReport', b => window.__uiVis(b))));+  await ip.reload({ waitUntil: 'load' }); await ready(ip); await settle(ip, 300);+  check('a reload does not bring it back', !(await ip.$eval('#importReport', b => window.__uiVis(b))) && !(await cardText()));++  // The API's JSON: ClickUp's own order (orderindex), and the dependencies a CSV cannot carry.+  await pick('clickup-json', 'clickup-tasks.json');+  plan = await exportPlan(I.url);+  const bu = plan.items.filter(r => r.phase === 0).map(r => r.name);+  check('the API JSON lands in ClickUp\'s order, by orderindex, subtasks by theirs', bu[0] === 'Level and anchor the machine' && bu.indexOf('Teach fiducials') === bu.indexOf('Load the first program') + 1, bu.join(' | '));+  check('dependencies come across as deps, to and from a milestone too',+    JSON.stringify(by(plan, 'Install the feeder bank').deps) === '["cu-86c3a0002"]' && JSON.stringify(by(plan, 'Load the first program').deps) === '["cu-86c3a0001"]'+    && JSON.stringify(by(plan, 'First panel off the line').deps) === '["cu-86c3a0003"]' && JSON.stringify(by(plan, 'Run the 50-panel qualification').deps) === '["cu-86c3a0006"]', JSON.stringify(plan.items.map(r => [r.name, r.deps])));+  check('the chart draws them, into and out of the milestone', await ip.evaluate(() => ['cu-86c3a0002>cu-86c3a0001', 'cu-86c3a0001>cu-86c3a0003', 'cu-86c3a0003>cu-86c3a0006', 'cu-86c3a0006>cu-86c3a0007']+    .every(e => { const [f, t] = e.split('>'); return !!document.querySelector(`.dep-svg path[data-from="${f}"][data-to="${t}"]`); })));+  check('a date set in Sydney with no time keeps its Sydney day', by(plan, 'Supplier visit from Sydney').start === '2026-11-23' && by(plan, 'Supplier visit from Sydney').end === '2026-11-24');+  card = await cardText();+  check('its report names the link that had nowhere to go', /Dependencies on tasks that did not come across on 1 task/.test(card) && !/milestones on/.test(card) && !/CSV exports do not include them/.test(card), card);+  // A phone's width (the viewport only: switching to mobile emulation would reload the page,+  // and the report is shown once).+  await ip.setViewport({ width: 393, height: 852, deviceScaleFactor: 1 }); await settle(ip, 200);+  const fit = await ip.evaluate(() => { const b = document.getElementById('importReport').getBoundingClientRect(), body = document.querySelector('#importReport .ir-body');+    return { l: Math.round(b.left), r: Math.round(b.right), w: innerWidth, h: Math.round(b.height), vh: innerHeight, sw: body.scrollWidth, cw: body.clientWidth, x: document.getElementById('irClose').getBoundingClientRect().right }; });+  check('at 393px the report fits the screen, its x on it, nothing in it running sideways', fit.l >= 0 && fit.r <= fit.w && fit.h <= fit.vh && fit.sw <= fit.cw + 1 && fit.x <= fit.w, JSON.stringify(fit));+  await shot(ip, 'clickup-report-393');+  await ip.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); await settle(ip, 200);+  await ip.click('#irClose'); await settle(ip, 100);++  // The wrong file for the format: refused with the reason, and the plan is left alone.+  const before = JSON.stringify(await exportPlan(I.url));+  const dialogs = [];+  const onDialog = d => { dialogs.push(d.message()); d.accept().catch(() => {}); };+  ip.on('dialog', onDialog);+  const wrong = join(TMP, 'not-clickup.csv'); writeFileSync(wrong, 'name,start,end\nA,2026-10-05,2026-10-09\n');+  await ip.click('#btnImport'); await settle(ip, 100);+  const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="clickup-csv"]')]);+  await ch.accept([wrong]);+  for (let i = 0; i < 30 && !dialogs.length; i++) await sleep(100);+  ip.off('dialog', onDialog);+  check('a file that is not a ClickUp export is refused, saying what ClickUp CSV expects', dialogs.length === 1 && /does not look like a ClickUp export/.test(dialogs[0]), dialogs.join(' | '));+  check('and the plan is left as it was', JSON.stringify(await exportPlan(I.url)) === before);+  await ip.close();+}+ // The Setup lens (0.58.0). Until 0.57.1 this flow drove the header's Sections modal; the // same operations now live on a page, so it drives the page and asserts the effects // against the plan the SERVER saved.@@ -3576,6 +3858,205 @@ async function pickFlow(browser) {   }   await page.close(); }+// ── Subtasks (0.79.0, issue #5) ──+// A task holds subtasks: indented under it with a chevron and a count, folded per person, its+// subtasks' span under its bar when folded; Alt+Right and Alt+Left nest and take out; a drag+// onto a task's middle nests, a drag to a gap at the left takes out; the Table follows the+// tree in plan order and stands every row alone under a sort; a filter that matches only a+// subtask keeps its task, dimmed; deleting a task takes its subtasks, and one undo brings them+// back; Collapse all folds them too.+async function subtasksFlow(browser) {+  section = 'subtasks';+  const T = (uid, id, name, ph, extra) => ({ type: 'task', uid, id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'not-started', ...extra });+  const X = await startServer({ project: { title: 'Subtasks Test', appName: 'sub-test', view: { mode: 'gantt', grain: 'week', milestones: 'diamonds' } }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],+    items: [T('00001', 'b1', 'Frame', 0), T('00002', 'b2', 'Frame bolts', 0, { parent: 'b1', start: '2026-10-12', end: '2026-10-23' }), T('00003', 'b3', 'Wire', 0, { owner: 'Ana' }),+      T('00004', 'b4', 'Wire loom', 0, { parent: '00003', owner: 'Kai', effort: 6 }), T('00005', 'b5', 'Test', 0), T('00006', 's1', 'Pack', 1), { type: 'milestone', uid: '00007', name: 'Ship gate', date: '2026-10-20', phase: 1 }, T('00008', 's2', 'Send', 1)] }, 'subtasks');+  const page = await openPage(browser, X.url);+  const P = async () => (await savedPlan(X.url, { minWait: 900 })).items;+  const parentOf = async uid => ((await P()).find(r => r.uid === uid) || {}).parent || '';+  const lrow = id => `#labels .label-row[data-id="${id}"]`;+  const rowState = id => page.$eval(lrow(id), e => ({ sub: e.classList.contains('is-sub'), depth: e.style.getPropertyValue('--depth'), kids: e.classList.contains('has-kids'), folded: e.classList.contains('kids-folded'), hidden: e.classList.contains('row-collapsed'), ctx: e.classList.contains('ctx-dim'), count: (e.querySelector('.sub-count') || {}).textContent || '' }));+  let items = await P();+  check('a data file\'s parent named by task id is read as that task\'s uid', items.find(r => r.uid === '00002').parent === '00001' && items.find(r => r.uid === '00004').parent === '00003');+  // Subtasks start folded (0.79.0, ClickUp's default, Drew's choice), and each person opens their+  // own: what one opens stays open for them across a reload and stays folded for everybody else.+  const st0 = await rowState('b2'), fr0 = await rowState('b1');+  check('subtasks start folded: the task wears the chevron and its count, its subtasks are put away', st0.hidden && fr0.folded && fr0.kids && fr0.count === '1', JSON.stringify([st0, fr0]));+  await page.click(lrow('b1') + ' .sub-tog'); await settle(page, 200);+  await page.click(lrow('b3') + ' .sub-tog'); await settle(page, 200);+  await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);+  check('a task this person opened stays open for them after a reload', !(await rowState('b2')).hidden && !(await rowState('b4')).hidden);+  {+    const ctx2 = await browser.createBrowserContext();+    const other = await openPage(browser, X.url, { ctx: ctx2 });+    check('and stays folded for somebody else', await other.$eval(lrow('b1'), e => e.classList.contains('kids-folded')) && await other.$eval(lrow('b3'), e => e.classList.contains('kids-folded')));+    await other.close(); await ctx2.close();+  }+  let st = await rowState('b2'), fr = await rowState('b1');+  check('a subtask is indented under its task, and the task wears the chevron and its count', st.sub && st.depth === '1' && fr.kids && fr.count === '1', JSON.stringify([st, fr]));+  // Fold with the chevron: the subtask goes, the task's bar shows its subtasks' span.+  await page.click(lrow('b1') + ' .sub-tog'); await settle(page, 200);+  st = await rowState('b2');+  const span = await page.evaluate(() => { const e = document.querySelector('.label-row[data-id="b1"]') && [...document.querySelectorAll('.sub-span')].find(x => x.style.display !== 'none'); return e ? { title: e.dataset.title, dates: e.dataset.dates } : null; });+  check('the chevron folds the task\'s subtasks away, per person, and its bar shows their span', st.hidden && (await rowState('b1')).folded && span && /12-Oct-2026 . 23-Oct-2026/.test(span.dates), JSON.stringify([st, span]));+  check('folding is a view setting: nothing is saved', (await parentOf('00002')) === '00001' && !(await (await fetch(X.url + 'changelog')).json()).events.some(e => e.action === 'parent'));+  await page.click(lrow('b1') + ' .sub-tog'); await settle(page, 200);+  check('and unfolds them again', !(await rowState('b2')).hidden);+  // Alt+Right and Alt+Left on the selected task.+  await clickBar(page, 'b5'); await page.keyboard.press('Escape'); await settle(page, 150);+  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowRight'); await page.keyboard.up('Alt'); await settle(page, 300);+  check('Alt+Right nests a task under the task above it, as its last subtask', (await parentOf('00005')) === '00003' && (await P()).map(r => r.uid).join() === '00001,00002,00003,00004,00005,00006,00007,00008');+  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowLeft'); await page.keyboard.up('Alt'); await settle(page, 300);+  items = await P();+  check('Alt+Left takes it out again, to straight after its task', !items.find(r => r.uid === '00005').parent && items.map(r => r.uid).join() === '00001,00002,00003,00004,00005,00006,00007,00008', items.map(r => r.uid + '<' + (r.parent || '')).join(' '));+  await undo(page);+  check('Ctrl+Z puts it back under the task', (await parentOf('00005')) === '00003');+  await undo(page);+  check('and Ctrl+Z again takes the nest back', !(await parentOf('00005')));+  // Drag Send onto the middle of Pack: it becomes Pack's subtask; drag it out to the left: top level.+  await clearSel(page);+  const mid = async id => page.evaluate(i => { const e = document.querySelector(`#labels .label-row[data-id="${i}"] .name-text`); e.scrollIntoView({ block: 'center' }); const b = e.getBoundingClientRect(); return { x: b.left + Math.min(8, b.width / 2), y: b.top + b.height / 2, h: b.height }; }, id);+  let a = await mid('s2'), b = await mid('s1');+  await page.mouse.move(a.x, a.y); await page.mouse.down();+  for (let k = 1; k <= 8; k++) { await page.mouse.move(a.x, a.y + (b.y - a.y) * k / 8); await sleep(30); }+  const nestMark = await page.$eval(lrow('s1'), e => e.classList.contains('nest-target'));+  await page.mouse.up(); await settle(page, 300);+  check('dragging a task onto the middle of another outlines it, and the drop nests it there', nestMark && (await parentOf('00008')) === '00006');+  a = await mid('s2');+  const gate = await page.evaluate(() => { const e = [...document.querySelectorAll('#labels .label-row.ms')].find(x => x.textContent.includes('Ship gate')); const r = e.getBoundingClientRect(); return { y: r.bottom - 3 }; });+  // Straight down first: a sideways start is a date drag, as it always was.+  await page.mouse.move(a.x, a.y); await page.mouse.down(); await page.mouse.move(a.x, a.y + 12); await sleep(30);+  for (let k = 1; k <= 8; k++) { await page.mouse.move(a.x - 60 * k / 8, a.y + 12 + (gate.y - a.y - 12) * k / 8); await sleep(30); }+  const line = await page.evaluate(() => { const d = document.querySelector('#labels .drop-indicator'); return d && d.classList.contains('show') ? parseFloat(d.style.left || '0') : null; });+  await page.mouse.up(); await settle(page, 300);+  items = await P();+  check('dragging a subtask to a gap and to the left takes it out, at the level the line shows', line === 0 && !items.find(r => r.uid === '00008').parent && items.map(r => r.uid).indexOf('00008') === items.map(r => r.uid).indexOf('00007') + 1, JSON.stringify([line, items.map(r => r.uid + '<' + (r.parent || '')).join(' ')]));+  // The Table follows the tree in plan order (it opens sorted by deadline).+  await view(page, 'table');+  if (await page.$('#toPlanOrder')) { await page.click('#toPlanOrder'); await settle(page, 200); }+  const trow = id => `#tableBody tr[data-key="t:${id}"]`;+  const tstate = id => page.$eval(trow(id), tr => ({ depth: (tr.querySelector('.t-ind') || { style: { getPropertyValue: () => '' } }).style.getPropertyValue('--depth'), tog: !!tr.querySelector('button.sub-tog'), ctx: tr.classList.contains('ctx-dim'), eff: (tr.querySelector('td.c-effort') || {}).textContent || '' })).catch(() => null);+  let w = await tstate('b4'), wp = await tstate('b3');+  check('in the Table a subtask is indented under its task, which has the chevron and its subtasks\' hours', w && w.depth === '1' && wp.tog && /not est\. · 6h/.test(wp.eff), JSON.stringify([w, wp]));+  await page.click(trow('b3') + ' button.sub-tog'); await settle(page, 200);+  check('the Table\'s chevron folds them too, the same fold as the chart', !(await tstate('b4')) && (await page.evaluate(() => window.__collab.tree().find(r => r.uid === '00004').hidden)));+  await page.click(trow('b3') + ' button.sub-tog'); await settle(page, 200);+  await page.click('#tableView thead th[data-sort="name"]'); await settle(page, 200);+  check('under a sort every row stands alone, a subtask marked with its task', !!(await page.$(trow('b4') + ' .sub-of')) && !(await page.$(trow('b4') + ' .t-ind')));+  await page.click('#tableView thead th[data-sort="name"]'); await settle(page, 100); await page.click('#tableView thead th[data-sort="name"]'); await settle(page, 200);+  // A filter that matches only the subtask keeps its task, dimmed, in both lenses.+  await page.select('#ganttOwner', 'Kai'); await settle(page, 300);+  w = await tstate('b4'); wp = await tstate('b3');+  check('an owner filter that matches only a subtask keeps its task on screen, dimmed (Table)', w && wp && wp.ctx && !w.ctx, JSON.stringify([w, wp]));+  await view(page, 'gantt');+  check('and in the chart', (await rowState('b3')).ctx && !(await rowState('b3')).hidden && !(await rowState('b4')).ctx);+  await page.select('#ganttOwner', ''); await settle(page, 300);+  // Collapse all folds tasks with subtasks too; Expand all opens everything.+  await page.click('#btnCollapseAll'); await settle(page, 200);+  check('Collapse all folds every task with subtasks too', (await rowState('b1')).folded && (await rowState('b3')).folded);+  await page.click('#btnCollapseAll'); await settle(page, 200);+  check('and Expand all unfolds them', !(await rowState('b1')).folded && !(await rowState('b2')).hidden);+  // Deleting a task deletes its subtasks with it; one Ctrl+Z brings both back, nested.+  await view(page, 'table');+  await page.hover(trow('b3')); await settle(page, 150);+  await page.click(trow('b3') + ' .row-del'); await settle(page, 300);+  const toast = await page.$eval('#undoToast', e => e.textContent);+  items = await P();+  check('deleting a task takes its subtasks with it, and the toast says so', !items.some(r => r.uid === '00003' || r.uid === '00004') && /Wire and its 1 subtask/.test(toast), toast);+  await undo(page);+  items = await P();+  check('one Ctrl+Z brings the task and its subtask back, still nested', items.find(r => r.uid === '00004') && items.find(r => r.uid === '00004').parent === '00003');+  await view(page, 'gantt');+  const md = await (await fetch(X.url + 'changelog?format=md')).text();+  check('the changelog says who made what a subtask of what, and took it out', /made "Test" a subtask of "Wire"/.test(md) && /took "Test" out from under "Wire"/.test(md), md.split('\n').filter(l => /subtask|out from under/.test(l)).slice(0, 3).join(' | '));+  await page.close();+}+// ── Quick add (0.79.0, issue #6) ──+// The + on a section header opens a blank row at the end of the section; Enter adds the task+// and leaves the row open for the next; Esc, or Enter on an empty row, stops. The + on a task+// adds its subtasks the same way. New's remembered owner, status and tags and its default dates+// are the defaults. Each task is one undo entry and one changelog line. The Table has a+// + in a row's Section cell (no line under each section). Dependency mode is left first, as New leaves it.+async function quickAddFlow(browser) {+  section = 'quick add';+  const T = (id, name, ph, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'not-started', ...extra });+  const X = await startServer({ project: { title: 'Quick Add Test', appName: 'qa-test', view: { mode: 'gantt', grain: 'week', milestones: 'diamonds' } }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],+    items: [T('q1', 'Frame', 0), T('q2', 'Wire', 0), T('q3', 'Pack', 1), { type: 'milestone', name: 'Ship gate', date: '2026-10-20', phase: 1 }] }, 'quickadd');+  const page = await openPage(browser, X.url);+  const P = async () => (await savedPlan(X.url, { minWait: 900 })).items;+  // New remembers an owner, a status and a tag; quick add starts from them.+  await page.click('#btnNew'); await settle(page, 200);+  await page.type('#ncName', 'Seed row'); await setVal(page, '#ncOwner', 'Ana'); await setVal(page, '#ncStatus', 'in-progress');+  await page.click('#ncCreate'); await settle(page, 400);+  await page.click('#btnDeps'); await settle(page, 150);+  const hdr = await page.$$('#labels .label-row.group');+  await hdr[0].hover(); await settle(page, 150);+  const addVis = await page.$eval('#labels .label-row.group .lane-add', b => window.__uiVis(b));+  await page.click('#labels .label-row.group .lane-add'); await settle(page, 250);+  const open = await page.evaluate(() => ({ row: !!document.querySelector('#labels .qa-row'), focus: document.activeElement === document.querySelector('.qa-in'), dep: document.body.classList.contains('dep-mode') }));+  check('the + on a section header shows on hover, leaves Dependency mode and opens a blank row with the cursor in it', addVis && open.row && open.focus && !open.dep, JSON.stringify([addVis, open]));+  await page.keyboard.type('Quick one'); await page.keyboard.press('Enter'); await settle(page, 500);+  check('Enter adds the task and leaves the row open for the next one', await page.evaluate(() => !!document.querySelector('#labels .qa-row') && document.activeElement === document.querySelector('.qa-in') && document.querySelector('.qa-in').value === ''));+  await page.keyboard.type('Quick two'); await page.keyboard.press('Enter'); await settle(page, 400);+  await page.keyboard.press('Enter'); await settle(page, 200);+  check('Enter on an empty row stops', !(await page.$('#labels .qa-row')));+  let items = await P();+  const sec0 = items.filter(r => r.phase === 0).map(r => r.name);+  const q1 = items.find(r => r.name === 'Quick one');+  check('the tasks land at the end of that section, in the order typed', sec0.slice(-2).join('|') === 'Quick one|Quick two', sec0.join('|'));+  check('with New\'s remembered owner and status, and New\'s dates', q1 && q1.owner === 'Ana' && q1.status === 'in-progress' && q1.start === TODAY && q1.end === addDaysISO(TODAY, 7), JSON.stringify(q1));+  const log = (await (await fetch(X.url + 'changelog')).json()).events;+  check('each one is a changelog line of its own', log.filter(e => e.action === 'add' && /^Quick (one|two)$/.test(e.name || '') && e.via === 'quick-add').length === 2);+  await undo(page);+  items = await P();+  check('Ctrl+Z takes back the last one only', items.some(r => r.name === 'Quick one') && !items.some(r => r.name === 'Quick two'));+  // Esc stops, keeping nothing typed.+  const adds = await page.$$('#labels .label-row.group .lane-add');+  await hdr[1].hover(); await settle(page, 150); await adds[1].click(); await settle(page, 200);+  const opened = await page.evaluate(() => ({ row: !!document.querySelector('#labels .qa-row'), focus: document.activeElement === document.querySelector('.qa-in'), active: document.activeElement && (document.activeElement.className || document.activeElement.tagName) }));+  await page.keyboard.type('Never mind'); await page.keyboard.press('Escape'); await settle(page, 300);+  const left = await page.evaluate(() => ({ row: !!document.querySelector('#labels .qa-row'), val: (document.querySelector('.qa-in') || {}).value, editor: document.getElementById('editor').classList.contains('show') }));+  check('Esc stops without adding the half-typed task', opened.row && opened.focus && !left.row && !(await P()).some(r => r.name === 'Never mind'), JSON.stringify([opened, left]));+  // The + on a task adds its subtasks, after the ones it has.+  await page.hover('#labels .label-row[data-id="q1"]'); await settle(page, 150);+  await page.click('#labels .label-row[data-id="q1"] .row-add'); await settle(page, 250);+  check('the + on a task opens a blank row indented under it', await page.evaluate(() => { const q = document.querySelector('#labels .qa-row'); return !!q && q.style.getPropertyValue('--depth') === '1' && q.previousElementSibling && q.previousElementSibling.dataset.id === 'q1'; }));+  await page.keyboard.type('Sub one'); await page.keyboard.press('Enter'); await settle(page, 450);+  await page.keyboard.type('Sub two'); await page.keyboard.press('Enter'); await settle(page, 450);+  await page.keyboard.press('Escape'); await settle(page, 200);+  items = await P();+  const kids = items.filter(r => r.parent === items.find(x => x.id === 'q1').uid).map(r => r.name);+  check('Enter adds subtasks of that task, in the order typed, right under it', kids.join('|') === 'Sub one|Sub two' && items.map(r => r.name).indexOf('Sub one') === items.map(r => r.name).indexOf('Frame') + 1, JSON.stringify([kids, items.map(r => r.name)]));+  // The Table opens sorted: the + in a row's Section cell adds to that section, the blank row+  // under that row.+  await view(page, 'table');+  const sorted = !!(await page.$('#toPlanOrder'));+  await page.hover('#tableBody tr[data-key="t:q2"]'); await settle(page, 150);+  await page.click('#tableBody tr[data-key="t:q2"] .sec-add'); await settle(page, 250);+  const under = await page.evaluate(() => { const e = document.querySelector('#tableBody tr.qa-entry'); return !!e && e.previousElementSibling && e.previousElementSibling.dataset.key === 't:q2' && document.activeElement === e.querySelector('.qa-in'); });+  await page.keyboard.type('Sorted one'); await page.keyboard.press('Enter'); await settle(page, 450); await page.keyboard.press('Escape'); await settle(page, 250);+  check('in the sorted Table, the + in a row\'s Section cell opens a blank row under it and adds to that section', sorted && under && ((await P()).find(r => r.name === 'Sorted one') || {}).phase === 0);+  // In plan order too, quick add is the + on hover (0.79.0, Drew's default): no "+ Add task"+  // line under every section; the + in a row's Section cell adds to the end of that section.+  await page.click('#toPlanOrder'); await settle(page, 200);+  check('the Table has no "+ Add task" line under its sections', (await page.$$('#tableBody tr.qa-line')).length === 0);+  const key1 = await page.evaluate(() => { const r = window.__collab.rows().filter(x => x.phase === 1 && x.id); return r.length ? 't:' + r[0].id : null; });+  await page.hover(`#tableBody tr[data-key="${key1}"]`); await settle(page, 150);+  await page.click(`#tableBody tr[data-key="${key1}"] .sec-add`); await settle(page, 250);+  check('the + in a row\'s Section cell opens a blank row, the cursor in it', await page.evaluate(() => !!document.querySelector('#tableBody tr.qa-entry .qa-in') && document.activeElement === document.querySelector('#tableBody .qa-in')));+  await page.keyboard.type('Table one'); await page.keyboard.press('Enter'); await settle(page, 500);+  check('Enter adds it and keeps the row open', await page.evaluate(() => !!document.querySelector('#tableBody tr.qa-entry .qa-in') && document.activeElement === document.querySelector('#tableBody .qa-in')));+  await page.keyboard.press('Escape'); await settle(page, 250);+  items = await P();+  check('the Table\'s task lands at the end of its section', items.filter(r => r.phase === 1).map(r => r.name).slice(-1)[0] === 'Table one', items.filter(r => r.phase === 1).map(r => r.name).join('|'));+  await page.hover('#tableBody tr[data-key="t:q3"]'); await settle(page, 150);+  await page.click('#tableBody tr[data-key="t:q3"] .row-add'); await settle(page, 250);+  await page.keyboard.type('Table sub'); await page.keyboard.press('Enter'); await settle(page, 450); await page.keyboard.press('Escape'); await settle(page, 250);+  items = await P();+  check('the Table\'s + on a task adds a subtask of it', (items.find(r => r.name === 'Table sub') || {}).parent === items.find(r => r.id === 'q3').uid);+  await view(page, 'gantt');+  await page.close();+} // 0.71.0: a LINKED provenance is fixed (a link, never a text field, in the table and the // item editor), a plain one is still edited; and the table's count says what it hides. async function provFlow(browser) {
uninstall.sh