app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
Publish 0.80.0
15 files changed
+1737−37
README.md+45−2SKILL.md+75−2client-vs-server/README.md+7−3client-vs-server/where-it-lives.html+3−1client-vs-server/where-it-lives.pngdocs/BUILD-NOTES.md+112gantt.html+487−15install.shpackage.json+1−1page.json+1−1server.js+540−10test/collab_test.mjs+89−2test/server_test.mjs+179test/ui_test.mjs+198uninstall.shREADME.md+45−2@@ -494,6 +494,47 @@ that carries them: while your saves are failing, your lines queue in the tab rat posting, and they go out in order behind the first save that lands, or are dropped along with the edits if you choose to discard them. +### Asking someone to take a task (0.80.0)++The point is to keep people informed: work does not land on somebody's plate without them+knowing. Set a row's owner to another person (in the Table, the owner pill, the item editor,+the New panel, a paste) and the row keeps its owner while a dashed pill after it says who was+asked: **→ Ana?**. Ana gets a toast and a count on the **Requests** button (the tray by your+name in the header). Its panel lists:++- **For you**: what people have asked you to take, each with an optional note and+ **Accept** or **Decline**. Accept makes the row yours; Decline leaves it as it was.+- **Changes to your tasks**: somebody moved the dates of tasks you own. One notice per person+ per save (a burst of edits in ten minutes is one notice), with the dates each task had.+- **You asked**: your requests, pending, accepted or declined, by whom and with their note,+ and **Withdraw** while one is pending.+- **Waiting on others**, for the plan's owner.+- **Let my AI answer requests for me**: an AI with the plan's agent key may then accept or+ decline in your name, and the history says it was your AI.++The item editor shows a request on its row, with the same buttons. Ctrl+Z takes back your own+step: a request you made is withdrawn, an answer you gave is taken back and the request is+pending again. Every step is in the changelog and the Chat digest, written by the server+("Drew Owens asked Ana to take "Etch the cards""; "Drew Owens moved the dates of 3 of Ana's+tasks, and Ana was told").++What is NOT a request: taking a task yourself, clearing an owner, putting an owner back that+you just changed, a name with no Adom account, and another person's task's **dates**+(a notice instead), **section** or **status**.++**Who is who: People, on Setup.** Owners are names. A name is a person when it matches the+display name, username or first name of somebody who has signed in to the plan, or when the+plan's owner has linked it to their account in the People card. The card lists every owner+name, how many rows carry it and its account; it warns when two people share a first name+(both are asked until one is picked). A name nobody answers to is assigned directly and gets+a dotted underline in the chart and the Table, so it gets linked.++**The server enforces it**, for every tab, script and AI: a field-level save, a whole-plan+`/save` and the agent token all turn an owner change onto a person into a request. The agent+token answers only for a person who allowed it, naming them. A tab from before 0.80.0 sees its+owner change bounce back (reload it). Only the plan's owner can turn requests off (`POST+/access {"requests":"off"}`); they are always off under `--no-auth`, where nobody signs in.+ ## Your view and the shared plan: what lives where <table>@@ -574,12 +615,14 @@ loopback are open. `/health` is public and leaks nothing but counts. | Endpoint | Method | Description | |----------|--------|-------------| | `/health` | GET | Public. Rev, event count, and the changelog's `integrity` object |-| `/access` | GET or POST | Who can open the plan: `{mode: any|org|me, org, owner}`. The owner or the agent token changes it; the owner is named with the agent token |+| `/access` | GET or POST | Who can open the plan: `{mode: any|org|me, org, owner, requests: on|off}`. The owner or the agent token changes it; the owner is named with the agent token |+| `/requests` | GET | The requests (0.80.0), each with its row's name and owner now, the notices of moved dates, the accounts and who lets their AI answer. `?state=pending`, `?to=<name>`, `?uid=`, `?id=`. A signed-in caller also gets `mine: {for, asked, notices}`. Made, answered and withdrawn through `/ops` |+| `/people` | GET | Every owner name on the plan and the account it resolves to (`linked`, `match`, `first`, `shared`, `none`, `off`), with counts; `?name=` for one name | | `/notify` | GET or POST | Google Chat digests: status (never the webhook), cadence, test message; the owner or the agent token sets the webhook | | `/live` | GET | Server-sent events: revisions, presence, and `denied` when the plan stops being shared with you | | `/live/ping` | POST | `?cid=<tab>`: the tab is still open. A stream not heard from in 150 s is dropped from presence | | `/data` | GET | Current project data |-| `/state` | GET | Saved edits (sidecar) |+| `/state` | GET | Saved edits (sidecar), with `requests` and `people` | | `/save` | POST | Persist edits | | `/uid` | POST | Allocate the next permanent item id. `{"count":n}` for a batch, up to 500 | | `/uid/backfill` | POST | Give a uid to any row that has none. Runs at startup too, so this is only for a data file edited under a running server |
SKILL.md+75−2@@ -782,7 +782,7 @@ Each event records the action, the item, the before and after dates, the day shi Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rename`, `note`, `owner`, `status`, `desc`, `effort`, `tags`, `phase.tags`, `tag.create`, `tag.rename`, `tag.color`, `tag.delete`, `dep.add`, `dep.remove` (on a milestone too since 0.78.0, with-`ms: true`), `reorder`, `plan.dates` (0.75.0) and `plan.calendar` (0.76.0, `from`/`to` each+`ms: true`), `reorder`, the server's `request.*` lines and `requests.mode` (0.80.0, see below), `plan.dates` (0.75.0) and `plan.calendar` (0.76.0, `from`/`to` each `{ workHolidays, extraDaysOff }`). The server writes `milestone.ids` (0.78.0, with a `count`) once, the first time it starts on a plan whose milestones have no ids, and keeps it out of the Chat digest like its other bookkeeping.@@ -888,7 +888,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`, and since 0.79.0 `parent` and `pos`, which go-together, see Subtasks). The answer carries `stateRev`, the ops as stored,+together, see Subtasks). An `owner` onto another person becomes a request: see+"Assigning work to a person is a request" below. 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. @@ -926,6 +927,74 @@ 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. +## Assigning work to a person is a request (0.80.0)++The point is to keep people informed: **work is not put on a person's plate without them+knowing.** On a plan with sign-in, an owner change onto another PERSON (a name that resolves+to an Adom account), from `/ops`, a whole-state `/save` or a new row's `owner`, is not applied:+the server keeps the row's owner (a new row is made unassigned) and makes a pending request+that person accepts or declines. A name with no account behind it is assigned directly.+Moving the dates of somebody else's task lands at once and sends its owner a notice. Section+and status changes are plain edits.++**Who a name is.** A link the plan's owner made on Setup (People), or one an accept made,+decides. Otherwise: the accounts of people who have signed in to this plan whose display name,+username or email it is, else whose first name it is. Two people sharing a first name are both+asked until the owner links it. `GET /people` lists every owner name and how it resolves+(`linked`, `match`, `first`, `shared`, `none`, `off`) with a count of names not linked;+`GET /people?name=Ana` resolves one.++```bash+# ask (the plain owner field does the same; `note` is shown to them)+curl -s -X POST localhost:8901/ops -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \+ -d '{"ops":[{"op":"req","uid":"00120","to":"Ana","note":"Drew asked me to hand this to you"}]}'+# -> {"ok":true, "requested":[{"id":"rq-...","uid":"00120","to":"Ana","state":"pending"}], ...}++# read the status: pending, accepted, declined, withdrawn or closed (with `why`)+curl -s "localhost:8901/requests?id=rq-..." # also ?state=pending, ?to=Ana, ?uid=00120++# withdraw one you made+curl -s -X POST localhost:8901/ops -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \+ -d '{"ops":[{"op":"req.withdraw","id":"rq-..."}]}'++# answer FOR a person, only if they turned on "Let my AI answer requests for me" (Requests panel)+curl -s -X POST localhost:8901/ops -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \+ -d '{"ops":[{"op":"req.answer","id":"rq-...","accept":true,"for":"drew2","note":"Drew said yes in Chat"}]}'+```++The rules for an AI:++* **Read `requested` in every `/ops` and `/save` answer.** A row whose owner you set is NOT+ theirs until they accept. Say "I asked Ana to take X; it is hers when she accepts", never+ "assigned". An empty `requested` for an owner change means the name has no account (or the+ feature is off) and the owner was set directly.+* **Answering for a person needs their permission and their name.** `req.answer` (and+ `req.reopen` to take your answer back) with the agent token must carry `for`: the person's+ username (or exact display name), and is refused unless that person has turned on "Let my+ AI answer requests for me" in the app. Only answer when they told you to, in words, this+ time. The history then reads "Drew Owens's AI accepted "X" for them". You cannot turn the+ permission on yourself (`ai.answers` is refused from the agent token).+* **Respect a decline.** Read `reply` for their reason. Do not ask the same person for the+ same row again unless a human asks you to; the server refuses it from the agent token for+ one day ("Ana declined it on 2026-11-05 17:41 UTC ...; ask a person before asking again").+* **Moving dates is not a request**, but it is news to the owner: one notice per batch per+ person (merged for ten minutes), so write a re-plan in one batch, not a dozen. The answer+ carries `{op:"notice"}` ops, and the changelog gets one `notice.dates` line per person.+* **One pending request per row.** Asking someone else replaces it (`closed`, `why: replaced+ by a newer request`); a direct owner change by a person, or deleting the row, closes it.+* **The history is the server's.** It writes `request`, `request.accept`,+ `request.decline`, `request.withdraw`, `request.reopen`, `request.close`, `notice.dates`,+ `people.link` and `ai.answers` lines itself (`reqId`, `to.owner`, `requester`, `note`,+ `reply`, `why`, `onBehalf`); do not post your own `owner` line for a change that became a+ request (the server drops it).+* **The agent token is never "you".** Even clearing and re-setting an owner goes through a+ request, unless you are putting back a value you changed a moment ago.+* **Linking names is the plan owner's** (`{op:"people.link","name":"Bob","account":"u:123"}`,+ `"account":"none"` for "no Adom account", `null` to unlink). The agent token may do it, but+ only when the owner asks.+* `GET /access` says `requests: "on"` or `"off"`; only the plan's owner can turn it off. When+ off (or on a server run with `--no-auth`) an owner change applies directly as before.+ ## Importing from ClickUp When someone wants a ClickUp project in a plan, use the ClickUp imports; do not translate@@ -1056,6 +1125,10 @@ The sidecar is at `v: 4` and everything in it is keyed by `uid`: | 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) | | tags | the tag registry, `{name, color?}[]`, the same shape as `project.tags` (0.61.0). Written once the registry has been touched in the app. Absent: the data file's registry stands. `[]`: it was emptied, and the merged plan carries no `project.tags` |+| requests | the requests (0.80.0): `{id, uid, to, was, from:{id,name}, at, state, by?, done?, note?, reply?, why?, created?}`; `by.for` names the person an AI answered for. Written by the server only: a `/save` cannot change them. Pending ones are kept until answered, answered ones for 30 days |+| notices | moved dates told to a task's owner (0.80.0): `{id, kind:'dates', to, ids, from, at, upd, count, rows:[{uid, name, was, now}]}`. Server only, kept 14 days |+| people | owner names linked to accounts, by the plan's owner on Setup or by an accept: `{"ana": {id, name, username, as}}`, or `{none: true}` for "no Adom account". Server only |+| delegates | people who let their AI answer requests for them: `{"u:123": {on, name, at}}`. Server only | An absent field means the sidecar has nothing to say and the data file's value stands; an empty one means the user cleared it. `tags` follows the same rule: no key
client-vs-server/README.md+7−3@@ -14,9 +14,10 @@ Pressing **Collapse all**, folding a section or a task's subtasks, filtering to only your screen. Your teammates keep whatever they had. -Dragging a bar, changing an owner or a status, renaming a section, pressing-**Auto-schedule**, or adding and deleting rows changes the plan, so it changes on every-screen.+Dragging a bar, changing a status, renaming a section, pressing **Auto-schedule**, or+adding and deleting rows changes the plan, so it changes on every screen. Setting somebody+else as a row's owner asks them first (0.80.0): the request is on every screen at once, and+the owner changes when they accept. ## Only this tab @@ -44,6 +45,7 @@ tab of the same plan in the same browser shares them; another computer starts fr | Critical path highlight | Only the highlight. Auto-schedule, which moves dates, is a plan change | | Name column width; table column widths and sort | | | Which legend chips are folded away | The "18 tags" and "50 sections" chips |+| Which answers and notices you have seen | Only what the count on the Requests button leaves out; the requests and notices themselves are the plan's (0.80.0) | A browser that has never opened the plan starts from the plan's own defaults: the `project.view` block in the data file can set the lens, grain, milestone style, theme@@ -63,6 +65,8 @@ Saved by the adom-gantt server and shared with everyone the plan is shared with. | Backups | Daily, and before every import or compaction; the last 30 are kept | | Project dates | Owner only; the dashed lines and the header's clock follow them | | Working calendar | Owner only; which holidays the plan works and its extra days off. Every working-day count follows it |+| Requests (0.80.0) | Who asked whom to take which task, the answers and their notes, the notices of moved dates, who lets their AI answer for them. Written by the server only; owner-only switch to turn them off |+| People (0.80.0) | Which Adom account each owner name belongs to (Setup, owner only), and the accounts of everyone who has signed in to the plan | | Sharing (only me, the org, anyone signed in) | Owner only | | Google Chat digest | Owner only | | Sign-in sessions and the agent key | Never shown in the page |
client-vs-server/where-it-lives.html+3−1@@ -85,6 +85,7 @@ <li>Critical path highlight</li> <li>Name column width; table columns and sort</li> <li>Which legend chips are folded away</li>+ <li>Which answers and notices in Requests you have seen</li> </ul> <p class="foot">Every tab of this plan in this browser shares these. Another computer or browser starts from the plan's defaults.</p> </div>@@ -104,6 +105,7 @@ <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>+ <li><b>Requests:</b> who asked whom to take a task, the answers, the notices of moved dates; <b>People</b>: the account behind each owner name</li> <li>The changelog (who changed what), daily backups</li> <li>Sharing and the Google Chat digest (owner only)</li> </ul>@@ -113,7 +115,7 @@ <div class="kicker">Live, never saved</div> <p>Who has the plan open <span class="chip" style="background:#1fc7b6">DO</span><span class="chip" style="background:#e3b341">AB</span>, and which rows each person has selected, the coloured rows with initials. The server passes it on and forgets it when they close the tab.</p> </div>- <div class="so"><b>So:</b> when you press Collapse all, filter to Unassigned, or switch to the table, only your screen changes. When you drag a bar, change an owner or press Auto-schedule, everyone's does.</div>+ <div class="so"><b>So:</b> when you press Collapse all, filter to Unassigned, or switch to the table, only your screen changes. When you drag a bar, change a status or press Auto-schedule, everyone's does. Setting someone else as the owner asks them first, and moving their dates tells them.</div> </div> </body> </html>
client-vs-server/where-it-lives.png⋯ 1 unchanged line ⋯
docs/BUILD-NOTES.md+112@@ -3,6 +3,118 @@ 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.80.0, requests: adding and moving tasks to other people)++Drew: "we need to add a request flow for adding/moving tasks to other people, which both+humans and AI should honor." Built on branch `requests` for the release after 0.79.0+(subtasks). The design, with the open questions, is `.gantt-gate/DESIGN-requests.md`.++**The pattern, from the tools that have one.** Asana (approvals: Approve, Request changes,+Reject, with a comment), Linear (triage: Accept or Decline, each with an optional comment;+Decline cancels), Jira (approvals: Approve or Decline, comment optional or required). ClickUp+and Monday have no native accept; they build it from assigned comments or a status column.+None of the five gates plain assignment; all share one request shape: a pending object on the+item, an inbox with a count, Accept or Decline with an optional note, the requester sees the+answer, and a pending one can be withdrawn. That is what this is, applied to the owner.++**What is a request.** An owner change onto another person with an Adom account, on an existing+row or a new one (a new row is made unassigned and asked). Direct, as before: taking a task yourself, clearing+an owner, putting back an owner you changed a moment ago (an undo, and an old tab's Ctrl+Z),+and the dates, section and status of anybody's task. Gating dates would make Auto-schedule,+which moves dozens of people's dates in one press, unusable; none of the five tools gates+dates, sections or status; the changelog, the digest and the same-field notice already say who+moved what.++**Who is who.** Owners are free text. A name is a person when it matches their display name,+username, email, its local part or their first name. When somebody accepts under a name, the+plan links it to their account (`people` in the sidecar) and only they match it from then on.+The agent token is never anybody.++**Enforced on the server.** `reqEngine` in server.js, used by `applyOpsTo` (every tab, every+version, the agent token) and by `saveGuardOwners` for a whole-state `/save` (the old owner is+stored, a request is made, and `requests`/`people` in a `/save` body are ignored). Requests+live in the sidecar, so they ride the revisions, the announcements (`req`, `people` ops, after+the rows) and every catch-up; old tabs ignore ops they do not know. A 0.78.x tab's owner change+comes back without the owner and its own settle step puts the old owner back on its screen;+the `owner` changelog line it then posts to `/log` is dropped (`replacedOwnerLine`), so the+history never claims a reassignment that did not happen. The server writes the request lines+itself (`request`, `request.accept`, `.decline`, `.withdraw`, `.reopen`, `.close`, and+`requests.mode`), stamped with the session or the agent, so the chain stays whole and the Chat+digest needed no change. Only the person asked or the plan's owner answers; never the agent+token. The agent token may not re-ask a name that declined the same row within 14 days.+`GET /requests` lists them; `POST /access {requests:"off"}` is the owner's switch (always off+under `--no-auth`).++**The page.** It asks first rather than waiting for the server to bounce the field: the+Table's owner cell, the owner pill, the item editor (in the Apply's one undo) and the New+panel. A paste and anything else goes through the server's rule and settles back. A dashed+pill after the owner, "→ Ana?", in the chart and the Table (the accent for the person asked);+a Requests tray button with a count by the signed-in chip; its panel with For you, You asked+and, for the plan's owner, Waiting on others; a line in the item editor with Accept, Decline or+Withdraw. Ctrl+Z withdraws a request you made, and takes back an answer you gave (the request+is pending again; an accept's owner goes back with it); redo does it again. Which answers you+have seen is per browser.++**Drew's decisions (2026-11-05), built.** "The point is NOT to force anyone to use the app's+UI; it is to keep people informed when new tasks are assigned to them."++1. *Taking somebody's task yourself* and 2. *clearing an owner*: direct, as built.+3. *A name with no Adom account is assigned directly*, and Drew expects every name to be+ linked. The server keeps the accounts of everyone who signs in to the plan+ (`<plan>-accounts.json`, seeded from the sessions file at startup), a name resolves to one+ by a link, by display name or username, or by first name (`resolveName`), and only a name+ that resolves is asked. Setup has a **People** card: every owner name, its row count and+ its account, which the plan's owner picks from a select (`people.link`); a name nobody+ answers to gets a dotted underline on its owner pill and Table cell. `GET /people` lists+ them and counts the unlinked. On a copy of the live plan taken at 12:27 today, using the+ people who have edited it as the accounts, 7 of its 14 owner names do not resolve: John,+ Anastasia, Adrian, Ray, Kale (Kaley Mer's first name is Kaley, so "Kale" needs a link),+ Kyle and Aditya, 178 of 566 rows between them. The server will know only the people who+ sign in after the deploy, so the number on day one is higher until they do.+4. *An AI may answer for a person with their permission*: "Let my AI answer requests for me"+ in the Requests panel (`ai.answers`, stored as `delegates`, only for oneself, never from+ the agent key). An agent-key answer must say `for` whom (username or exact name), and is+ refused unless that person opted in. The request records `by.for`; the changelog reads+ "Ana Lopez's AI accepted "Ship" for them".+5. *Only the plan's owner turns requests off*, as built.+6. *First-name matching*, with a warning: two accounts sharing a first name resolve to+ `shared`; both are asked (either may answer) and the People card says so until the owner+ picks one.+7. *Moving the dates of somebody else's task notifies its owner*, as a notice in the same+ inbox ("Changes to your tasks") and badge, with the dates each row had. One notice per+ person per batch, merged into that person's last notice from the same hand for ten+ minutes, so a drag-and-adjust is one notice and an Auto-schedule press is one per owner+ ("moved the dates of 12 of Ana's tasks"). One `notice.dates` changelog line per person per+ batch, which the Chat digest posts. A whole-state `/save` makes them too.+8. *The agent's lockout after a decline is one day.*++**Full gates, on this branch with the published 0.79.0 (ee6e0f7) merged, under the suite+lock:** server 396/0, collab 136/0, fuzz seed 11 5/0, fuzz seed 101 5/0, ui 1116/0 (the+standing 7 controls untested). The `requests` UI flow is 41 checks and the server's Requests block 58.++**Found on the way.** A changelog event with a key holding `undefined` broke the hash chain:+`stableStringify` hashes the key, `JSON.stringify` drops it on disk. The request lines go+through JSON before they are appended. The page's "Asked Ana..." toast was overwritten by+"saved" a moment later; it is said again when the batch is answered.++**Branches in flight.** Subtasks: requests key on uid, and a subtree deleted by its parent+closes their requests through the same `del` op. editor-compact: the editor's request line is+one `#imReq` div placed under owner and status; after that branch lands it belongs in+`edShell`'s body as an `.ed-note`, a one-line move at the merge. clickup-import: imports+replace the plan and are never requests.++**Tests.** server_test: a `Requests` block (58 checks: ops, the agent token, an old-style+`/save`, the dropped `/log` line, undo by the person who changed it, replacement, closing, the+off switch, the changelog in words, the chain) and the sharing block turns requests off, since+it uses owners as plain fields. collab_test: a `requests` scenario (three people: a request+from the Table, accept in the inbox, an owner change straight into the model as an old tab+makes it, a decline with a note, two people asking at once, every tab converging on the+server's requests); every other scenario starts with requests off. ui_test: a `requests` flow+on its own server with sign-in (41 checks, two people), and manifest entries for the button,+the pill, the panel's controls and the editor's buttons (`required: false`: the coverage gate+runs `--no-auth`, where they cannot render). Screenshots dark and light, read: the inbox, the+answer, the editor line, the Table, 393px. The what-lives-where tables and diagram name the+requests (server) and the answers you have seen (this browser); the PNG is re-rendered. ## 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
gantt.html+487−15@@ -475,6 +475,63 @@ body.col-resizing { cursor: col-resize; user-select: none; } /* "NO OWNER" (0.62.0): dim capitals where the name would be, since a row nobody owns is the state most worth seeing, and the chip is the place to click to fix it. */ .label-row .owner-pill.none { font-style: normal; opacity: 0.55; letter-spacing: 0.04em; font-size: 0.86em; }+/* ── Requests (0.80.0): an owner change onto somebody else is a request they answer ──+ The pending pill sits right after the owner pill and is the same mono chip, dashed, so it+ reads as "not yet": "-> Ana?". The person asked sees theirs in the accent, as something to+ answer. The inbox is a tray button by the signed-in chip, with a count. */+.tag.req-pill { cursor: pointer; background: transparent; color: var(--text2); border: 1px dashed var(--text2); padding: 0 5px; max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.tag.req-pill.mine { color: var(--accent); border-color: var(--accent); }+.tag.req-pill:hover { filter: brightness(1.15); }+#tableView td.c-owner .req-pill { margin-left: 6px; }+.req-btn { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 28px; padding: 0; border-radius: 7px; border: 1px solid transparent; background: none; color: var(--text2); cursor: pointer; flex-shrink: 0; }+.req-btn:hover { border-color: var(--border); color: var(--text); }+.req-btn svg { width: 17px; height: 17px; }+.req-btn .req-n { position: absolute; top: -3px; right: -4px; min-width: 15px; height: 15px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--accent); color: var(--bg); font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; line-height: 15px; text-align: center; }+.req-btn .req-n:empty { display: none; }+.req-panel { position: fixed; z-index: 99998; width: min(380px, calc(100vw - 16px)); max-height: min(70vh, 560px); overflow: auto; background: var(--elevated); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.45); display: none; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); }+.req-panel.show { display: block; }+.req-panel h3 { margin: 0; padding: 10px 14px 6px; font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: var(--text2); text-transform: uppercase; }+.req-panel .rq-head { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 14px 4px; border-bottom: 1px solid var(--border-muted); }+.req-panel .rq-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 15px; font-weight: 600; color: var(--text); }+.req-panel .rq-off { padding: 10px 14px; color: var(--text2); }+.req-panel .rq-item { padding: 8px 14px 10px; border-top: 1px solid var(--border-muted); }+.req-panel .rq-item:first-of-type { border-top: 0; }+.req-panel .rq-row { color: var(--text); font-weight: 600; cursor: pointer; text-decoration: none; }+.req-panel .rq-row:hover { color: var(--accent); }+.req-panel .rq-meta { color: var(--text2); margin-top: 2px; }+.req-panel .rq-note { margin-top: 4px; color: var(--text-body); font-style: italic; }+.req-panel .rq-state { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; border: 1px solid currentColor; }+.req-panel .rq-state.pending { color: var(--text2); border-style: dashed; }+.req-panel .rq-state.accepted { color: var(--ok); }+.req-panel .rq-state.declined { color: var(--alarm); }+.req-panel .rq-state.withdrawn, .req-panel .rq-state.closed { color: var(--text2); }+.req-panel .rq-act { display: flex; gap: 6px; margin-top: 7px; align-items: center; }+.req-panel .rq-act input { flex: 1; min-width: 0; height: 26px; box-sizing: border-box; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; }+.req-panel .rq-act input:focus { outline: none; border-color: var(--accent); }+.req-panel .rq-b { height: 26px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--overlay); color: var(--text); font: inherit; font-weight: 600; cursor: pointer; white-space: nowrap; }+.req-panel .rq-b:hover { border-color: var(--text2); }+.req-panel .rq-b.yes { background: var(--accent); border-color: var(--accent); color: var(--bg); }+.req-panel .rq-b.yes:hover { filter: brightness(1.1); }+.req-panel .rq-none { padding: 4px 14px 10px; color: var(--text2); }+.im-req { margin-top: 6px; padding: 6px 8px; border: 1px dashed var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11.5px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }+.im-req.mine { border-color: var(--accent); color: var(--text-body); }+.im-req .rq-b { height: 22px; padding: 0 8px; border-radius: 5px; border: 1px solid var(--border); background: var(--overlay); color: var(--text); font: inherit; font-weight: 600; cursor: pointer; }+.im-req .rq-b.yes { background: var(--accent); border-color: var(--accent); color: var(--bg); }+/* Names not linked to an Adom account (0.80.0): a dotted underline, quiet, so they get linked. */+.label-row .owner-pill.unlinked, #tableView td.c-owner.unlinked { text-decoration: underline dotted; text-underline-offset: 2px; }+.req-panel .rq-ai { display: block; padding: 10px 14px 12px; border-top: 1px solid var(--border-muted); color: var(--text-body); cursor: pointer; }+.req-panel .rq-ai input { margin: 0 6px 0 0; vertical-align: -2px; }+.req-panel .rq-ai .rq-meta { display: block; margin: 2px 0 0 20px; }+.req-panel .rq-mv { margin-top: 3px; color: var(--text2); }+.req-panel .rq-mv .rq-row { font-weight: 500; }+.req-panel .rq-was { text-decoration: line-through; opacity: 0.8; }+.pp-list { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.3fr); gap: 6px 12px; align-items: center; margin-top: 8px; }+.pp-list .pp-h { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }+.pp-list .pp-nm { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.pp-list .pp-n { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); text-align: right; }+.pp-list select { min-width: 0; width: 100%; }+.pp-list .pp-why { grid-column: 1 / -1; margin: -3px 0 3px; font-size: 11px; color: var(--text2); }+.pp-list .pp-why.warn { color: var(--warn); } /* The name text is its own hit target now: only the glyphs open the editor, and the empty space after them selects the row. The margin cancels the padding so the box hugs the text without nudging the row sideways; the hover outline is the affordance@@ -1868,6 +1925,11 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="sec-cols" aria-hidden="true"><span class="c-name">Section</span><span class="sec-count">Rows</span><span class="sec-dates">Dates</span><span class="sec-acts" style="visibility:hidden"><span class="sec-ghost">Remove</span></span></div> <div class="sec-list" role="list"></div> </div>+ <div class="an-card su-card" id="suPeople" style="display:none">+ <div class="su-head"><h3>People</h3><span class="su-n" id="ppState"></span></div>+ <p class="an-sub">Every owner name on this plan and the Adom account it belongs to. A task put on a linked person is asked of them first, and moving the dates of their tasks tells them; a name with no account is just assigned, and is marked with a dotted underline. Names match accounts by display name, username or first name; pick one here when that guesses wrong, or when two people share a first name. Only the plan's owner can change these.</p>+ <div class="pp-list" id="ppList"></div>+ </div> <div class="an-card su-card" id="suShare"> <div class="su-head"><h3>Sharing</h3><span class="su-n" id="shareState"></span></div> <p class="an-sub">Who can open this plan. Everyone it lets in can change it, and every change is in the changelog under their name. Only the plan's owner can change this.</p>@@ -1911,6 +1973,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="tip" id="tip"></div> <div class="editor" id="editor"></div> <div class="dropdown" id="exportMenu"></div>+<div class="req-panel" id="reqPanel" role="dialog" aria-label="Requests"></div> <div class="dropdown" id="importMenu"></div> <div class="undo-toast" id="undoToast">Undid change · <kbd>Ctrl+Z</kbd></div> <div class="conflict-bar" id="conflictBar" role="alert" aria-live="assertive"></div>@@ -3371,6 +3434,7 @@ $('hdr').innerHTML = ` <button class="fs-btn fs-big" id="fsUp" title="Larger text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="19" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="20" font-weight="600">A</text></svg></button> </div>${ADOM_USER?` <div class="vsep"></div>+ <button class="req-btn" id="btnReq" type="button" title="Requests: tasks people have asked you to take, and the ones you asked of others" aria-label="Requests"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 13h5l1.5 3h5L16 13h5"/><path d="M5.5 5h13L21 13v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6z"/></svg><span class="req-n" id="reqN"></span></button> <div class="who" id="whoChip" title="Signed in as ${esc(ADOM_USER.displayName)}${ADOM_USER.username?', @'+esc(ADOM_USER.username):''}${ADOM_USER.email?', '+esc(ADOM_USER.email):''}"> ${avatarHtml(ADOM_USER,'who-av')}<span class="who-name">${esc(ADOM_USER.displayName)}</span> </div>`:''}@@ -4397,6 +4461,8 @@ function moveOneRow(r,dir,table){ // return: a stale entry does nothing rather than half-applying. function applySnapshot(sn,seqIn){ const seq=sn.seq!==undefined?sn.seq:(seqIn!==undefined?seqIn:Infinity);+ // A request (0.80.0): made, answered or withdrawn, and taken back the same way.+ if(sn.type==='req')return applyReqSnapshot(sn); // Project dates (0.75.0): the inverse is the dates as they stand. if(sn.type==='pdates'){ const inv={type:'pdates',start:planDates.start,end:planDates.end};@@ -5697,7 +5763,7 @@ function flushSave(){ if(SNAPSHOT)return; // What this tab has that the server does not, as ops (0.77.0). A batch on its way is in // it too, which is harmless: an op says what a field IS, so landing twice changes nothing.- const ops=computeOps();+ const ops=computeOps().concat(reqOut.splice(0)); if(ops.length){ const body=JSON.stringify({client:CLIENT_ID,batch:CLIENT_ID+'.'+(++batchN),ops}); let sent=false;@@ -5746,7 +5812,9 @@ function saveToFile(){ if(inflight||syncBusy){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},250); return; } clearTimeout(saveRetry); saveRetry=null; sealUndo();- const at=localEditN, ops=computeOps();+ // Request ops (0.80.0) ride after the rows, so a request for a row made in the same+ // batch finds it on the server.+ const at=localEditN, rq=reqOut.splice(0), ops=computeOps().concat(rq); if(!ops.length){ cleanAtN=at; if(unsavedCount||logQueue.length)onSaveOk('saved'); return; } const b={batch:CLIENT_ID+'.'+(++batchN),ops,rev:null,vals:new Map(),fields:new Map(),adds:new Set(),dels:new Set(),order:null,struct:curStruct(),sent:{}}; ops.forEach(op=>{@@ -5768,6 +5836,8 @@ function saveToFile(){ saveInFlight=false; const d=await r.json().catch(()=>({})); if(!r.ok){+ // Requests not taken go again with the next try, unless the person may not write.+ if(rq.length&&r.status!==403&&r.status!==401)reqOut.unshift(...rq); if(inflight===b)inflight=null; if(r.status===403)onSaveBlocked('This plan is not shared with you any more, so the server refused the save.'); else if(r.status===401)onSaveBlocked('Your sign-in has ended, so the server refused the save. Export your version, then reload to sign in again.');@@ -5775,6 +5845,7 @@ function saveToFile(){ return; } onSaveOk('saved');+ rqSettle(rq,d); if(inflight!==b)return; // a catch-up settled it already const out=Array.isArray(d.ops)?d.ops:[]; b.rev=typeof d.stateRev==='number'?d.stateRev:null;@@ -5791,7 +5862,7 @@ function saveToFile(){ // in a few seconds the stream is behind or gone, and a catch-up settles everything. setTimeout(()=>{ if(inflight===b)pullAndMerge().catch(()=>{}); },3000); })- .catch(()=>{ saveInFlight=false; if(inflight===b)inflight=null; onSaveBlocked('The server could not be reached.'); retrySaveSoon(); });+ .catch(()=>{ saveInFlight=false; if(rq.length)reqOut.unshift(...rq); if(inflight===b)inflight=null; onSaveBlocked('The server could not be reached.'); retrySaveSoon(); }); } // A save that failed for want of a server is tried again on its own. Ops are safe to send // twice: each says what a field is, which item goes in or out of a list, or which row a row@@ -5874,6 +5945,8 @@ function stateOps(st){ if(Array.isArray(st.tags))ops.push({op:'reg',tags:st.tags}); if(st.dates&&typeof st.dates==='object'&&!Array.isArray(st.dates))ops.push({op:'dates',dates:st.dates}); if(st.calendar&&typeof st.calendar==='object'&&!Array.isArray(st.calendar))ops.push({op:'cal',calendar:st.calendar});+ // The requests whole (0.80.0): a catch-up replaces the list.+ ops.push({op:'reqs',list:Array.isArray(st.requests)?st.requests:[],people:st.people,notices:Array.isArray(st.notices)?st.notices:[],delegates:st.delegates||{}}); return ops; } // After the page caught up: whatever it still has that the server does not goes out soon,@@ -5885,9 +5958,9 @@ function afterSync(){ // the count only says whether it is worth looking. Every save asked for, every undo step // and every gesture on the page counts. A page nobody is touching compared all ~500 rows // after every change anybody made, about 5 ms each, three times a second in a meeting.- if(saveTimer||inflight||localEditN===cleanAtN)return;+ if(saveTimer||inflight||(localEditN===cleanAtN&&!reqOut.length))return; const at=localEditN;- if(computeOps().length)saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},150);+ if(computeOps().length||reqOut.length)saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},150); else cleanAtN=at; } // A gesture counts before anything it does: a key held for a move saves 600 ms later, and an@@ -5909,6 +5982,331 @@ function onRev(m){ afterSync(); }).catch(()=>{}); }+// ── Requests (0.80.0): adding and moving tasks to other people ──+//+// Drew: "we need to add a request flow for adding/moving tasks to other people, which both+// humans and AI should honor." Setting a row's owner to somebody else does not put it on+// their plate: it asks them, and they accept or decline with an optional note, the pattern+// Linear's triage, Asana's approvals and Jira's approvals share. The SERVER decides (see+// reqEngine in server.js), so a tab of any version and the agent key all go through it;+// this page only shows it and asks first, so nothing flickers. Taking a task yourself,+// clearing an owner, dates, sections and status stay direct.+//+// The list lives in the sidecar (`requests`, and `people`: the names people have accepted+// under) and arrives with the plan, in every announcement (`req`, `people` ops) and in every+// catch-up. Which answers this person has already seen is a convenience of this browser.+const REQ={on:false,list:new Map(),people:{},youOwner:false,accounts:[],delegates:{},notices:new Map()};+const reqOut=[];+const RQ_SEEN_KEY='adom-gantt:reqSeen:'+location.pathname;+let rqSeen=new Set();+try{ rqSeen=new Set(JSON.parse(localStorage.getItem(RQ_SEEN_KEY)||'[]')); }catch(e){}+function rqSaveSeen(){ try{ localStorage.setItem(RQ_SEEN_KEY,JSON.stringify([...rqSeen].slice(-300))); }catch(e){} }+const rqNorm=s=>String(s==null?'':s).trim().replace(/^@/,'').replace(/\s+/g,' ').toLowerCase();+const rqClean=s=>String(s==null?'':s).trim().replace(/\s+/g,' ').slice(0,200);+function rqMyId(){ return ADOM_USER?'u:'+(ADOM_USER.id||ADOM_USER.username||ADOM_USER.displayName):''; }+// Who an owner name is: the same rule as the server's resolveName. A link the plan's owner+// made on Setup (or an accept made) decides; otherwise the accounts of the people who have+// signed in to this plan whose display name or username it is, else whose first name it is.+// `how`: linked, match, first, shared (two people share that first name), none (no account:+// assigned directly, and marked), off (the owner said it has no account).+function rqKeys(a){+ const ex=new Set(); [a.name,a.username].forEach(v=>{ const x=rqNorm(v); if(x)ex.add(x); });+ const f=rqNorm(a.name).split(' ')[0];+ return {ex,first:f.length>=2?f:''};+}+function rqResolve(name){+ const n=rqNorm(name); if(!n)return {ids:[],how:'none'};+ const l=REQ.people&&REQ.people[n];+ if(l&&typeof l==='object'){ if(l.none)return {ids:[],how:'off'}; if(l.id)return {ids:[l.id],how:'linked'}; }+ const acc=REQ.accounts.slice();+ // This person is an account whether or not the list has caught up with them.+ if(ADOM_USER&&!acc.some(a=>a.id===rqMyId()))acc.push({id:rqMyId(),name:ADOM_USER.displayName||'',username:ADOM_USER.username||''});+ const exact=acc.filter(a=>rqKeys(a).ex.has(n)).map(a=>a.id);+ if(ADOM_USER&&!exact.includes(rqMyId())){ const e=String(ADOM_USER.email||'').toLowerCase(); if(e&&(rqNorm(e)===n||rqNorm(e.split('@')[0])===n))exact.push(rqMyId()); }+ if(exact.length)return {ids:exact,how:exact.length>1?'shared':'match'};+ const first=acc.filter(a=>rqKeys(a).first===n).map(a=>a.id);+ if(first.length)return {ids:first,how:first.length>1?'shared':'first'};+ return {ids:[],how:'none'};+}+function rqIsMe(name){ return !!ADOM_USER&&rqResolve(name).ids.includes(rqMyId()); }+// A name nobody with an Adom account answers to: assigned directly, and marked quietly so+// the plan's owner links it (Drew, 0.80.0).+function rqUnlinked(name){ if(!rqOn()||!rqClean(name))return false; const h=rqResolve(name).how; return h==='none'||h==='off'; }+function rqOn(){ return REQ.on&&!!ADOM_USER&&!SNAPSHOT; }+// Would setting this row's owner to `v` be a request rather than a change?+function rqAsks(r,v){+ if(!rqOn()||!r)return false;+ const to=rqClean(v);+ if(!to||rqNorm(to)===rqNorm(r.owner))return false;+ // Nobody to ask behind the name: assigned directly.+ const res=rqResolve(to);+ return res.ids.length>0&&!res.ids.includes(rqMyId());+}+function rqPending(uid){ for(const q of REQ.list.values())if(q&&q.uid===uid&&q.state==='pending')return q; return null; }+function rqForMe(q){ return !!q&&q.state==='pending'&&rqIsMe(q.to); }+function rqMine(q){ return !!q&&!!q.from&&q.from.id===rqMyId(); }+const rqNewId=()=>'rq-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,8);+// A request op goes out with the next batch, after the rows (a new row comes first).+function rqSend(op){ reqOut.push(op); markLocalEdit(); clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},120); }+// Ask `to` to take row `r`. The row keeps its owner; the pill shows at once.+function rqAsk(r,to,opts){+ opts=opts||{}; to=rqClean(to);+ if(!r||!r.uid||!to)return null;+ const open=rqPending(r.uid);+ if(open&&rqNorm(open.to)===rqNorm(to)){ flash(saveInd,`${open.to} has already been asked to take "${r.name}"`,4000); return open; }+ const q={id:rqNewId(),uid:r.uid,to,was:r.owner||'',from:{id:rqMyId(),name:USER_NAME||'you'},at:new Date().toISOString(),state:'pending',_local:true};+ if(opts.created)q.created=true;+ if(opts.note)q.note=String(opts.note).slice(0,500);+ if(open){ open.state='closed'; open.why='replaced by a newer request'; }+ REQ.list.set(q.id,q);+ rqSend({op:'req',uid:r.uid,to,id:q.id,note:q.note,created:opts.created===true});+ // One Ctrl+Z takes the request back (it is withdrawn); joined to the edit that made it+ // when there was one, so the editor's Apply is still one undo.+ if(!opts.noUndo){+ const step={type:'req',do:'withdraw',id:q.id};+ const top=undoStack[undoStack.length-1];+ if(opts.join&&top){ undoStack.pop(); pushUndo({type:'multi',steps:[top,step]}); }+ else pushUndo(step);+ }+ rqRefresh([r.uid]);+ flash(saveInd,`Asked ${to} to take "${r.name}". It is ${to}'s when ${to} accepts.`,5000);+ return q;+}+// Answer, withdraw or take back an answer. Shown at once; the server's word follows.+function rqDo(id,what,note,noUndo){+ const q=REQ.list.get(id); if(!q)return;+ const prev=q.state;+ if(what==='accept'||what==='decline'){+ if(prev!=='pending')return;+ q.state=what==='accept'?'accepted':'declined'; q.by={id:rqMyId(),name:USER_NAME}; q.done=new Date().toISOString();+ if(note)q.reply=String(note).slice(0,500); else delete q.reply;+ rqSend({op:'req.answer',id,accept:what==='accept',note:note||undefined});+ rqSeen.add(id); rqSaveSeen();+ } else if(what==='withdraw'){+ if(prev!=='pending')return;+ q.state='withdrawn'; q.by={id:rqMyId(),name:USER_NAME}; q.done=new Date().toISOString();+ rqSend({op:'req.withdraw',id});+ } else if(what==='reopen'){+ if(prev==='pending')return;+ q.state='pending'; delete q.by; delete q.done; delete q.reply;+ rqSend({op:'req.reopen',id});+ }+ if(!noUndo)pushUndo(rqInverse(id,what,prev,note));+ rqRefresh([q.uid]);+}+function rqInverse(id,what,prev,note){+ if(what==='reopen')return {type:'req',do:prev==='accepted'?'accept':prev==='declined'?'decline':'withdraw',id,note:note||''};+ return {type:'req',do:'reopen',id,prev:what==='accept'?'accepted':what==='decline'?'declined':'withdrawn',note:note||''};+}+// An undo entry for a request: do it again (or take it back), and hand back the opposite.+function applyReqSnapshot(sn){+ const q=REQ.list.get(sn.id);+ const note=sn.note||(q&&q.reply)||'';+ if(q)rqDo(sn.id,sn.do,note,true); else rqSend(sn.do==='withdraw'?{op:'req.withdraw',id:sn.id}:sn.do==='reopen'?{op:'req.reopen',id:sn.id}:{op:'req.answer',id:sn.id,accept:sn.do==='accept',note:note||undefined});+ return sn.do==='reopen'?rqInverse(sn.id,'reopen',sn.prev||'withdrawn',note):rqInverse(sn.id,sn.do,'pending',note);+}+// A request from the server: the whole of it, as stored. Tells this person when somebody+// asks them, and when somebody answers what they asked.+function rqUpsert(req,quiet){+ if(!req||typeof req!=='object'||!req.id)return;+ const old=REQ.list.get(req.id);+ REQ.list.set(req.id,req);+ if(quiet)return;+ const r=liveRowOf(req.uid), nm=r?r.name:'a task';+ if(req.state==='pending'&&rqIsMe(req.to)&&!rqMine(req)&&(!old||old.state!=='pending'))+ flash(saveInd,`${req.from&&req.from.name||'Someone'} asked you to take "${nm}". Open Requests to answer.`,8000);+ else if(rqMine(req)&&old&&old.state==='pending'&&(req.state==='accepted'||req.state==='declined')&&req.by&&req.by.id!==rqMyId())+ flash(saveInd,`${req.by.name} ${req.state} "${nm}"${req.reply?': "'+req.reply+'"':''}`,8000);+}+// A notice (0.80.0): somebody moved the dates of tasks this person owns. One per batch per+// person, kept up to date for ten minutes from the same hand. Told once, on arrival.+function rqNoticeForMe(n){ return !!n&&Array.isArray(n.ids)&&n.ids.includes(rqMyId())&&!(n.from&&n.from.id===rqMyId()); }+function rqNotice(n,quiet){+ if(!n||!n.id)return;+ const old=REQ.notices.get(n.id);+ REQ.notices.set(n.id,n);+ if(old&&old.upd!==n.upd)rqSeen.delete(n.id);+ if(quiet||!rqNoticeForMe(n))return;+ const r0=n.rows&&n.rows[0];+ flash(saveInd,n.count>1?`${n.from&&n.from.name||'Someone'} moved the dates of ${n.count} of your tasks. Requests shows which.`:`${n.from&&n.from.name||'Someone'} moved the dates of your task "${r0?r0.name:''}"`,7000);+}+// The whole list, as a catch-up or the first load has it. Requests this page has made and+// not sent yet are kept.+function rqLoad(list,people,notices,delegates){+ if(notices!==undefined){ REQ.notices.clear(); (Array.isArray(notices)?notices:[]).forEach(n=>{ if(n&&n.id)REQ.notices.set(n.id,n); }); }+ if(delegates!==undefined)REQ.delegates=delegates&&typeof delegates==='object'&&!Array.isArray(delegates)?delegates:{};+ const keep=[...REQ.list.values()].filter(q=>q&&q._local&&reqOut.some(o=>o.id===q.id));+ REQ.list.clear();+ (Array.isArray(list)?list:[]).forEach(q=>{ if(q&&q.id)REQ.list.set(q.id,q); });+ keep.forEach(q=>{ if(!REQ.list.has(q.id))REQ.list.set(q.id,q); });+ REQ.people=people&&typeof people==='object'&&!Array.isArray(people)?people:{};+}+// What the server did with this page's request ops: one it refused is taken off the page,+// and the person is told why.+function rqSettle(sent,d){+ if(!sent||!sent.length)return;+ const back=new Set((Array.isArray(d&&d.ops)?d.ops:[]).filter(o=>o&&o.op==='req'&&o.req).map(o=>o.req.id));+ const why=(Array.isArray(d&&d.refused)?d.refused:[]).filter(x=>/^req/.test(x));+ const uids=[];+ sent.forEach(op=>{ if(op.op!=='req'||!op.id||back.has(op.id))return; const q=REQ.list.get(op.id);+ if(q&&q._local){ REQ.list.delete(op.id); uids.push(q.uid); } });+ if(why.length)flash(saveInd,'The server did not take that request: '+why[0],7000);+ else{+ // The save's own "saved" would otherwise be the last word: say what was done.+ const made=sent.filter(op=>op.op==='req'&&back.has(op.id)).map(op=>REQ.list.get(op.id)).filter(Boolean);+ if(made.length===1){ const q=made[0], r=liveRowOf(q.uid); flash(saveInd,`Asked ${q.to} to take "${r?r.name:'it'}". It is ${q.to}'s when ${q.to} accepts.`,6000); }+ else if(made.length>1)flash(saveInd,`Asked ${made.length} people to take tasks. Each is theirs when they accept.`,6000);+ }+ if(uids.length||why.length)rqRefresh(uids);+}+// The pill on a row: "-> Ana?", in the accent for the person asked.+function reqPillHtml(r){+ if(!r||!r.uid||!REQ.list.size)return '';+ const q=rqPending(r.uid); if(!q)return '';+ const mine=rqIsMe(q.to);+ const t=`${q.from&&q.from.name||'Someone'} asked ${q.to} to take this, ${agoText(q.at)||'just now'}${q.note?': "'+q.note+'"':''}. ${mine?'Click to answer':'Waiting on '+q.to}`;+ return `<span class="tag req-pill${mine?' mine':''}" data-req="${esc(q.id)}" title="${esc(t)}">→ ${esc(q.to)}?</span>`;+}+// Repaint what shows requests: the rows named, the badge, the open panel and editor.+function rqRefresh(uids){+ (uids||[]).forEach(u=>{ const r=liveRowOf(u); if(!r)return;+ if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r);+ if(tableBody&&tableRowsCache){ tableForceKeys.add(rowKey(r)); } });+ if(prefs.view==='table'&&(uids||[]).length){ const rows=uids.map(liveRowOf).filter(Boolean); if(!patchTableRows(rows,new Set(['owner'])))renderTable(); }+ rqBadge();+ if(reqPanel.classList.contains('show'))rqRenderPanel();+ if(edSubject&&(uids||[]).includes(edSubject.uid))rqEditorLine(edSubject);+}+function rqCounts(){+ let forMe=0, answered=0, moved=0;+ REQ.list.forEach(q=>{ if(rqForMe(q))forMe++;+ else if(rqMine(q)&&(q.state==='accepted'||q.state==='declined')&&q.by&&q.by.id!==rqMyId()&&!rqSeen.has(q.id))answered++; });+ REQ.notices.forEach(n=>{ if(rqNoticeForMe(n)&&!rqSeen.has(n.id))moved++; });+ return {forMe,answered,moved};+}+function rqBadge(){+ const b=$('btnReq'), n=$('reqN'); if(!b||!n)return;+ b.style.display=rqOn()||REQ.list.size?'':'none';+ const c=rqCounts(), t=c.forMe+c.answered+c.moved;+ n.textContent=t?String(t):'';+ b.title=(c.forMe?`${c.forMe} request${c.forMe===1?'':'s'} waiting on you`:'No requests waiting on you')+(c.answered?`, ${c.answered} answer${c.answered===1?'':'s'} to yours`:'')+(c.moved?`, ${c.moved} change${c.moved===1?'':'s'} to your tasks`:'')+'. Tasks people ask you to take, the ones you asked of others, and dates moved on yours';+}+const reqPanel=$('reqPanel');+function rqItemHtml(q,kind){+ const r=liveRowOf(q.uid), nm=r?r.name:'(a row that is gone)';+ const who=q.from&&q.from.name||'Someone';+ const st=`<span class="rq-state ${esc(q.state)}">${esc(q.state)}</span>`;+ let meta, act='';+ if(kind==='for'){+ meta=`${esc(who)} asked you${q.was?' (it is '+esc(q.was)+'\'s now)':q.created?' (a new task)':''}, ${esc(agoText(q.at))}`;+ act=`<div class="rq-act"><input type="text" class="rq-in" maxlength="500" placeholder="a note (optional)" data-req="${esc(q.id)}"><button class="rq-b yes" data-a="accept" data-req="${esc(q.id)}">Accept</button><button class="rq-b" data-a="decline" data-req="${esc(q.id)}">Decline</button></div>`;+ } else if(kind==='asked'){+ meta=`you asked ${esc(q.to)}, ${esc(agoText(q.at))}${q.by&&q.state!=='pending'?' · '+esc(q.state)+' by '+esc(q.by.for?q.by.for.name+'\'s AI':q.by.name)+' '+esc(agoText(q.done)):''}${q.why?' ('+esc(q.why)+')':''}`;+ if(q.state==='pending')act=`<div class="rq-act"><button class="rq-b" data-a="withdraw" data-req="${esc(q.id)}">Withdraw</button></div>`;+ } else {+ meta=`${esc(who)} asked ${esc(q.to)}, ${esc(agoText(q.at))}`;+ act=`<div class="rq-act"><input type="text" class="rq-in" maxlength="500" placeholder="a note (optional)" data-req="${esc(q.id)}"><button class="rq-b yes" data-a="accept" data-req="${esc(q.id)}" title="Answer for ${esc(q.to)}, as the plan's owner">Accept for them</button><button class="rq-b" data-a="decline" data-req="${esc(q.id)}">Decline</button><button class="rq-b" data-a="withdraw" data-req="${esc(q.id)}">Withdraw</button></div>`;+ }+ const notes=(q.note?`<div class="rq-note">"${esc(q.note)}"</div>`:'')+(q.reply?`<div class="rq-note">${esc(q.by?q.by.name:'')}: "${esc(q.reply)}"</div>`:'');+ return `<div class="rq-item" data-req="${esc(q.id)}"><a class="rq-row" data-uid="${esc(q.uid)}" href="#row=${esc(q.uid)}">${esc(nm)}</a>${kind==='asked'?st:''}<div class="rq-meta">${meta}</div>${notes}${act}</div>`;+}+function rqNoticeHtml(n){+ const rows=(n.rows||[]).slice(0,6), more=Math.max(0,(n.count||rows.length)-rows.length);+ const d=v=>v?(v.date?fmt(v.date):(v.s?fmt(v.s):'')+' to '+(v.e?fmt(v.e):'')):'';+ return `<div class="rq-item rq-ntc" data-notice="${esc(n.id)}"><div class="rq-meta">${esc(n.from&&n.from.name||'Someone')} moved the dates of ${n.count>1?esc(String(n.count))+' of your tasks':'your task'}, ${esc(agoText(n.upd||n.at))}</div>`+ +rows.map(x=>`<div class="rq-mv"><a class="rq-row" data-uid="${esc(x.uid)}" href="#row=${esc(x.uid)}">${esc(x.name||'#'+x.uid)}</a> <div class="rq-meta">now ${esc(d(x.now))}, was <span class="rq-was">${esc(d(x.was))}</span></div></div>`).join('')+ +(more?`<div class="rq-meta">and ${more} more</div>`:'')+'</div>';+}+function rqRenderPanel(){+ const all=[...REQ.list.values()].sort((a,b)=>String(b.at).localeCompare(String(a.at)));+ const forMe=all.filter(rqForMe);+ const asked=all.filter(q=>rqMine(q)&&q.state!=='closed').slice(0,30);+ const others=REQ.youOwner?all.filter(q=>q.state==='pending'&&!rqIsMe(q.to)&&!rqMine(q)):[];+ // Keep a note being typed across a repaint.+ const drafts={}; reqPanel.querySelectorAll('.rq-in').forEach(i=>{ if(i.value)drafts[i.dataset.req]=i.value; });+ const sec=(t,list,kind,none)=>`<h3>${t}</h3>`+(list.length?list.map(q=>rqItemHtml(q,kind)).join(''):`<div class="rq-none">${none}</div>`);+ const moved=[...REQ.notices.values()].filter(rqNoticeForMe).sort((a,b)=>String(b.upd||b.at).localeCompare(String(a.upd||a.at))).slice(0,20);+ const aiOn=!!(REQ.delegates[rqMyId()]&&REQ.delegates[rqMyId()].on);+ reqPanel.innerHTML=`<div class="rq-head"><span class="rq-title">Requests</span></div>`+ +(rqOn()?'':`<div class="rq-off">Requests are off on this plan: an owner change applies at once.</div>`)+ +sec('For you',forMe,'for','Nothing waiting on you.')+ +`<h3>Changes to your tasks</h3>`+(moved.length?moved.map(rqNoticeHtml).join(''):'<div class="rq-none">Nobody has moved your dates lately.</div>')+ +sec('You asked',asked,'asked','You have not asked anyone to take a task.')+ +(REQ.youOwner?sec('Waiting on others',others,'other','Nothing else is waiting.'):'')+ +`<label class="rq-ai"><input type="checkbox" id="rqAi"${aiOn?' checked':''}> Let my AI answer requests for me <span class="rq-meta">An AI with this plan's agent key may then accept or decline in your name; the history says it was your AI.</span></label>`;+ reqPanel.querySelectorAll('.rq-in').forEach(i=>{ if(drafts[i.dataset.req])i.value=drafts[i.dataset.req]; });+}+function rqPlacePanel(){+ const b=$('btnReq'); if(!b)return;+ const r=b.getBoundingClientRect(), w=reqPanel.offsetWidth||380;+ reqPanel.style.left=Math.max(8,Math.min(r.right-w,window.innerWidth-w-8))+'px';+ reqPanel.style.top=(r.bottom+6)+'px';+}+function openReqPanel(focusId){+ closeMenus();+ rqRenderPanel();+ reqPanel.classList.add('show');+ rqPlacePanel();+ // Answers to what this person asked count as seen once the panel shows them.+ REQ.list.forEach(q=>{ if(rqMine(q)&&q.state!=='pending')rqSeen.add(q.id); });+ REQ.notices.forEach(n=>{ if(rqNoticeForMe(n))rqSeen.add(n.id); }); rqSaveSeen(); rqBadge();+ if(focusId){ const it=reqPanel.querySelector(`.rq-item[data-req="${CSS.escape(focusId)}"]`); if(it){ it.scrollIntoView({block:'nearest'}); const i=it.querySelector('.rq-in'); if(i)i.focus(); } }+}+function closeReqPanel(){ reqPanel.classList.remove('show'); }+function wireRequests(){+ const b=$('btnReq');+ if(b)b.addEventListener('click',e=>{ e.stopPropagation(); if(reqPanel.classList.contains('show'))closeReqPanel(); else openReqPanel(); });+ reqPanel.addEventListener('click',e=>{+ e.stopPropagation();+ const a=e.target.closest('.rq-row');+ if(a){ e.preventDefault(); closeReqPanel(); location.hash='row='+a.dataset.uid; openRowLink(); return; }+ if(e.target.id==='rqAi'){ rqSend({op:'ai.answers',on:e.target.checked}); REQ.delegates=Object.assign({},REQ.delegates); if(e.target.checked)REQ.delegates[rqMyId()]={on:true}; else delete REQ.delegates[rqMyId()];+ flash(saveInd,e.target.checked?'Your AI may now answer requests for you; the history will say it was your AI':'Your AI no longer answers requests for you',5000); return; }+ const btn=e.target.closest('.rq-b'); if(!btn)return;+ const id=btn.dataset.req, inp=reqPanel.querySelector(`.rq-in[data-req="${CSS.escape(id)}"]`);+ rqDo(id,btn.dataset.a,inp?inp.value.trim():'');+ });+ reqPanel.addEventListener('keydown',e=>{+ e.stopPropagation();+ if(e.key==='Escape'){ e.preventDefault(); closeReqPanel(); if(b)b.focus(); }+ else if(e.key==='Enter'&&e.target.classList.contains('rq-in')){ e.preventDefault(); rqDo(e.target.dataset.req,'accept',e.target.value.trim()); }+ });+ document.addEventListener('click',e=>{ if(reqPanel.classList.contains('show')&&!reqPanel.contains(e.target)&&e.target!==b)closeReqPanel(); });+ window.addEventListener('resize',()=>{ if(reqPanel.classList.contains('show'))rqPlacePanel(); });+ rqBadge();+}+// What the server says about requests on this plan, once at load.+async function rqFetch(){+ if(SNAPSHOT||!ADOM_USER)return;+ try{+ const r=await fetch('requests'); if(!r.ok)return;+ const d=await r.json();+ REQ.on=!!d.on; REQ.youOwner=!!d.youAreOwner;+ if(Array.isArray(d.accounts))REQ.accounts=d.accounts;+ if(!REQ.list.size&&Array.isArray(d.requests))rqLoad(d.requests.map(q=>{ const o=Object.assign({},q); delete o.row; return o; }),d.people,d.notices,d.delegates);+ else { if(!REQ.notices.size&&Array.isArray(d.notices))d.notices.forEach(n=>{ if(n&&n.id)REQ.notices.set(n.id,n); }); if(d.delegates&&typeof d.delegates==='object')REQ.delegates=d.delegates; }+ // The owner pills and cells mark names nobody answers to, now that the accounts are known.+ items.forEach(r=>{ if(!isGroup(r)&&r.uid&&rqClean(r.owner)){ if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r); } });+ if(prefs.view==='table')renderTable();+ if(prefs.view==='setup')renderPeopleCard();+ rqRefresh([...new Set([...REQ.list.values()].map(q=>q.uid))]);+ }catch(e){}+}+// The item editor's line about a request on its row: who asked whom, and the buttons that+// answer or withdraw it. Empty when there is none.+function rqEditorLine(r){+ const el=$('imReq'); if(!el)return;+ const q=r&&rqPending(r.uid);+ if(!q){ el.style.display='none'; el.innerHTML=''; return; }+ const mine=rqIsMe(q.to), asker=rqMine(q);+ el.classList.add('im-req'); el.classList.toggle('mine',mine);+ el.style.display='';+ el.innerHTML=`<span>${mine?esc(q.from&&q.from.name||'Someone')+' asked you to take this':'Waiting on '+esc(q.to)+(asker?' (you asked)':' (asked by '+esc(q.from&&q.from.name||'someone')+')')}${q.note?': "'+esc(q.note)+'"':''}</span>`+ +(mine?`<button class="rq-b yes" id="imReqYes" type="button">Accept</button><button class="rq-b" id="imReqNo" type="button">Decline</button>`:'')+ +((asker||REQ.youOwner)&&!mine?`<button class="rq-b" id="imReqWd" type="button">Withdraw</button>`:'');+ const on=(id,what)=>{ const x=$(id); if(x)x.addEventListener('click',e=>{ e.preventDefault(); e.stopPropagation(); rqDo(q.id,what,''); }); };+ on('imReqYes','accept'); on('imReqNo','decline'); on('imReqWd','withdraw');+} // ── Applying ops to the page ── // One batch, applied to the page and repainted once. `mine` is this tab's own batch coming // back (its values are compared with what it SENT, so a field changed again since is kept);@@ -5959,6 +6357,8 @@ function applyOps(ops,ctx){ if(T.order&&lastSnap)lastSnap.order=items.map(rowOrderKey); } if(T.resync)requestResync(T.resync);+ if(T.reqs)rqRefresh([...T.reqs]);+ if(T.people&&prefs.view==='setup')renderPeopleCard(); return T; } // A row op that changes nothing but the row's key among its siblings (0.79.0): the same parent@@ -5986,6 +6386,13 @@ function requestResync(why){ } function applyOne(op,mine,who,T){ if(op.op==='row')return applyRowOp(op,mine,who,T);+ // Requests (0.80.0): one request whole, the names people answer to, or (a catch-up) all.+ if(op.op==='req'){ if(op.req&&op.req.id){ rqUpsert(op.req,!who); (T.reqs||(T.reqs=new Set())).add(op.req.uid); } return; }+ if(op.op==='people'){ REQ.people=op.people&&typeof op.people==='object'?op.people:{}; T.reqs=T.reqs||new Set(); items.forEach(r=>{ if(rqPending(r.uid)||rqClean(r.owner))T.reqs.add(r.uid); }); T.people=true; return; }+ if(op.op==='notice'){ if(op.notice&&op.notice.id){ rqNotice(op.notice,!who); T.reqs=T.reqs||new Set(); } return; }+ if(op.op==='delegates'){ REQ.delegates=op.delegates&&typeof op.delegates==='object'?op.delegates:{}; T.reqs=T.reqs||new Set(); return; }+ if(op.op==='reqs'){ const before=new Set([...REQ.list.values()].filter(q=>q.state==='pending').map(q=>q.uid)); rqLoad(op.list,op.people,op.notices,op.delegates);+ T.reqs=T.reqs||new Set(); before.forEach(u=>T.reqs.add(u)); REQ.list.forEach(q=>{ if(q.state==='pending')T.reqs.add(q.uid); }); return; } if(op.op==='add')return applyAddOp(op,mine,who,T); if(op.op==='del')return applyDelOp(op,mine,who,T); if(op.op==='order'){ if(Array.isArray(op.order)){ shadow.order=op.order.slice(); T.orderCheck=op.order; } return; }@@ -6264,6 +6671,9 @@ function startLive(){ if(m.type==='hello'){ liveYou=m.you||null; if(typeof m.stateRev==='number'&&m.stateRev>(baseRev||0))pullAndMerge().catch(()=>{}); return; } if(m.type==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; liveFocus=Array.isArray(m.focus)?m.focus:[]; renderPresence(); schedulePeerPaint(); return; } if(m.type==='rev'){ onRev(m); return; }+ // The owner turned requests on or off (0.80.0).+ if(m.type==='accounts'){ if(Array.isArray(m.accounts)){ REQ.accounts=m.accounts; if(prefs.view==='setup')renderPeopleCard(); } return; }+ if(m.type==='requests'){ REQ.on=!!m.on; rqRefresh([]); flash(saveInd,`${m.actor||'The owner'} turned requests ${m.on?'on: an owner change onto somebody else now asks them first':'off: an owner change applies at once'}`,6000); return; } // The owner changed the sharing and this person is no longer on it (0.68.0). The // stream is closed by the server; the bar offers the export before anything is lost. if(m.type==='denied'){ stopLive(); onSaveBlocked('The owner changed who this plan is shared with, and it is not shared with you any more.'); return; }@@ -6381,6 +6791,7 @@ function renderPresence(){ leg.appendChild(el); } // For the suite: pause and resume this tab's stream, and read what it knows.+window.__req={list:()=>[...REQ.list.values()].map(q=>Object.assign({},q)),counts:()=>rqCounts(),on:()=>REQ.on,out:()=>reqOut.length}; 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||'',@@ -7207,7 +7618,7 @@ function rowTagsHtml(r){ } function nameInner(r){ const nt='<span class="name-text">'+esc(r.name||'')+'</span>';- if(r.type==='milestone')return nt+rowTagsHtml(r);+ if(r.type==='milestone')return nt+reqPillHtml(r)+rowTagsHtml(r); const sk=statusOf(r); const tg=sk?`<span class="tag status-pill ${statusTagFor(sk)}" title="Click to change status">${esc(statusTextFor(sk))}</span>`:''; // The owner rides immediately right of the status, the same kind of chip answering@@ -7215,8 +7626,10 @@ function nameInner(r){ // with nobody on it still gets a chip, quieter, because "unassigned" is the state // 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+subCountHtml(r)+tg+op+rowTagsHtml(r);+ const ul=ow&&rqUnlinked(ow);+ const op=`<span class="tag owner-pill${ow?'':' none'}${ul?' unlinked':''}" title="${ow?(ul?esc(ow)+' is not linked to an Adom account, so nobody is told about work put on them. The plan\'s owner links names on Setup. ':'')+'Click to change owner':'Nobody owns this yet. Click to set an owner'}">${esc(ow||'NO OWNER')}</span>`;+ // A request somebody made for this row, right after its owner (0.80.0).+ return nt+subCountHtml(r)+tg+op+reqPillHtml(r)+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@@ -8899,6 +9312,9 @@ labelsEl.addEventListener('click',e=>{ // opens the name editor or selects the row, and a modified click falls through to // selection. One more excluded region, exactly like the status pill, which is why // the gap-click and name-text behaviours below are untouched by it.+ // The request pill opens the inbox at that request (0.80.0).+ const rpill=e.target.closest('.req-pill');+ if(rpill&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ e.stopPropagation(); openReqPanel(rpill.dataset.req); return; } const opill=e.target.closest('.owner-pill'); if(opill&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ e.stopPropagation();@@ -9197,7 +9613,7 @@ function showChartDrop(drop){ } 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('.status-pill, .owner-pill, .req-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;@@ -10591,7 +11007,7 @@ function setEditorKeys(fn){ // Closes the lists the PANEL opened, and only those: a Table cell's list is opened by the // same click that tells an open panel to close, and must survive it. function closeEditor(){edSubject=null;scheduleFocus();setEditorKeys(null);if(cellList&&editorEl.contains(cellList.sel))closeCellList();if(ownerCombo&&editorEl.contains(ownerCombo.inp))closeOwnerCombo();editorEl.classList.remove('show','square','compact');editorEl.innerHTML='';}-function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');}+function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');if(typeof closeReqPanel==='function')closeReqPanel();} // ── The keyboard on the menus (0.69.0) ── // Export, Import, Help and the who-card are one kind of menu, opened from a button that // keeps the focus. While one is open the arrows walk its items, Home and End jump to the@@ -12503,6 +12919,8 @@ function openItemModal(r,carriedName,opts){ +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'})+ // A request on this row (0.80.0): who asked whom, with Accept, Decline or Withdraw.+ +'<div class="ed-note im-req" id="imReq" style="display:none"></div>' +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'})@@ -12531,6 +12949,7 @@ function openItemModal(r,carriedName,opts){ $('imStart').value=ms?'':(r.start||''); $('imEnd').value=(ms?r.date:r.end)||''; $('imOwner').value=r.owner||'';+ rqEditorLine(r); $('imEffWrap').style.display=ms?'none':''; $('imEffort').value=effortOf(r)===null?'':String(effortOf(r)); // A milestone has no bar to label, so that field is hidden for it, the way its start@@ -12630,7 +13049,9 @@ function openItemModal(r,carriedName,opts){ if(end)r.end=end; if(r.start&&r.end&&r.start>r.end)r.start=r.end; }- setOpt(r,'owner',$('imOwner').value.trim());+ // An owner who is somebody else is asked, after the rest lands, in the same undo (0.80.0).+ const ownNew=$('imOwner').value.trim().replace(/\s+/g,' '), askTo=rqAsks(r,ownNew)?ownNew:'';+ if(!askTo)setOpt(r,'owner',ownNew); setOpt(r,'status',STATUS_ORDER.includes($('imStatus').value)?$('imStatus').value:''); setOpt(r,'description',$('imDesc').value.trim()); if(!ms)setEffort(r,eff);@@ -12658,6 +13079,7 @@ function openItemModal(r,carriedName,opts){ applyCollapse();refreshChartGeometry();renderTable(); closeEditor(); scheduleSave('item-editor');+ if(askTo)rqAsk(r,askTo,{join:true}); }); } @@ -12798,7 +13220,9 @@ function openCreateEditor(){ if(isMs){ r.date=end; } else { r.start=start<=end?start:end; r.end=end; } if(!isMs)r._key=undefined;- if(owner)r.owner=owner;+ // A new row for somebody else is made unassigned and asked of them (0.80.0).+ const askTo=owner&&rqAsks({owner:''},owner)?owner:'';+ if(owner&&!askTo)r.owner=owner; if(st)r.status=st; if(desc)r.description=desc; if(prov)r.provenance=prov;@@ -12824,6 +13248,7 @@ function openCreateEditor(){ if(tags.length)afterTagsChange(); // a tag new to the plan gets its legend chip scheduleSave('create'); flash(saveInd,'added '+(isMs?'milestone':'task'));+ if(askTo)rqAsk(r,askTo,{created:true,noUndo:true}); }); } // Compressed chip in the header, medium card in the pulldown: the same two@@ -13857,7 +14282,7 @@ function tableRowHtml(r){ +`<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>`+ +`<td class="c-owner${ed}${o?'':' empty'}${o&&rqUnlinked(o)?' unlinked':''}" data-field="owner"${o&&rqUnlinked(o)?` title="${esc(o)} is not linked to an Adom account. The plan's owner links names on Setup"`:''}>${o?esc(o):'NO OWNER'}${reqPillHtml(r)}</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>`@@ -14913,6 +15338,8 @@ function commitCell(r,field,v){ if(field==='owner'){ v=v.trim().replace(/\s+/g,' '); if((r.owner||'')===v)return;+ // Somebody else's name asks them (0.80.0); the row keeps its owner until they accept.+ if(rqAsks(r,v)){ rqAsk(r,v); return; } snap();setOpt(r,'owner',v); } else if(field==='status'){ if(statusOf(r)===v)return;@@ -15517,7 +15944,7 @@ function renderAnalytics(){ // prefs: that runs before this line does. const VIEWS=VIEW_LIST; function applyView(){- if(prefs.view==='setup'&&typeof renderChatCard==='function'){ renderShareCard(); renderChatCard(); }+ if(prefs.view==='setup'&&typeof renderChatCard==='function'){ renderShareCard(); renderChatCard(); renderPeopleCard(); } const v = VIEWS.includes(prefs.view) ? prefs.view : 'gantt'; document.documentElement.setAttribute('data-view',v); const vt=$('viewToggle');@@ -15538,6 +15965,48 @@ function agoText(iso){ const m=Math.round((Date.now()-t)/60000); return m<1?'just now':m<60?m+' min ago':m<1440?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago'; }+// ── People (0.80.0): owner names linked to Adom accounts, on Setup ──+// The plan's owner picks the account behind each owner name; everybody else sees the list.+// Every name on a row is listed, with how it resolves now: linked here, matched by name,+// matched by a first name, shared by two people (a warning, until it is linked), or no account.+async function renderPeopleCard(){+ const card=$('suPeople'); if(!card)return;+ if(SNAPSHOT||!ADOM_USER||!REQ.on){ card.style.display='none'; return; }+ card.style.display='';+ const names=new Map();+ items.forEach(r=>{ if(isGroup(r))return; const o=rqClean(r.owner); if(!o)return; const k=rqNorm(o); if(!names.has(k))names.set(k,{name:o,rows:0}); names.get(k).rows++; });+ Object.keys(REQ.people||{}).forEach(k=>{ if(!names.has(k))names.set(k,{name:(REQ.people[k]&&REQ.people[k].as)||k,rows:0}); });+ const list=[...names.values()].sort((a,b)=>a.name.localeCompare(b.name));+ const accName=id=>{ const a=REQ.accounts.find(x=>x.id===id); return a?a.name+(a.username?' (@'+a.username+')':''):id; };+ let unlinked=0, shared=0;+ const can=REQ.youOwner;+ const rows=list.map(p=>{+ const res=rqResolve(p.name), k=rqNorm(p.name), link=REQ.people[k];+ if(p.rows&&(res.how==='none'||res.how==='off'))unlinked++;+ if(res.how==='shared')shared++;+ const cur=link&&link.none?'none':link&&link.id?link.id:'';+ const opts=`<option value="">${res.how==='linked'||res.how==='off'?'not linked':res.ids.length===1?'matched: '+esc(accName(res.ids[0])):res.how==='shared'?'not linked (two people)':'not linked'}</option>`+ +REQ.accounts.map(a=>`<option value="${esc(a.id)}"${cur===a.id?' selected':''}>${esc(a.name+(a.username?' (@'+a.username+')':''))}</option>`).join('')+ +`<option value="none"${cur==='none'?' selected':''}>no Adom account</option>`;+ const why=res.how==='shared'?`<div class="pp-why warn">${res.ids.map(accName).map(esc).join(' and ')} share this first name: both are asked until you pick one.</div>`+ :res.how==='none'?`<div class="pp-why">Nobody who has opened this plan answers to it, so work put on it is assigned without asking.</div>`+ :res.how==='first'?`<div class="pp-why">Matched by first name.</div>`:'';+ return `<span class="pp-nm" title="${esc(p.name)}">${esc(p.name)}</span><span class="pp-n">${p.rows}</span>`+ +`<select class="pp-acc" data-name="${esc(p.name)}" aria-label="${esc('Adom account for '+p.name)}"${can?'':' disabled'}>${opts}</select>${why}`;+ });+ $('ppList').innerHTML=list.length?'<span class="pp-h">Name</span><span class="pp-h">Rows</span><span class="pp-h">Adom account</span>'+rows.join(''):'<span class="pp-why">No row has an owner yet.</span>';+ $('ppState').textContent=list.length?`${list.length} name${list.length===1?'':'s'}${unlinked?', '+unlinked+' not linked':''}${shared?', '+shared+' shared first name'+(shared===1?'':'s'):''}`:'';+ $('ppList').querySelectorAll('.pp-acc').forEach(sel=>sel.addEventListener('change',()=>{+ rqSend({op:'people.link',name:sel.dataset.name,account:sel.value===''?null:sel.value});+ const k=rqNorm(sel.dataset.name);+ if(sel.value==='')delete REQ.people[k];+ else if(sel.value==='none')REQ.people[k]={none:true,as:sel.dataset.name};+ else{ const a=REQ.accounts.find(x=>x.id===sel.value)||{}; REQ.people[k]={id:sel.value,name:a.name||'',username:a.username||'',as:sel.dataset.name}; }+ flash(saveInd,sel.value===''?`"${sel.dataset.name}" is not linked`:sel.value==='none'?`"${sel.dataset.name}" has no Adom account: assigned without asking`:`"${sel.dataset.name}" is ${accName(sel.value)}`,4000);+ renderPeopleCard();+ items.forEach(r=>{ if(rqNorm(r.owner)===k){ if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r); } });+ }));+} async function renderChatCard(){ const card=$('suChat'); if(!card)return; if(SNAPSHOT){ card.style.display='none'; return; }@@ -15743,9 +16212,11 @@ $('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn') // A snapshot carries its state already merged into its data, and has no server to ask // (0.75.1: guarded here, not only by the export's rewrite of this line, which an edit to the // line silently stopped matching, so snapshots fetched a state that was not there).-if(!SNAPSHOT)try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){baseRev=typeof st.stateRev==='number'?st.stateRev:0;withDeps(()=>{applyState(st);updateBars();});}else baseRev=0;}}catch(e){}+if(!SNAPSHOT)try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){baseRev=typeof st.stateRev==='number'?st.stateRev:0;rqLoad(st.requests,st.people,st.notices,st.delegates);withDeps(()=>{applyState(st);updateBars();});}else baseRev=0;}}catch(e){} // What the server holds, as of now: the baseline every later change is measured against. initShadow();+// Whether requests are on, and whether this person is the plan's owner (0.80.0).+rqFetch(); // The sidecar can carry a status or a description that the chart's own label rows // and tooltips were built from the data file with, so restate them. items.forEach(r=>{ if(r.type==='milestone')refreshMsChrome(r); else if(r.id)refreshLabelRow(r); });@@ -15894,6 +16365,7 @@ async function init(){ wireMsStyleAndGrain(); wireDepMode(); wireWhoChip();+ wireRequests(); wireNewButton(); wireFontScale(); wirePlanSpanClock();
install.shpackage.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.79.0",+ "version": "0.80.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": {
page.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.79.0",+ "version": "0.80.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": {
server.js+540−10@@ -185,7 +185,7 @@ function isDirectLoopback(req) { // Read-only, and only over direct loopback. Everything that CHANGES the plan needs // a signed-in person no matter where it came from.-const LOOPBACK_READS = new Set(['/data', '/state', '/changelog',+const LOOPBACK_READS = new Set(['/data', '/state', '/changelog', '/requests', '/people', '/export/json', '/export/csv', '/export/svg', '/export/html']); // ── Sharing (0.68.0) ── //@@ -208,7 +208,9 @@ function accessSave(o) { fs.writeFileSync(ACCESS_FILE, JSON.stringify(o, null, 1 function accessView() { const a = accessLoad(); return { mode: ACCESS_MODES.includes(a.mode) ? a.mode : 'any', org: typeof a.org === 'string' && a.org ? a.org : 'adom',- owner: a.owner && (a.owner.username || a.owner.id) ? a.owner : null };+ owner: a.owner && (a.owner.username || a.owner.id) ? a.owner : null,+ // Requests (0.80.0): on unless the owner turned them off; always off without sign-in.+ requests: AUTH_OFF ? 'off' : (a.requests === 'off' ? 'off' : 'on') }; } function isOwner(user, a) { if (!user || !a || !a.owner) return false;@@ -570,6 +572,12 @@ function whoOf(auth) { if (auth && auth.agent) return { id: 'agent', name: AGENT_ACTOR, initials: 'AI' }; return { id: '', name: 'Guest', initials: 'G' }; }+// The name the changelog records for a caller: the agent, the signed-in person, or 'user'.+function actorOf(auth) {+ if (auth && auth.agent) return AGENT_ACTOR;+ const u = auth && auth.user;+ return u ? (u.displayName || u.username || 'user') : 'user';+} function liveOpen(req, res, auth, query) { const cid = String((query.match(/(?:^|&)cid=([^&]*)/) || [])[1] || '').slice(0, 40); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform',@@ -579,6 +587,7 @@ function liveOpen(req, res, auth, query) { // the laptop slept) reconnects with the same cid, and the old connection can still look // open from here: it is closed now instead of being counted as a second tab. if (cid) for (const o of [...liveClients]) if (o.cid === cid) { liveClients.delete(o); try { o.res.end(); } catch (e) {} }+ if (auth && auth.user) { try { noteAccount(auth.user); } catch (e) {} } const c = { res, cid, who: whoOf(auth), auth, seen: Date.now() }; liveClients.add(c); liveSend(c, { type: 'hello', stateRev: stateRev(), you: c.who });@@ -797,7 +806,7 @@ var CHAT_DAILY_HOUR = 17; var CHAT_CADENCES = ['live', 'hourly', 'daily', 'off']; // The server's own bookkeeping is not news: the chain's genesis, uid backfills, a sidecar // upgrade. The agent's edits ARE news, and say whose they are.-var CHAT_SKIP_ACTIONS = new Set(['chain.genesis', 'uid.assign', 'state.upgrade', 'milestone.ids']);+var CHAT_SKIP_ACTIONS = new Set(['chain.genesis', 'uid.assign', 'state.upgrade', 'milestone.ids', 'people.link', 'ai.answers']); var chatBusy = false, chatNextTry = 0, chatBackoff = 0; function chatLoad() { try { const o = JSON.parse(fs.readFileSync(CHAT_FILE, 'utf8')); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; }@@ -1066,6 +1075,37 @@ function describeEvent(e) { const say = side => { const m = fld(side, 'mode', 'any'); return m === 'me' ? 'its owner only' : m === 'org' ? `the ${fld(side, 'org', 'adom')} organization` : 'anyone signed in to Adom'; }; return `${who} shared the plan with ${say('to')} (was ${say('from')})${via}`; }+ // Requests (0.80.0), written by the server.+ if (action === 'request' || action.startsWith('request.')) {+ const to = fld('to', 'owner', '') || 'someone';+ const asker = e.requester || 'someone';+ const q = s => s ? `: "${oneLine(s)}"` : '';+ if (action === 'request') {+ const was = fld('from', 'owner', '');+ return `${who} asked ${to} to take ${e.created ? 'the new task ' : ''}${name}${was ? ` (owned by ${was})` : ''}${q(e.note)}${via}`;+ }+ // An answer the agent gave for a person who lets their AI answer for them.+ if (e.onBehalf && (action === 'request.accept' || action === 'request.decline' || action === 'request.reopen')) {+ const ai = `${e.onBehalf}'s AI`;+ if (action === 'request.accept') return `${ai} accepted ${name} for them, asked by ${asker}${q(e.reply)}${via}`;+ if (action === 'request.decline') return `${ai} declined ${name} for them, asked by ${asker}${q(e.reply)}${via}`;+ return `${ai} took back its answer: ${to} is asked to take ${name} again${via}`;+ }+ if (action === 'request.accept') return `${who} accepted ${name}, asked by ${asker}${fld('from', 'owner', '') ? ` (was ${fld('from', 'owner', '')})` : ''}${q(e.reply)}${via}`;+ if (action === 'request.decline') return `${who} declined ${name}, asked by ${asker}${q(e.reply)}${via}`;+ if (action === 'request.withdraw') return `${who} withdrew the request for ${to} to take ${name}${via}`;+ if (action === 'request.reopen') return `${who} took back their ${e.was === 'accepted' ? 'accept' : e.was === 'declined' ? 'decline' : 'withdrawal'}: ${to} is asked to take ${name} again${via}`;+ if (action === 'request.close') return `${who} closed the request for ${to} to take ${name} (${e.why || 'closed'})${via}`;+ }+ if (action === 'notice.dates') {+ const n = typeof e.count === 'number' ? e.count : 1, to = fld('to', 'owner', '') || 'someone';+ const nm = Array.isArray(e.names) ? e.names.filter(x => typeof x === 'string').slice(0, 3).map(x => `"${x}"`).join(', ') : '';+ return n === 1 ? `${who} moved the dates of ${to}'s task ${nm || 'a task'}, and ${to} was told${via}`+ : `${who} moved the dates of ${n} of ${to}'s tasks${nm ? ' (' + nm + (n > 3 ? ' and more' : '') + ')' : ''}, and ${to} was told${via}`;+ }+ if (action === 'people.link') return `${who} linked the owner name "${e.name || '?'}" to ${fld('to', 'account', '') === 'none' ? 'no Adom account' : fld('to', 'account', '') || 'nobody (unlinked)'}${via}`;+ if (action === 'ai.answers') return `${who} ${fld('to', 'on', false) ? 'let their AI answer requests for them' : 'stopped their AI answering requests for them'}${via}`;+ if (action === 'requests.mode') return `${who} turned requests ${fld('to', 'requests', 'on')} for this plan${via}`; if (action === 'owner') { const f = fld('from', 'owner', ''), t = fld('to', 'owner', ''); if (!f) return `${who} assigned ${name} to ${t || 'nobody'}${via}`;@@ -1569,8 +1609,12 @@ function moveInOrder(order, uid, after) { order.splice(after === null ? 0 : order.indexOf(after) + 1, 0, uid); return true; }-function applyOpsTo(state, data, mergedByUid, ops, mayDate) {+function applyOpsTo(state, data, mergedByUid, ops, mayDate, rq) { const out = [], refused = [];+ // The request engine (0.80.0), when the caller has one: an owner change onto another+ // person becomes a request here, whoever sent it, and the request ops are applied in order+ // with the rest of the batch. Without one (a test harness, an import) owners are plain fields.+ const R = rq ? reqEngine(state, mergedByUid, rq, refused) : null; ['items', 'added', 'deleted'].forEach(k => { if (!Array.isArray(state[k])) state[k] = []; }); const entries = new Map(); state.items.forEach(o => { if (o && o.uid) entries.set(o.uid, o); });@@ -1618,6 +1662,9 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { if (!okDates || typeof row.name !== 'string' || !row.name.trim()) { refused.push('add: ' + a.uid + ' has no name or dates'); continue; } if (!Number.isInteger(row.phase) || row.phase < 0) row.phase = 0; if (row.deps !== undefined) row.deps = opValue('deps', row.deps) || [];+ // A new row made for somebody else is made unassigned and asked of them (0.80.0).+ let askTo = '';+ if (R && typeof row.owner === 'string' && row.owner.trim() && R.decide(row.uid, '', row.owner) === 'request') { askTo = row.owner.trim().replace(/\s+/g, ' ').slice(0, 200); row.owner = ''; } 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.@@ -1644,6 +1691,7 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { mergedByUid.set(row.uid, Object.assign({}, row)); out.push({ op: 'add', row: Object.assign({}, row), uid: row.uid }); orderTouched = true;+ if (askTo) R.make(row.uid, askTo, '', { created: true, note: a.reqNote, id: a.reqId }); } else if (op.op === 'del') { if (!uid) { refused.push('del: no uid'); continue; } // A created row stays in `added` as well, so an undo of the deletion, or an edit from@@ -1651,6 +1699,7 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { if (!dataUids.has(uid) && !isAdded(uid)) { refused.push('del: unknown ' + uid); continue; } if (!state.deleted.includes(uid)) state.deleted.push(uid); out.push({ op: 'del', uid });+ if (R) R.close(uid, 'row deleted'); orderTouched = true; } else if (op.op === 'row') { // An edit to a row somebody deleted, from a tab that had not heard of the deletion yet,@@ -1679,12 +1728,22 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { } if (!placeOk) refused.push('row ' + uid + ': place'); }+ // The dates as they were, for a notice to the row's owner if this moves them (0.80.0).+ const datesWas = ms ? { date: o.date } : { s: o.s, e: o.e }; for (const f of Object.keys(set)) { 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; }+ // An owner onto somebody else is asked of them, never set (0.80.0). The field is not+ // taken: the sender's page puts the old owner back when its batch settles.+ if (f === 'owner' && R) {+ const cur = typeof o.owner === 'string' ? o.owner : ((mergedByUid.get(uid) || {}).owner || '');+ const d = R.decide(uid, cur, v);+ if (d === 'request') { R.make(uid, v, cur, { note: op.reqNote, id: op.reqId }); continue; }+ if (d === 'direct') R.direct(uid, cur, v);+ } o[f] = Array.isArray(v) ? v.slice() : v; done[f] = true; } // The two lists merge item by item: what this tab took out, then what it put in.@@ -1708,6 +1767,10 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { } const fields = Object.keys(done); if (!fields.length) continue;+ if (R && (done.s || done.e || done.date)) {+ const datesNow = ms ? { date: o.date } : { s: o.s, e: o.e };+ if (JSON.stringify(datesNow) !== JSON.stringify(datesWas)) R.moved(uid, datesWas, datesNow);+ } 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;@@ -1735,8 +1798,16 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { if (!mayDate) { refused.push(op.op + ': only the owner'); continue; } if (op.op === 'dates') { const d = normalizeDates(op.dates); if (!d) { refused.push('dates'); continue; } state.dates = d; out.push({ op: 'dates', dates: Object.assign({}, d) }); } else { const c = normalizeCalendar(op.calendar); if (!c) { refused.push('cal'); continue; } state.calendar = c; out.push({ op: 'cal', calendar: JSON.parse(JSON.stringify(c)) }); }+ } else if (R && R.handles(op.op)) {+ const ch = R.apply(op);+ // An answer can change a row's owner: the row op is announced like any other.+ if (ch && ch.owner !== undefined) {+ const e = entryFor(ch.uid); e.owner = ch.owner;+ out.push({ op: 'row', uid: ch.uid, set: { owner: ch.owner } });+ } } else refused.push('unknown op ' + String(op.op).slice(0, 20)); }+ if (R) R.flush(out); // The order is kept complete, every live row exactly once, because the merge reads it // all or nothing. A row it does not list yet (just created) goes to the end of its own // section, then the moves are replayed on top, in the order they were made.@@ -1788,6 +1859,399 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { out.sort((a, b) => rank(a) - rank(b)); return { out, refused }; }+// ── Requests: adding and moving tasks to other people (0.80.0) ──+//+// Drew: "we need to add a request flow for adding/moving tasks to other people, which both+// humans and AI should honor", and then: the point is not to force anyone into the app, it is+// to keep people informed when new work is put on them. So an owner change onto another+// PERSON (a name linked to an Adom account) is not applied: it becomes a REQUEST that person+// accepts or declines, the pattern Linear's triage, Asana's approvals and Jira's approvals+// share (a pending object on the item, an inbox, Accept or Decline with an optional note, the+// requester sees the answer, a pending one can be withdrawn). A name with no account is+// assigned directly, and marked so the owner links it. Moving the dates of somebody else's+// task is a NOTICE to them (one per batch per person), not a request. Enforced here, so a tab+// of any version, a script's whole-state /save and the agent key all go through it. See+// .gantt-gate/DESIGN-requests.md and BUILD-NOTES.+//+// Kept in the sidecar: `requests`, `notices`, `people` (names linked to accounts, by the+// plan's owner on Setup or by an accept) and `delegates` (people who let their AI answer for+// them), so they ride the same revisions, announcements, catch-ups and backups as the plan.+// The accounts that can be linked are the people who have signed in to this plan, kept in+// `<plan>-accounts.json` beside it.+const REQ_NOTE_MAX = 500, REQ_KEEP_MS = 30 * 86400000, REQ_MAX = 500, REQ_DECLINE_MS = 1 * 86400000;+const NOTICE_KEEP_MS = 14 * 86400000, NOTICE_MAX = 300, NOTICE_MERGE_MS = 10 * 60000, NOTICE_ROWS = 30;+const REQ_OPS = new Set(['req', 'req.answer', 'req.withdraw', 'req.reopen', 'people.link', 'ai.answers']);+const REQ_ID = /^rq-[A-Za-z0-9-]{4,48}$/;+function normName(s) { return String(s == null ? '' : s).trim().replace(/^@/, '').replace(/\s+/g, ' ').toLowerCase(); }+function cleanName(s) { return String(s == null ? '' : s).trim().replace(/\s+/g, ' ').slice(0, 200); }+function reqNote(v) { return typeof v === 'string' ? oneLine(v).slice(0, REQ_NOTE_MAX) : ''; }+function personId(u) { return u ? 'u:' + (u.id || u.username || u.displayName) : ''; }++// ── The accounts a name can be linked to: everyone who has signed in to this plan ──+const ACCOUNTS_FILE = DATA_FILE.replace(/\.json$/, '-accounts.json');+let accountsMem = null;+function accountsLoad() {+ if (accountsMem) return accountsMem;+ try { const o = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, 'utf8')); accountsMem = o && typeof o === 'object' && !Array.isArray(o) ? o : {}; }+ catch (e) { accountsMem = {}; }+ return accountsMem;+}+function accountOf(u) {+ return { id: personId(u), name: u.displayName || u.username || '', username: u.username || '', email: u.email || '' };+}+// Recorded at sign-in and whenever a signed-in person writes or opens the live stream; written+// only when something about them is new, so it costs nothing per request.+function noteAccount(u) {+ if (!u || !(u.id || u.username)) return;+ const a = accountOf(u), all = accountsLoad(), old = all[a.id];+ if (old && old.name === a.name && old.username === a.username && old.email === a.email) return;+ all[a.id] = Object.assign({}, old || {}, a, { seen: new Date().toISOString() });+ try { fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(all, null, 1) + '\n', { encoding: 'utf8', mode: 0o600 }); } catch (e) {}+ if (!old) broadcastLive({ type: 'accounts', accounts: accountsPublic() });+}+// The plan's members can see who else is on it: names and usernames, never emails.+function accountsPublic() { return Object.values(accountsLoad()).map(a => ({ id: a.id, name: a.name, username: a.username })); }+// Everyone the server already knows, at startup: the sign-ins kept in the sessions file.+function seedAccounts() { for (const s of sessions.values()) { try { noteAccount(s.user); } catch (e) {} } }++// The names an account answers to: its display name, username, email and the part before+// the @ ("exact"), and the first word of its display name ("Drew" for Drew Owens).+function accountKeys(a) {+ const exact = new Set(), add = v => { const n = normName(v); if (n) exact.add(n); };+ add(a.name || a.displayName); add(a.username);+ if (a.email) { add(a.email); add(String(a.email).split('@')[0]); }+ const first = normName(a.name || a.displayName).split(' ')[0];+ return { exact, first: first && first.length >= 2 ? first : '' };+}+// Who an owner name is. A link made on Setup (or by an accept) decides; otherwise the accounts+// that match it exactly, else those whose first name it is. `how`: linked, match, first,+// shared (two or more people share that first name: still asked, and Setup warns), none (no+// account: assigned directly, and marked so it gets linked), off (the owner said "no account").+function resolveName(name, people, accounts) {+ const n = normName(name);+ if (!n) return { ids: [], how: 'none' };+ const link = people && typeof people === 'object' ? people[n] : null;+ if (link && typeof link === 'object') {+ if (link.none) return { ids: [], how: 'off' };+ if (link.id) return { ids: [link.id], how: 'linked' };+ }+ const list = Object.values(accounts || accountsLoad());+ const exact = list.filter(a => accountKeys(a).exact.has(n)).map(a => a.id);+ if (exact.length) return { ids: exact, how: exact.length > 1 ? 'shared' : 'match' };+ const first = list.filter(a => accountKeys(a).first === n).map(a => a.id);+ if (first.length) return { ids: first, how: first.length > 1 ? 'shared' : 'first' };+ return { ids: [], how: 'none' };+}+function nameIsPerson(name, u, people) { return !!u && resolveName(name, people).ids.includes(personId(u)); }+function requestsOn() { if (AUTH_OFF) return false; return accessLoad().requests !== 'off'; }+// What the request engine needs to know about the caller.+function reqCtx(req, auth) {+ const who = whoOf(auth);+ if (auth.user) noteAccount(auth.user);+ return { on: requestsOn(), who: { id: who.id, name: who.name }, user: auth.user || null, agent: !!auth.agent,+ planOwner: auth.mode === 'off' || isOwner(auth.user, accessView()),+ admin: !!auth.agent || auth.mode === 'off' || isOwner(auth.user, accessView()),+ events: [], requested: [], now: Date.now() };+}+// The last direct owner change on each row, and who made it, so putting the owner back is an+// undo and not a new assignment (an old tab's Ctrl+Z, a slip). In memory: a restart forgets+// it, and the worst that does is turn one undo into a request.+const ownerWas = new Map();+// Owner changes the server turned into requests a moment ago, so the changelog line an older+// tab posts for its own change (it posts lines to /log on its own) is not stored as a+// reassignment that never happened.+const reqRecent = [];+function reqEngine(state, mergedByUid, ctx, refused) {+ if (!Array.isArray(state.requests)) state.requests = [];+ if (!Array.isArray(state.notices)) state.notices = [];+ if (!state.people || typeof state.people !== 'object' || Array.isArray(state.people)) state.people = {};+ if (!state.delegates || typeof state.delegates !== 'object' || Array.isArray(state.delegates)) state.delegates = {};+ const list = state.requests, notices = state.notices, touched = new Set(), touchedN = new Set(), now = new Date(ctx.now).toISOString();+ let peopleTouched = false, delegatesTouched = false;+ const moved = new Map(); // owner name (normalised) -> {to, ids, rows: Map(uid -> {was, now})}+ const rowOf = uid => mergedByUid.get(uid) || null;+ const items = () => Array.isArray(state.items) ? state.items : [];+ const ownerOf = uid => { const e = items().find(o => o && o.uid === uid); return e && typeof e.owner === 'string' ? e.owner : ((rowOf(uid) || {}).owner || ''); };+ const find = id => list.find(r => r && r.id === id) || null;+ // Through JSON, so no key is left holding undefined: the chain hashes what is stored.+ const push = e => ctx.events.push(JSON.parse(JSON.stringify(Object.assign({}, e, { via: e.via || 'request' }))));+ const ev = (action, r, extra) => {+ const row = rowOf(r.uid) || {};+ push(Object.assign({ action, uid: r.uid, id: row.id || undefined, name: row.name || r.name || '', reqId: r.id,+ requester: r.from && r.from.name, to: { owner: r.to } }, extra || {}));+ };+ const res = name => resolveName(name, state.people);+ const isMe = name => !ctx.agent && !!ctx.user && res(name).ids.includes(ctx.who.id);+ const api = {+ handles: k => REQ_OPS.has(k),+ // 'same' (no change), 'direct' (apply it) or 'request' (ask instead).+ decide(uid, cur, v) {+ const to = cleanName(v);+ if (normName(to) === normName(cur)) return 'same';+ if (!ctx.on || !to) return 'direct';+ const r = res(to);+ // A name with no Adom account behind it cannot answer: assigned directly (Drew, 0.80.0).+ if (!r.ids.length) return 'direct';+ if (!ctx.agent && r.ids.includes(ctx.who.id)) return 'direct';+ const w = ownerWas.get(uid);+ if (w && w.by === ctx.who.id && normName(w.owner) === normName(to)) return 'direct';+ return 'request';+ },+ direct(uid, cur, v) {+ const w = ownerWas.get(uid);+ if (w && w.by === ctx.who.id && normName(w.owner) === normName(v)) ownerWas.delete(uid);+ else ownerWas.set(uid, { owner: cur, by: ctx.who.id });+ api.close(uid, 'owner changed');+ },+ make(uid, toIn, was, o) {+ o = o || {};+ const to = cleanName(toIn), row = rowOf(uid);+ if (!to || !row) { refused.push('req: ' + (row ? 'no name' : 'unknown ' + uid)); return null; }+ if (o.id && REQ_ID.test(o.id) && find(o.id)) return find(o.id); // sent twice+ // The AI honours a decline: the agent key may not ask the same name for the same row+ // again for a day (Drew: 1 day). A person may.+ if (ctx.agent) {+ const no = list.find(r => r && r.uid === uid && r.state === 'declined' && normName(r.to) === normName(to) && ctx.now - (Date.parse(r.done) || 0) < REQ_DECLINE_MS);+ if (no) { refused.push(`req ${uid}: ${no.to} declined it on ${String(no.done).slice(0, 16).replace('T', ' ')} UTC${no.reply ? ' ("' + no.reply + '")' : ''}; ask a person before asking again`); return null; }+ }+ const open = list.find(r => r && r.uid === uid && r.state === 'pending');+ if (open && normName(open.to) === normName(to)) { ctx.requested.push({ id: open.id, uid, to: open.to, state: 'pending', again: true }); return open; }+ if (open) api.closeOne(open, 'replaced by a newer request');+ const r = { id: o.id && REQ_ID.test(o.id) ? o.id : 'rq-' + ctx.now.toString(36) + '-' + crypto.randomBytes(3).toString('hex'),+ uid, to, was: cleanName(was), from: { id: ctx.who.id, name: ctx.who.name }, at: now, state: 'pending' };+ if (o.created) r.created = true;+ const note = reqNote(o.note); if (note) r.note = note;+ list.push(r); touched.add(r.id);+ ctx.requested.push({ id: r.id, uid, to, state: 'pending' });+ reqRecent.push({ actor: ctx.who.name, uid, rowId: row.id || '', rowName: row.name || '', to: normName(to), at: ctx.now });+ while (reqRecent.length > 200 || (reqRecent.length && ctx.now - reqRecent[0].at > 600000)) reqRecent.shift();+ ev('request', r, { from: { owner: r.was }, note: note || undefined, created: r.created || undefined });+ return r;+ },+ closeOne(r, why) {+ r.state = 'closed'; r.by = { id: ctx.who.id, name: ctx.who.name }; r.done = now; r.why = why;+ touched.add(r.id); ev('request.close', r, { why });+ },+ close(uid, why) { list.filter(r => r && r.uid === uid && r.state === 'pending').forEach(r => api.closeOne(r, why)); },+ // A date of somebody else's task moved: noted for one notice per person at the end of the+ // batch. `was` and `nowV` are {s, e} or {date}.+ moved(uid, was, nowV) {+ if (!ctx.on) return;+ const owner = cleanName(ownerOf(uid)); if (!owner) return;+ const r = res(owner); if (!r.ids.length) return;+ if (!ctx.agent && r.ids.includes(ctx.who.id)) return;+ const k = normName(owner);+ if (!moved.has(k)) moved.set(k, { to: owner, ids: r.ids, rows: new Map() });+ const g = moved.get(k).rows, prev = g.get(uid);+ g.set(uid, { was: prev ? prev.was : was, now: nowV });+ },+ // The person a name or username given by the agent key stands for (`for` on an answer).+ personFor(v) {+ const n = normName(v); if (!n) return null;+ const all = accountsLoad(), byId = all[v] || null;+ if (byId) return byId;+ const hits = Object.values(all).filter(a => accountKeys(a).exact.has(n));+ return hits.length === 1 ? hits[0] : null;+ },+ // req, req.answer, req.withdraw, req.reopen, people.link, ai.answers.+ // Returns {uid, owner} when a row's owner moves.+ apply(op) {+ if (op.op === 'people.link') {+ // Linking a name to an account is the plan owner's (Setup), or the server operator's.+ if (!ctx.admin) { refused.push('people.link: only the plan\'s owner links names to accounts'); return null; }+ const n = normName(op.name); if (!n) { refused.push('people.link: no name'); return null; }+ if (op.account === null || op.account === undefined || op.account === '') delete state.people[n];+ else if (op.account === 'none') state.people[n] = { none: true, as: cleanName(op.name) };+ else {+ const a = accountsLoad()[op.account];+ if (!a) { refused.push('people.link: no such account ' + String(op.account).slice(0, 40)); return null; }+ state.people[n] = { id: a.id, name: a.name, username: a.username, as: cleanName(op.name) };+ }+ peopleTouched = true;+ push({ action: 'people.link', name: cleanName(op.name), to: { account: state.people[n] ? (state.people[n].none ? 'none' : state.people[n].name) : '' }, via: 'setup' });+ return null;+ }+ if (op.op === 'ai.answers') {+ // A person lets their AI answer requests for them, or stops it. Only for themselves.+ if (ctx.agent || !ctx.user) { refused.push('ai.answers: only a signed-in person can say this, for themselves'); return null; }+ if (op.on === true) state.delegates[ctx.who.id] = { on: true, name: ctx.who.name, at: now };+ else delete state.delegates[ctx.who.id];+ delegatesTouched = true;+ push({ action: 'ai.answers', to: { on: op.on === true }, via: 'requests' });+ return null;+ }+ if (op.op === 'req') {+ const uid = typeof op.uid === 'string' ? op.uid : '';+ if (!rowOf(uid)) { refused.push('req: unknown ' + uid); return null; }+ if (!ctx.on) { refused.push('req: requests are off on this plan; set the owner'); return null; }+ const to = cleanName(op.to);+ if (!to) { refused.push('req ' + uid + ': no name'); return null; }+ if (isMe(to)) { refused.push('req ' + uid + ': that is you; set the owner'); return null; }+ if (!res(to).ids.length) { refused.push('req ' + uid + ': ' + to + ' has no Adom account linked on this plan; set the owner (it is assigned directly)'); return null; }+ const cur = ownerOf(uid);+ if (normName(cur) === normName(to)) { refused.push('req ' + uid + ': ' + to + ' already owns it'); return null; }+ api.make(uid, to, cur, { note: op.note, id: op.id, created: op.created === true });+ return null;+ }+ const r = find(op.id);+ if (!r) { refused.push(op.op + ': unknown ' + String(op.id).slice(0, 60)); return null; }+ // The agent key acts for a person only when that person has said it may (Drew, 0.80.0),+ // and says whom: `for` is their username or name.+ let actFor = null;+ if (ctx.agent && (op.op === 'req.answer' || op.op === 'req.reopen')) {+ actFor = op.for !== undefined ? api.personFor(op.for) : null;+ if (!actFor) { refused.push(`${op.op} ${r.id}: the agent key answers only for a named person ("for": their username) who lets their AI answer for them`); return null; }+ if (!(state.delegates[actFor.id] && state.delegates[actFor.id].on)) { refused.push(`${op.op} ${r.id}: ${actFor.name} has not let their AI answer requests for them (Requests, in the header)`); return null; }+ }+ const by = actFor ? { id: 'agent', name: AGENT_ACTOR, for: { id: actFor.id, name: actFor.name } } : { id: ctx.who.id, name: ctx.who.name };+ const whose = actFor ? actFor.id : ctx.who.id;+ if (op.op === 'req.answer') {+ const yes = op.accept === true;+ if (r.state !== 'pending') {+ // Sent twice, or answered by somebody else first: say which.+ if (!(r.by && r.by.id === by.id && r.state === (yes ? 'accepted' : 'declined'))) refused.push(`req.answer ${r.id}: already ${r.state}${r.by ? ' by ' + r.by.name : ''}`);+ return null;+ }+ const mine = res(r.to).ids.includes(whose);+ if (!mine && !(ctx.planOwner && !actFor)) { refused.push(`req.answer ${r.id}: only ${r.to}${actFor ? '' : ' or the plan\'s owner'} can answer it`); return null; }+ if (!rowOf(r.uid)) { api.closeOne(r, 'row deleted'); refused.push(`req.answer ${r.id}: the row is gone`); return null; }+ r.state = yes ? 'accepted' : 'declined'; r.by = by; r.done = now;+ const reply = reqNote(op.note); if (reply) r.reply = reply; else delete r.reply;+ touched.add(r.id);+ const onBehalf = actFor ? actFor.name : undefined;+ if (!yes) { ev('request.decline', r, { reply: reply || undefined, onBehalf }); return null; }+ // The name is theirs from now on, so a namesake cannot answer for them.+ if (mine) { const k = normName(r.to), l = state.people[k];+ if (!l || l.id !== whose) { const a = accountsLoad()[whose] || { id: whose, name: ctx.who.name, username: ctx.user ? ctx.user.username : '' };+ state.people[k] = { id: a.id, name: a.name, username: a.username || '', as: r.to }; peopleTouched = true; } }+ const from = ownerOf(r.uid);+ ownerWas.delete(r.uid);+ ev('request.accept', r, { from: { owner: from }, reply: reply || undefined, onBehalf });+ return { uid: r.uid, owner: r.to };+ }+ if (op.op === 'req.withdraw') {+ if (r.state !== 'pending') { if (!(r.state === 'withdrawn' && r.by && r.by.id === ctx.who.id)) refused.push(`req.withdraw ${r.id}: already ${r.state}`); return null; }+ if (!(r.from && r.from.id === ctx.who.id) && !ctx.planOwner) { refused.push(`req.withdraw ${r.id}: only ${r.from ? r.from.name : 'the requester'} or the plan's owner can withdraw it`); return null; }+ r.state = 'withdrawn'; r.by = { id: ctx.who.id, name: ctx.who.name }; r.done = now; touched.add(r.id);+ ev('request.withdraw', r);+ return null;+ }+ if (op.op === 'req.reopen') {+ // Undo of an answer or a withdrawal, by whoever made it.+ if (r.state === 'pending') return null;+ const same = r.by && r.by.id === by.id && (!actFor || (r.by.for && r.by.for.id === actFor.id));+ if (r.state === 'closed' || !same) { refused.push(`req.reopen ${r.id}: only ${r.by ? r.by.name : 'whoever answered it'} can take back their answer`); return null; }+ if (!rowOf(r.uid)) { refused.push(`req.reopen ${r.id}: the row is gone`); return null; }+ const other = list.find(x => x && x !== r && x.uid === r.uid && x.state === 'pending');+ if (other) { refused.push(`req.reopen ${r.id}: ${other.to} has been asked since`); return null; }+ const was = r.state; let ch = null;+ if (was === 'accepted' && normName(ownerOf(r.uid)) === normName(r.to)) ch = { uid: r.uid, owner: r.was || '' };+ r.state = 'pending'; delete r.by; delete r.done; delete r.reply; touched.add(r.id);+ ev('request.reopen', r, { was, onBehalf: actFor ? actFor.name : undefined });+ return ch;+ }+ return null;+ },+ // The notices of this batch (one per person, merged into that person's last one from the+ // same hand within ten minutes), then the requests it changed, whole, after its rows; old+ // ones pruned.+ flush(out) {+ moved.forEach(g => {+ const rows = [...g.rows].map(([uid, m]) => ({ uid, name: (rowOf(uid) || {}).name || '', was: m.was, now: m.now }));+ const last = notices.slice().reverse().find(n => n && n.kind === 'dates' && normName(n.to) === normName(g.to) && n.from && n.from.id === ctx.who.id && ctx.now - (Date.parse(n.upd || n.at) || 0) < NOTICE_MERGE_MS);+ let n = last;+ if (n) {+ const byUid = new Map((n.rows || []).map(x => [x.uid, x]));+ rows.forEach(x => { const p = byUid.get(x.uid); byUid.set(x.uid, p ? Object.assign({}, x, { was: p.was }) : x); });+ n.rows = [...byUid.values()].slice(-NOTICE_ROWS); n.count = Math.max(n.count || 0, byUid.size); n.upd = now; n.ids = g.ids;+ } else {+ n = { id: 'nt-' + ctx.now.toString(36) + '-' + crypto.randomBytes(3).toString('hex'), kind: 'dates', to: g.to, ids: g.ids,+ from: { id: ctx.who.id, name: ctx.who.name }, at: now, upd: now, count: rows.length, rows: rows.slice(-NOTICE_ROWS) };+ notices.push(n);+ }+ touchedN.add(n.id);+ // One changelog line per person per batch: the Chat digest's "moved 3 of Ana's tasks".+ push({ action: 'notice.dates', to: { owner: g.to }, count: rows.length, names: rows.slice(0, 6).map(x => x.name), noticeId: n.id, via: 'notice' });+ });+ const keep = ctx.now - REQ_KEEP_MS;+ let kept = list.filter(r => r && (r.state === 'pending' || touched.has(r.id) || (Date.parse(r.done || r.at) || 0) >= keep));+ if (kept.length > REQ_MAX) { const open = kept.filter(r => r.state === 'pending' || touched.has(r.id)); kept = open.concat(kept.filter(r => !open.includes(r)).slice(-(REQ_MAX - Math.min(REQ_MAX, open.length)))); }+ if (kept.length !== list.length) { list.length = 0; kept.forEach(r => list.push(r)); }+ const nkeep = ctx.now - NOTICE_KEEP_MS;+ const nk = notices.filter(n => n && (touchedN.has(n.id) || (Date.parse(n.upd || n.at) || 0) >= nkeep)).slice(-NOTICE_MAX);+ if (nk.length !== notices.length) { notices.length = 0; nk.forEach(n => notices.push(n)); }+ touched.forEach(id => { const r = find(id); if (r) out.push({ op: 'req', req: JSON.parse(JSON.stringify(r)) }); });+ touchedN.forEach(id => { const n = notices.find(x => x.id === id); if (n) out.push({ op: 'notice', notice: JSON.parse(JSON.stringify(n)) }); });+ if (peopleTouched) out.push({ op: 'people', people: JSON.parse(JSON.stringify(state.people)) });+ if (delegatesTouched) out.push({ op: 'delegates', delegates: JSON.parse(JSON.stringify(state.delegates)) });+ if (!list.length) delete state.requests;+ if (!notices.length) delete state.notices;+ if (!Object.keys(state.people).length) delete state.people;+ if (!Object.keys(state.delegates).length) delete state.delegates;+ },+ };+ return api;+}+// A whole-state /save (a script, a tab older than 0.77.0, the agent) is checked the same way+// as ops: every row whose owner it moves onto somebody else keeps its old owner in what is+// stored (a new row is stored unassigned) and a request is made; every date it moves on+// somebody else's task is a notice to them. It can never write the requests, the notices, the+// names or who lets their AI answer: those are always the server's.+function saveGuardOwners(store, ctx) {+ if (!store || typeof store !== 'object' || Array.isArray(store)) return;+ let old = null; try { old = loadState(); } catch (e) {}+ ['requests', 'notices', 'people', 'delegates'].forEach(k => {+ delete store[k];+ if (old && old[k] !== undefined) store[k] = cloneJson(old[k]);+ });+ let before, after;+ try { before = mergedData(); after = mergedFrom(loadData(), cloneJson(store)); } catch (e) { return; }+ const was = new Map((before.items || []).map(r => [r && r.uid, r]));+ const mergedByUid = new Map((after.items || []).map(r => [r && r.uid, r]));+ if (!Array.isArray(store.items)) store.items = [];+ const R = reqEngine(store, mergedByUid, ctx, []);+ // Rows that are gone close their requests.+ was.forEach((r, uid) => { if (uid && !mergedByUid.has(uid)) R.close(uid, 'row deleted'); });+ mergedByUid.forEach((r, uid) => {+ if (!uid) return;+ const b = was.get(uid), cur = b ? (b.owner || '') : '', v = r.owner || '';+ if (b) {+ const dw = b.type === 'milestone' ? { date: b.date } : { s: b.start, e: b.end }, dn = r.type === 'milestone' ? { date: r.date } : { s: r.start, e: r.end };+ if (JSON.stringify(dw) !== JSON.stringify(dn)) R.moved(uid, dw, dn);+ }+ const d = R.decide(uid, cur, v);+ if (d === 'same') return;+ if (d === 'direct') { R.direct(uid, cur, v); return; }+ // Put the stored owner back where the save changed it: the row's override, and a new+ // row's own field (unassigned while it is asked).+ const ov = store.items.find(o => o && o.uid === uid);+ const add = Array.isArray(store.added) ? store.added.find(a => a && a.uid === uid) : null;+ if (ov && 'owner' in ov) ov.owner = cur;+ if (add && (!b || !ov || !('owner' in ov))) add.owner = b ? cur : '';+ R.make(uid, v, cur, { created: !b });+ });+ R.flush([]);+}+// A changelog line an older tab posts for an owner change the server made a request of: it+// did not happen, so it is not stored.+function replacedOwnerLine(e, actor) {+ if (!e || e.action !== 'owner' || !e.to || typeof e.to.owner !== 'string') return false;+ const to = normName(e.to.owner), now = Date.now();+ return reqRecent.some(x => x.actor === actor && x.to === to && now - x.at < 600000 &&+ ((e.uid && e.uid === x.uid) || (e.id && e.id === x.rowId) || (e.key && e.key === x.uid) || (!e.uid && !e.id && e.name && e.name === x.rowName)));+}+// Every owner name on the plan, who it is, and how many rows carry it (GET /people, the Setup+// card's list, and how many names are not linked yet).+function peopleView(merged, state) {+ const people = state && state.people && typeof state.people === 'object' ? state.people : {};+ const names = new Map();+ (merged.items || []).forEach(r => { const o = cleanName(r && r.owner); if (!o) return; const k = normName(o); if (!names.has(k)) names.set(k, { name: o, rows: 0 }); names.get(k).rows++; });+ Object.keys(people).forEach(k => { if (!names.has(k)) names.set(k, { name: (people[k] && people[k].as) || k, rows: 0 }); });+ const acc = accountsLoad();+ return [...names.entries()].map(([k, v]) => { const r = resolveName(v.name, people, acc);+ return { name: v.name, rows: v.rows, how: r.how, accounts: r.ids.map(id => acc[id] ? { id, name: acc[id].name, username: acc[id].username } : { id, name: (people[k] && people[k].name) || id, username: '' }) }; })+ .sort((a, b) => a.name.localeCompare(b.name));+} function resyncOnStart() { try { let st = loadState();@@ -4620,6 +5084,7 @@ const server = http.createServer(async (req, res) => { catch (e) { jsonResp(res, 502, { ok: false, error: 'could not read your Adom profile' }); return; } try { user.orgs = await carbonOrgs(st.session_token); } catch (e) { user.orgs = []; } const sid = newSession(user);+ try { noteAccount(user); } catch (e) {} setCookie(req, res, sid); jsonResp(res, 200, { ok: true, user }); return;@@ -4744,6 +5209,9 @@ const server = http.createServer(async (req, res) => { 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 + ')'); }+ // Requests (0.80.0): a whole-state save cannot assign work to somebody else either.+ const rq = reqCtx(req, auth);+ saveGuardOwners(store, rq); // 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.@@ -4769,8 +5237,9 @@ const server = http.createServer(async (req, res) => { const cid = typeof incoming.client === 'string' ? incoming.client.slice(0, 40) : ''; delete store.client; delete incoming.client; writeAtomic(STATE_FILE, JSON.stringify(store));+ if (rq.events.length) { try { appendLog(rq.events, actorOf(auth)); } catch (e) {} } incoming.stateRev = store.stateRev;- jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length, stateRev: incoming.stateRev });+ jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length, stateRev: incoming.stateRev, requested: rq.requested }); broadcastLive({ type: 'rev', stateRev: store.stateRev, by: cid, actor: whoOf(auth).name, ts: Date.now() }, cid); } catch (e) { jsonResp(res, 400, { ok: false, error: e.message });@@ -4796,16 +5265,22 @@ const server = http.createServer(async (req, res) => { const mergedByUid = new Map(); (mergedData().items || []).forEach(r => { if (r && r.uid) mergedByUid.set(r.uid, r); }); const mayDate = !!auth.agent || isDirectLoopback(req) || isOwner(auth.user, accessView());- const { out, refused } = applyOpsTo(state, data, mergedByUid, p.ops, mayDate);+ const rq = reqCtx(req, auth);+ const { out, refused } = applyOpsTo(state, data, mergedByUid, p.ops, mayDate, rq); const cur = stateRev(); const cid = typeof p.client === 'string' ? p.client.slice(0, 40) : ''; const batch = typeof p.batch === 'string' ? p.batch.slice(0, 40) : '';- if (!out.length) { jsonResp(res, 200, { ok: true, stateRev: cur, ops: [], refused }); return; }+ // `requested` (0.80.0): the owner changes that became requests, so a caller (the agent+ // above all) can tell "asked" from "assigned".+ if (!out.length) { jsonResp(res, 200, { ok: true, stateRev: cur, ops: [], refused, requested: rq.requested }); return; } state.v = 4; state.stateRev = cur + 1; delete state.baseRev; delete state.client; writeAtomic(STATE_FILE, JSON.stringify(state));- jsonResp(res, 200, { ok: true, stateRev: state.stateRev, ops: out, refused });+ // The server writes the request lines itself, as whoever sent the batch: a request is+ // the server's decision, so no page can leave it out of the history or say it differently.+ if (rq.events.length) { try { appendLog(rq.events, actorOf(auth)); } catch (e) { console.error('adom-gantt: could not log a request:', e.message); } }+ jsonResp(res, 200, { ok: true, stateRev: state.stateRev, ops: out, refused, requested: rq.requested }); broadcastLive({ type: 'rev', stateRev: state.stateRev, by: cid, batch, actor: whoOf(auth).name, ts: Date.now(), ops: out }); } catch (e) { jsonResp(res, 500, { ok: false, error: e.message });@@ -4848,7 +5323,12 @@ const server = http.createServer(async (req, res) => { // takes this name over any the events carry. const who = auth.agent ? AGENT_ACTOR : (auth.user ? (auth.user.displayName || auth.user.username) : null);- const out = appendLog(events, who || (payload && payload.actor));+ // An older tab posts its own `owner` line for a change the server made a request of+ // instead (0.80.0). It did not happen, so it is not stored; the server's `request`+ // line already says what did.+ const kept = events.filter(e => !replacedOwnerLine(e, who || 'user'));+ if (!kept.length) { jsonResp(res, 200, { ok: true, added: 0, dropped: events.length, rev: currentRev() }); return; }+ const out = appendLog(kept, who || (payload && payload.actor)); jsonResp(res, 200, { ok: true, added: out.added, rev: out.rev, file: path.basename(LOG_FILE) }); } catch (e) { jsonResp(res, 400, { ok: false, error: e.message });@@ -4923,7 +5403,7 @@ const server = http.createServer(async (req, res) => { jsonResp(res, 200, { ok: true, mode: a.mode, org: a.org, owner: a.owner ? { displayName: a.owner.displayName || a.owner.username || '', username: a.owner.username || '' } : null, you: you ? { displayName: you.displayName || '', username: you.username || '', orgs: Array.isArray(you.orgs) ? you.orgs : [] } : null,- youAreOwner: owner, canChange: owner || admin, authOff: AUTH_OFF });+ youAreOwner: owner, canChange: owner || admin, authOff: AUTH_OFF, requests: a.requests }); return; } if (req.method !== 'POST') { jsonResp(res, 405, { ok: false, error: 'GET or POST' }); return; }@@ -4951,6 +5431,12 @@ const server = http.createServer(async (req, res) => { jsonResp(res, 400, { ok: false, error: 'you are not a member of ' + (cur.org || 'adom') }); return; } }+ if ('requests' in body) {+ // Requests on or off (0.80.0): the owner's, like the sharing, and in the changelog.+ if (!(owner || admin)) { jsonResp(res, 403, { ok: false, error: 'only the plan\'s owner can turn requests on or off' }); return; }+ if (body.requests !== 'on' && body.requests !== 'off') { jsonResp(res, 400, { ok: false, error: 'requests is on or off' }); return; }+ if (body.requests === 'off') cur.requests = 'off'; else delete cur.requests;+ } // The owner's display name, filled the first time they are seen signed in. if (owner && you && cur.owner && !cur.owner.displayName) { cur.owner.displayName = you.displayName; if (you.id) cur.owner.id = String(you.id); } accessSave(cur);@@ -4962,6 +5448,10 @@ const server = http.createServer(async (req, res) => { auth.agent ? AGENT_ACTOR : (you ? (you.displayName || you.username) : null)); } catch (e) {} }+ if (before.requests !== after.requests) {+ try { appendLog([{ action: 'requests.mode', from: { requests: before.requests }, to: { requests: after.requests }, via: 'setup' }], actorOf(auth)); } catch (e) {}+ broadcastLive({ type: 'requests', on: after.requests === 'on', actor: whoOf(auth).name, ts: Date.now() });+ } dropRefusedLive(); jsonResp(res, 200, Object.assign({ ok: true }, after, { youAreOwner: isOwner(you, after) })); return;@@ -5015,6 +5505,45 @@ const server = http.createServer(async (req, res) => { return; } + // ── Requests (0.80.0): the list, with each row's name and owner now ──+ if (urlPath === '/requests') {+ if (req.method !== 'GET') { jsonResp(res, 405, { ok: false, error: 'GET; requests are made, answered and withdrawn with POST /ops' }); return; }+ let st = null; try { st = loadState(); } catch (e) {}+ const all = st && Array.isArray(st.requests) ? st.requests : [];+ const people = st && st.people && typeof st.people === 'object' ? st.people : {};+ const rows = new Map(); try { (mergedData().items || []).forEach(r => { if (r && r.uid) rows.set(r.uid, r); }); } catch (e) {}+ const q = parsed.query;+ const out = all.filter(r => r && (!q.state || r.state === q.state) && (!q.uid || r.uid === q.uid) && (!q.id || r.id === q.id)+ && (!q.to || normName(r.to) === normName(q.to))).map(r => {+ const row = rows.get(r.uid);+ return Object.assign({}, r, { row: row ? { name: row.name, owner: row.owner || '', id: row.id || null, type: row.type } : null });+ });+ const u = auth.user, me = personId(u);+ const body = { ok: true, on: requestsOn(), count: out.length, requests: out, people };+ if (u) body.mine = { for: out.filter(r => nameIsPerson(r.to, u, people)).map(r => r.id), asked: out.filter(r => r.from && r.from.id === me).map(r => r.id) };+ body.you = whoOf(auth);+ body.youAreOwner = auth.mode === 'off' || isOwner(u, accessView());+ // Who can be linked, who lets their AI answer, and the notices of moved dates.+ body.accounts = accountsPublic();+ body.delegates = st && st.delegates && typeof st.delegates === 'object' ? st.delegates : {};+ body.notices = st && Array.isArray(st.notices) ? st.notices : [];+ if (u) body.mine.notices = body.notices.filter(n => Array.isArray(n.ids) && n.ids.includes(me)).map(n => n.id);+ jsonResp(res, 200, body);+ return;+ }+ // Every owner name on the plan and who it is: linked, matched, shared first name, or no+ // account (0.80.0). The Setup card's list, for an agent too.+ if (urlPath === '/people') {+ let st = null; try { st = loadState(); } catch (e) {}+ const list = peopleView(mergedData(), st || {});+ jsonResp(res, 200, { ok: true, names: list, unlinked: list.filter(x => x.rows && (x.how === 'none' || x.how === 'off')).length,+ shared: list.filter(x => x.how === 'shared').length, accounts: accountsPublic(),+ // ?name=: who one name is, whether or not a row carries it yet.+ lookup: parsed.query.name ? (() => { const r = resolveName(parsed.query.name, (st || {}).people); const acc = accountsLoad();+ return { name: cleanName(parsed.query.name), how: r.how, accounts: r.ids.map(id => ({ id, name: acc[id] ? acc[id].name : id })) }; })() : undefined });+ return;+ }+ if (urlPath === '/state') { const integrity = integrityStatus(); if (fs.existsSync(STATE_FILE)) {@@ -5284,6 +5813,7 @@ process.on('uncaughtException', err => { }); if (SESSION_FILE) loadSessions();+try { seedAccounts(); } catch (e) {} // Before the door opens, so the first request bearing a token is already answerable. ensureAgentToken();
test/collab_test.mjs+89−2@@ -15,7 +15,7 @@ // 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-// subtasks milestones)+// subtasks milestones requests) // 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)@@ -165,7 +165,7 @@ async function freePort() { throw new Error(`no free port in ${PORTS[0]}..${PORTS[1]} (set COLLAB_TEST_PORTS=<lo>-<hi>)`); } const servers = [];-async function startServer(carbon) {+async function startServer(carbon, opts = {}) { const dir = mkdtempSync(join(TMP, 'plan-')); const file = join(dir, 'plan.json'); if (PLAN) {@@ -192,6 +192,9 @@ async function startServer(carbon) { const S = { base, dir, file, proc, token, log: () => log }; // The first person is the plan's owner, so the owner-only paths (critical path, Analytics) run. await fetch(base + '/access', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ owner: 'user0' }) });+ // Requests (0.80.0) are on by default with sign-in. These scenarios use the owner as an+ // everyday field that people race on, so they turn them off; the `requests` scenario keeps them.+ if (!opts.requests) await fetch(base + '/access', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ requests: 'off' }) }); servers.push(S); return S; }@@ -1100,6 +1103,89 @@ async function subtasksScenario(carbon) { // on it, and its links travel as field ops like a task's. Two people link the same gate at the // same moment, in both directions, and neither link is lost in any tab. // ═════════════════════════════════════════════════════════════════════════════+// ── Requests (0.80.0): a request, then accept or decline, across tabs ──+// Drew (the plan's owner), Kale (asked) and Noah (watching). The owner changes go in two+// ways: through the page's own path (the Table's owner cell), and straight into the page's+// model the way a tab from before 0.80.0 makes them, which only the server can catch.+async function requestsScenario(carbon) {+ scenario = 'requests';+ const S = await startServer(carbon, { requests: true });+ const A = await openPerson(S, carbon, 0), K = await openPerson(S, carbon, 1), N = await openPerson(S, carbon, 2), all = [A, K, N];+ for (const P of all) await goReady(P, S);+ await sleep(1500);+ if (!(await A.page.evaluate(() => !!window.__req))) { skip('requests', 'this build has no requests'); for (const P of all) await closePerson(P); await stopServer(S); return; }+ const srvRow = async uid => (await planRows(S, A.sid)).find(r => r.uid === uid) || null;+ const srvReqs = async () => ((await (await asUser(S, A.sid, '/requests')).json()).requests) || [];+ const tabReqs = P => P.page.evaluate(() => window.__req.list());+ const badge = P => P.page.evaluate(() => (document.getElementById('reqN') || {}).textContent || '');+ const until = async (fn, ms = 8000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(150); } return fn(); };+ const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.owner === 'Drew Owens' && !/cancel/i.test(r.status || ''));+ const notKale = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.owner && r.owner !== 'Kale Mer' && r.owner !== 'Drew Owens');+ if (rows.length < 2 || notKale.length < 2) { skip('requests', 'the plan has too few rows of Drew\'s'); for (const P of all) await closePerson(P); await stopServer(S); return; }++ // 1. Drew asks Kale through the Table's owner cell.+ const r1 = rows[0];+ await A.page.bringToFront();+ await A.page.evaluate(() => { const b = document.querySelector('.vt-btn[data-view="table"]'); if (b) b.click(); }); await sleep(800);+ const cell = `#tableView tr[data-key="t:${r1.id}"] td[data-field="owner"]`;+ await A.page.evaluate(sel => document.querySelector(sel).scrollIntoView({ block: 'center' }), cell);+ await A.page.click(cell); await sleep(150);+ await A.page.keyboard.down('Control'); await A.page.keyboard.press('KeyA'); await A.page.keyboard.up('Control');+ await A.page.keyboard.type('Kale Mer'); await A.page.keyboard.press('Enter');+ await quiesce(S, all);+ let q = (await srvReqs()).find(x => x.uid === r1.uid && x.state === 'pending');+ check('Drew sets Kale as the owner: the server holds a pending request and the row stays Drew\'s', q && q.to === 'Kale Mer' && (await srvRow(r1.uid)).owner === 'Drew Owens', JSON.stringify([q, (await srvRow(r1.uid)).owner]));+ check('every tab has the request; nobody\'s copy of the row changed', await until(async () => {+ for (const P of all) { const l = await tabReqs(P); const t = await tabRow(P, r1.uid); if (!l.some(x => x.id === q.id && x.state === 'pending') || t.owner !== 'Drew Owens') return false; } return true; }));+ check('Kale\'s badge counts it; Noah\'s does not', await until(async () => (await badge(K)) === '1' && (await badge(N)) === ''), `${await badge(K)} / ${await badge(N)}`);++ // 2. Kale accepts in his inbox: every tab shows him as the owner.+ await K.page.bringToFront(); await K.page.click('#btnReq'); await sleep(300);+ await K.page.type('#reqPanel .rq-in', 'on it');+ await K.page.click('#reqPanel .rq-b.yes');+ await quiesce(S, all);+ q = (await srvReqs()).find(x => x.id === q.id);+ check('Kale accepts: the server has the row as his and the request accepted, with his note', q.state === 'accepted' && q.reply === 'on it' && (await srvRow(r1.uid)).owner === 'Kale Mer', JSON.stringify(q));+ check('every tab shows Kale as the owner and the request accepted', await until(async () => {+ for (const P of all) { const l = await tabReqs(P); const t = await tabRow(P, r1.uid); if (!l.some(x => x.id === q.id && x.state === 'accepted') || t.owner !== 'Kale Mer') return false; } return true; }));+ check('Drew\'s badge counts the answer', await until(async () => (await badge(A)) === '1'), await badge(A));++ // 3. A change straight into the page's model, as a tab from before 0.80.0 makes it: the+ // server asks instead, and the tab puts the old owner back on its own screen.+ const r2 = rows[1];+ await setF(A, r2.uid, 'owner', 'Kale Mer'); await sent([A]); await quiesce(S, all);+ q = (await srvReqs()).find(x => x.uid === r2.uid && x.state === 'pending');+ check('an owner change that skips the page\'s own check is still a request, made by the server', q && q.to === 'Kale Mer' && (await srvRow(r2.uid)).owner === 'Drew Owens', JSON.stringify([q, (await srvRow(r2.uid)).owner]));+ check('and the tab that made it shows the old owner again, with the request', await until(async () => (await tabRow(A, r2.uid)).owner === 'Drew Owens' && (await tabReqs(A)).some(x => x.id === q.id)), JSON.stringify(await tabRow(A, r2.uid)));+ const dirty = await A.page.evaluate(() => window.__collab.dirty());+ check('with nothing left to send (it does not keep sending the owner)', dirty.length === 0 && await A.page.evaluate(() => window.__req.out()) === 0, JSON.stringify(dirty));++ // 4. Kale declines it from his inbox; Drew sees the decline and the row stays his.+ await K.page.bringToFront(); await K.page.click('#btnReq'); await sleep(300);+ await K.page.evaluate(id => { const i = document.querySelector(`#reqPanel .rq-in[data-req="${id}"]`); i.value = 'no room'; document.querySelector(`#reqPanel .rq-b[data-a="decline"][data-req="${id}"]`).click(); }, q.id);+ await quiesce(S, all);+ q = (await srvReqs()).find(x => x.id === q.id);+ check('Kale declines: the row stays Drew\'s, everywhere', q.state === 'declined' && (await srvRow(r2.uid)).owner === 'Drew Owens' && await until(async () => { for (const P of all) if ((await tabRow(P, r2.uid)).owner !== 'Drew Owens') return false; return true; }), JSON.stringify(q));+ check('Drew\'s tab has the decline and its note', await until(async () => (await tabReqs(A)).some(x => x.id === q.id && x.state === 'declined' && x.reply === 'no room')));++ // 5. Noah moves somebody else's task to Kale while Drew asks Kale for another: two requests+ // at the same moment, both pending, nobody's edits lost.+ const [n1, n2] = notKale;+ await Promise.all([setF(N, n1.uid, 'owner', 'Kale Mer'), setF(A, n2.uid, 'owner', 'Kale Mer'), setF(N, n1.uid, 'status', 'blocked')]);+ await sent([A, N]); await quiesce(S, all);+ const pend = (await srvReqs()).filter(x => x.state === 'pending' && (x.uid === n1.uid || x.uid === n2.uid));+ check('two people asking at once: two pending requests, the owners unchanged, the other field kept', pend.length === 2 && (await srvRow(n1.uid)).owner === n1.owner && (await srvRow(n2.uid)).owner === n2.owner && (await srvRow(n1.uid)).status === 'blocked', JSON.stringify(pend.map(x => [x.uid, x.from.name])));+ check('Kale\'s badge counts both', await until(async () => (await badge(K)) === '2'), await badge(K));+ // Every tab converges on the server's requests.+ const want = JSON.stringify((await srvReqs()).map(x => [x.id, x.state]).sort());+ check('every tab holds exactly the server\'s requests', await until(async () => { for (const P of all) { const l = (await tabReqs(P)).map(x => [x.id, x.state]).sort(); if (JSON.stringify(l) !== want) return false; } return true; }));+ // The changelog chain stays whole with the server's lines in it.+ const h = await (await fetch(S.base + '/health')).json();+ check('the changelog chain is whole', h.integrity && h.integrity.ok === true, JSON.stringify(h.integrity));+ for (const P of all) { if (P.errors.length) check(`no page errors in ${P.name}'s tab`, false, P.errors.slice(0, 3).join(' | ')); }+ for (const P of all) await closePerson(P);+ await stopServer(S);+} async function milestonesScenario(carbon) { scenario = 'milestones'; const S = await startServer(carbon);@@ -1262,6 +1348,7 @@ try { if (want('sections')) await sectionsScenario(carbon); if (want('subtasks')) await subtasksScenario(carbon); if (want('milestones')) await milestonesScenario(carbon);+ if (want('requests')) await requestsScenario(carbon); exitCode = fail ? 1 : 0; } catch (e) { console.error('\nSUITE CRASHED:', e && e.stack || e);
test/server_test.mjs+179@@ -512,6 +512,8 @@ async function main() { ana: { id: 'u2', name: 'ana', display_name: 'Ana Lopez', orgs: ['adom'] }, zed: { id: 'u3', name: 'zed', display_name: 'Zed Out', orgs: ['elsewhere'] }, bob: { id: 'u4', name: 'bob', display_name: 'Bob Broken', orgs: null },+ kim1: { id: 'u5', name: 'kim1', display_name: 'Kim One', orgs: ['adom'] },+ kim2: { id: 'u6', name: 'kim2', display_name: 'Kim Two', orgs: ['adom'] }, }; const intents = new Map(); let nextWho = 'drew', seq = 0; const carbon = http.createServer((req, res) => {@@ -544,6 +546,8 @@ async function main() { const asPost = (who, u, body) => as(who, u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const agent = (u, body) => post(S, u, body, { Authorization: 'Bearer ' + S.token }); const html = { headers: { Accept: 'text/html' } };+ // Owners in this block are plain fields: requests (0.80.0) have their own block below.+ await agent('/access', { requests: 'off' }); const drew = await signIn('drew'), ana = await signIn('ana'), zed = await signIn('zed'), bob = await signIn('bob'); ok(eq(drew.user && drew.user.orgs, ['adom', 'cosmiic']), 'sign-in reads the person\'s Adom orgs', drew.user); ok(eq(bob.user && bob.user.orgs, []), 'an org list that cannot be read is "no orgs", and sign-in still works', bob.user);@@ -756,6 +760,181 @@ async function main() { r = await fetch(L2.base + '/data'); ok(r.status === 200, 'without it, a direct loopback read still works for the AI handoff loop', r.status); stop(L2);+ // ── Requests (0.80.0): an owner change onto somebody else is a request they answer ──+ {+ console.log('Requests');+ const Q = await start('requests', basePlan(false), null, 8947, env, { auth: true });+ S = Q;+ const tok = { Authorization: 'Bearer ' + Q.token };+ await post(Q, '/access', { owner: 'drew2' }, tok);+ const dr = await signIn('drew'), an = await signIn('ana'), ze = await signIn('zed');+ const opsQ = (who, ops) => asPost(who, '/ops', { client: 't', batch: 'b', ops });+ const agentOps = ops => post(Q, '/ops', { ops }, tok);+ const exp = async () => (await as(dr, '/export/json')).j;+ const reqs = async q => (await as(dr, '/requests' + (q || ''))).j;+ const md = async () => (await fetch(Q.base + '/changelog?format=md', { headers: tok })).text();+ let ex = await exp();+ const etch = ex.items.find(x => x.id === 'etch'), ship = ex.items.find(x => x.id === 'ship'), launch = ex.items.find(x => x.name === 'Launch');+ r = await as(dr, '/access');+ ok(r.j.requests === 'on', 'requests are on by default on a plan with sign-in', r.j);+ r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Ana', status: 'in-progress' } }]);+ ex = await exp();+ let e = ex.items.find(x => x.uid === etch.uid);+ ok(r.status === 200 && (e.owner || '') === '' && e.status === 'in-progress', 'an owner change onto somebody else is not applied; the rest of the op lands', [e.owner, e.status]);+ ok(r.j.requested.length === 1 && r.j.requested[0].to === 'Ana' && r.j.ops.some(o => o.op === 'req' && o.req.state === 'pending' && o.req.uid === etch.uid), 'it becomes a pending request, in the answer and in the announced ops', r.j);+ const id1 = r.j.requested[0].id;+ let st = (await as(dr, '/state')).j;+ ok(Array.isArray(st.requests) && st.requests[0].id === id1 && st.requests[0].from.name === 'Drew Owens' && st.requests[0].was === '', 'it is kept in the sidecar, with who asked and the owner it had', st.requests);+ r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Ana' } }]);+ ok(r.j.requested.length === 1 && r.j.requested[0].id === id1 && (await reqs('?state=pending')).count === 1, 'asking the same person again is the same request', r.j);+ r = await as(an, '/requests');+ ok(r.j.mine && eq(r.j.mine.for, [id1]) && r.j.requests[0].row.name === 'Etch the cards', 'Ana sees it as hers to answer ("Ana" is the first word of Ana Lopez), with the row\'s name', r.j.mine);+ r = await as(dr, '/requests');+ ok(eq(r.j.mine.asked, [id1]) && eq(r.j.mine.for, []), 'Drew sees it as one he asked', r.j.mine);+ r = await opsQ(ze, [{ op: 'req.answer', id: id1, accept: true }]);+ ok(r.j.refused.some(x => /only Ana or the plan's owner/.test(x)) && (await reqs('?id=' + id1)).requests[0].state === 'pending', 'somebody else cannot answer it', r.j.refused);+ r = await agentOps([{ op: 'req.answer', id: id1, accept: true }]);+ ok(r.j.refused.some(x => /agent key answers only for a named person/.test(x)), 'the agent key cannot answer without naming the person it acts for', r.j.refused);+ r = await agentOps([{ op: 'req.answer', id: id1, accept: true, for: 'ana' }]);+ ok(r.j.refused.some(x => /Ana Lopez has not let their AI answer/.test(x)), 'nor for a person who has not let their AI answer for them', r.j.refused);+ r = await opsQ(an, [{ op: 'req.answer', id: id1, accept: true, note: 'yes, after Friday' }]);+ ex = await exp(); e = ex.items.find(x => x.uid === etch.uid);+ ok(e.owner === 'Ana' && r.j.ops.some(o => o.op === 'row' && o.uid === etch.uid && o.set.owner === 'Ana'), 'Ana accepts: the row is hers, announced as a row op every tab applies', r.j.ops);+ let rq = (await reqs('?id=' + id1)).requests[0];+ ok(rq.state === 'accepted' && rq.by.name === 'Ana Lopez' && rq.reply === 'yes, after Friday', 'the requester sees who accepted it, and the note', rq);+ st = (await as(dr, '/state')).j;+ ok(st.people && st.people.ana && st.people.ana.name === 'Ana Lopez' && r.j.ops.some(o => o.op === 'people'), 'the name she accepted under is linked to her account', st.people);+ r = await opsQ(an, [{ op: 'req.reopen', id: id1 }]);+ ex = await exp(); e = ex.items.find(x => x.uid === etch.uid);+ ok((e.owner || '') === '' && (await reqs('?id=' + id1)).requests[0].state === 'pending', 'Ana\'s undo of her accept takes the row back off her plate and the request is pending again', [e.owner]);+ r = await opsQ(an, [{ op: 'req.answer', id: id1, accept: false, note: 'no room this month' }]);+ ex = await exp(); e = ex.items.find(x => x.uid === etch.uid);+ rq = (await reqs('?id=' + id1)).requests[0];+ ok(rq.state === 'declined' && rq.reply === 'no room this month' && (e.owner || '') === '', 'a decline leaves the row as it was, and keeps the note', [rq, e.owner]);+ // Direct: to yourself, cleared, put back.+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Drew' } }]);+ ex = await exp();+ ok(ex.items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 0, 'taking a task yourself is direct', r.j);+ // The agent.+ r = await agentOps([{ op: 'row', uid: ship.uid, set: { owner: 'Ana' } }]);+ ex = await exp();+ ok(ex.items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 1, 'the agent key cannot reassign either: it asks', r.j);+ const id2 = r.j.requested[0].id;+ rq = (await reqs('?id=' + id2)).requests[0];+ ok(rq.from.name === 'adom-gantt agent' && rq.was === 'Drew', 'the request says the agent asked, and whose it was', rq);+ r = await opsQ(an, [{ op: 'req.answer', id: id2, accept: false, note: 'ask Drew' }]);+ r = await agentOps([{ op: 'req', uid: ship.uid, to: 'Ana' }]);+ ok(r.j.requested.length === 0 && r.j.refused.some(x => /Ana declined it on .* \("ask Drew"\); ask a person/.test(x)), 'the agent may not ask again after a decline', r.j.refused);+ r = await opsQ(dr, [{ op: 'req', uid: ship.uid, to: 'Ana', note: 'please, it is urgent' }]);+ const id3 = r.j.requested[0] && r.j.requested[0].id;+ ok(id3 && (await reqs('?id=' + id3)).requests[0].note === 'please, it is urgent', 'a person may ask again, with a note', r.j);+ r = await opsQ(an, [{ op: 'req.withdraw', id: id3 }]);+ ok(r.j.refused.some(x => /only Drew Owens or the plan's owner can withdraw/.test(x)), 'the person asked cannot withdraw it', r.j.refused);+ r = await opsQ(dr, [{ op: 'req.withdraw', id: id3 }]);+ ok((await reqs('?id=' + id3)).requests[0].state === 'withdrawn', 'the requester withdraws it', r.j);+ // A new row for somebody else is made unassigned and asked of them.+ r = await agentOps([{ op: 'add', row: { uid: '00080', type: 'task', id: 'new-for-ana', name: 'New for Ana', start: '2026-10-05', end: '2026-10-07', phase: 0, owner: 'Ana' } }]);+ ex = await exp(); e = ex.items.find(x => x.uid === '00080');+ ok(e && (e.owner || '') === '' && r.j.requested.length === 1 && (await reqs('?uid=00080')).requests[0].created === true, 'a row made for somebody else is made unassigned and asked of them', [e, r.j.requested]);+ r = await agentOps([{ op: 'del', uid: '00080' }]);+ ok((await reqs('?uid=00080')).requests[0].state === 'closed' && (await reqs('?uid=00080')).requests[0].why === 'row deleted', 'deleting the row closes its request', (await reqs('?uid=00080')).requests);+ // Old-style whole-state /save, from a script with the agent key and from a person.+ st = (await as(dr, '/state')).j;+ const body = JSON.parse(JSON.stringify(st)); delete body.integrity; delete body.stateRev;+ const msOv = body.items.find(o => o.uid === launch.uid);+ if (msOv) msOv.owner = 'Zed'; else body.items.push({ uid: launch.uid, owner: 'Zed', name: 'Launch', date: launch.date, phase: launch.phase, deps: [] });+ body.requests = []; body.people = {};+ r = await post(Q, '/save', body, tok);+ ex = await exp(); st = (await as(dr, '/state')).j;+ ok(r.status === 200 && (ex.items.find(x => x.uid === launch.uid).owner || '') === '' && r.j.requested.length === 1 && r.j.requested[0].to === 'Zed', 'a whole-state /save cannot reassign: the old owner is stored and Zed is asked', [r.j, ex.items.find(x => x.uid === launch.uid)]);+ ok(st.requests.length >= 4 && st.people && st.people.ana, 'and a /save cannot write the requests or the names: they are kept', [st.requests.length, st.people]);+ // An older tab's own changelog line for an owner change that became a request is dropped.+ r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Zed' } }]);+ r = await asPost(dr, '/log', { events: [{ action: 'owner', id: 'etch', name: 'Etch the cards', from: { owner: '' }, to: { owner: 'Zed' }, via: 'table-edit' }] });+ ok(r.j.added === 0 && r.j.dropped === 1, 'an older tab\'s "reassigned" line for a change that became a request is not stored', r.j);+ r = await asPost(dr, '/log', { events: [{ action: 'status', id: 'etch', name: 'Etch the cards', from: { status: '' }, to: { status: 'blocked' } }] });+ ok(r.j.added === 1, 'its other lines are', r.j);+ // Undo: putting the owner back by the person who changed it is direct.+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: '' } }]);+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Drew' } }]);+ r = await opsQ(an, [{ op: 'row', uid: ship.uid, set: { owner: 'Ana' } }]);+ ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Ana', 'Ana takes Drew\'s task herself: direct', r.j);+ r = await opsQ(an, [{ op: 'row', uid: ship.uid, set: { owner: 'Drew' } }]);+ ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 0, 'and her undo puts it back with Drew, without asking him', r.j);+ r = await opsQ(ze, [{ op: 'row', uid: ship.uid, set: { owner: 'Ana' } }]);+ ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 1, 'but somebody else moving it to Ana is a request', r.j);+ // A request replaced by a newer one, and one closed by a direct change.+ const pend = (await reqs('?uid=' + ship.uid + '&state=pending')).requests[0];+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Zed' } }]);+ ok((await reqs('?id=' + pend.id)).requests[0].state === 'closed' && (await reqs('?uid=' + ship.uid + '&state=pending')).requests[0].to === 'Zed', 'one pending request per row: a newer one replaces it', (await reqs('?uid=' + ship.uid)).requests.map(x => [x.to, x.state, x.why]));+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: '' } }]);+ ok((await reqs('?uid=' + ship.uid + '&state=pending')).count === 0, 'a direct owner change closes the row\'s pending request', r.j);+ // The changelog and the Chat digest say it in words, and the chain stays whole.+ const m = await md();+ ok(/Drew Owens asked Ana to take "Etch the cards"/.test(m) && /Ana Lopez accepted "Etch the cards", asked by Drew Owens: "yes, after Friday"/.test(m)+ && /Ana Lopez declined "Etch the cards", asked by Drew Owens: "no room this month"/.test(m) && /adom-gantt agent asked Ana to take "Ship" \(owned by Drew\)/.test(m)+ && /Drew Owens withdrew the request for Ana to take "Ship"/.test(m) && /took back their accept/.test(m) && /asked Ana to take the new task "New for Ana"/.test(m), 'the changelog has every request event, in words', m.split('\n').filter(l => /ask|accept|declin|withdr/.test(l)).slice(0, 12));+ ok(!/reassigned "Etch the cards" from to Zed|assigned "Etch the cards" to Zed/.test(m), 'and no line claims the reassignment that did not happen', '');+ ok((await getJ(Q, '/health')).integrity.ok === true, 'the changelog chain is whole after the server wrote the request lines');+ // A name with no Adom account behind it is assigned directly (Drew), and /people says so.+ r = await opsQ(dr, [{ op: 'row', uid: launch.uid, set: { owner: 'Bob' } }]);+ ok((await exp()).items.find(x => x.uid === launch.uid).owner === 'Bob' && r.j.requested.length === 0, 'a name nobody with an Adom account answers to is assigned directly', r.j);+ let pv = (await as(dr, '/people')).j;+ ok(pv.names.find(x => x.name === 'Bob').how === 'none' && pv.unlinked >= 1 && pv.names.find(x => x.name.toLowerCase() === 'ana').how === 'linked', '/people lists Bob as not linked, and Ana as linked by her accept', pv.names);+ r = await opsQ(an, [{ op: 'people.link', name: 'Bob', account: 'u:u3' }]);+ ok(r.j.refused.some(x => /only the plan's owner links/.test(x)), 'only the plan\'s owner links a name to an account', r.j.refused);+ r = await opsQ(dr, [{ op: 'people.link', name: 'Bob', account: 'u:u3' }]);+ pv = (await as(dr, '/people')).j;+ ok(pv.names.find(x => x.name === 'Bob').how === 'linked' && pv.names.find(x => x.name === 'Bob').accounts[0].name === 'Zed Out' && r.j.ops.some(o => o.op === 'people'), 'the owner links Bob to Zed Out\'s account', pv.names.find(x => x.name === 'Bob'));+ r = await opsQ(dr, [{ op: 'row', uid: launch.uid, set: { owner: '' } }, { op: 'row', uid: launch.uid, set: { owner: 'Bob' } }]);+ ok(r.j.requested.length === 1 && r.j.requested[0].to === 'Bob', 'and a change onto Bob is now a request Zed answers', r.j);+ r = await opsQ(ze, [{ op: 'req.answer', id: r.j.requested[0].id, accept: true }]);+ ok((await exp()).items.find(x => x.uid === launch.uid).owner === 'Bob', 'Zed accepts it as Bob', r.j);+ // Two people who share a first name: still asked, and /people warns.+ await signIn('kim1'); await signIn('kim2'); S = Q;+ pv = (await as(dr, '/people')).j;+ r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Kim' } }]);+ pv = (await as(dr, '/people?name=Kim')).j;+ ok(r.j.requested.length === 1 && pv.lookup.how === 'shared' && pv.lookup.accounts.length === 2, 'a first name two people share is still asked, and flagged with both accounts', [r.j.requested, pv.lookup]);+ // An AI answers for a person only with their permission, and says whose.+ r = await agentOps([{ op: 'ai.answers', on: true }]);+ ok(r.j.refused.some(x => /only a signed-in person/.test(x)), 'the agent key cannot give itself permission', r.j.refused);+ r = await opsQ(an, [{ op: 'ai.answers', on: true }]);+ ok(r.j.ops.some(o => o.op === 'delegates' && o.delegates['u:u2'] && o.delegates['u:u2'].on), 'Ana lets her AI answer for her; the server keeps it', r.j.ops);+ r = await opsQ(dr, [{ op: 'req', uid: ship.uid, to: 'Ana', note: 'for your AI' }]);+ const idAi = r.j.requested[0].id;+ r = await agentOps([{ op: 'req.answer', id: idAi, accept: true, for: 'zed' }]);+ ok(r.j.refused.some(x => /has not let their AI|only Ana/.test(x)), 'the agent cannot answer Ana\'s request in somebody else\'s name', r.j.refused);+ r = await agentOps([{ op: 'req.answer', id: idAi, accept: true, for: 'ana', note: 'she said yes in Chat' }]);+ rq = (await reqs('?id=' + idAi)).requests[0];+ ok(rq.state === 'accepted' && rq.by.for && rq.by.for.name === 'Ana Lopez' && (await exp()).items.find(x => x.uid === ship.uid).owner === 'Ana', 'with her permission the agent accepts for her, and the request says so', rq);+ ok(/Ana Lopez's AI accepted "Ship" for them, asked by Drew Owens: "she said yes in Chat"/.test(await md()), 'the changelog says "Ana Lopez\'s AI accepted"', '');+ await opsQ(an, [{ op: 'ai.answers', on: false }]);+ // Moving the dates of somebody else's task tells them: one notice per batch per person.+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { s: '2026-10-13', e: '2026-10-19' } }]);+ let nt = r.j.ops.find(o => o.op === 'notice');+ ok(nt && nt.notice.to === 'Ana' && eq(nt.notice.ids, ['u:u2']) && nt.notice.count === 1 && nt.notice.rows[0].was.s === '2026-10-12', 'Drew moves Ana\'s task: a notice to Ana, with the dates it had', nt);+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { e: '2026-10-21' } }]);+ const nt2 = r.j.ops.find(o => o.op === 'notice');+ ok(nt2 && nt2.notice.id === nt.notice.id && nt2.notice.rows[0].was.s === '2026-10-12' && nt2.notice.rows[0].now.e === '2026-10-21', 'a second move a moment later updates the same notice, not a new one', nt2);+ r = await opsQ(an, [{ op: 'row', uid: ship.uid, set: { e: '2026-10-22' } }]);+ ok(!r.j.ops.some(o => o.op === 'notice'), 'moving your own task tells nobody', r.j.ops);+ r = await opsQ(dr, [{ op: 'row', uid: launch.uid, set: { date: '2026-10-23' } }, { op: 'row', uid: ship.uid, set: { s: '2026-10-14' } }]);+ ok(r.j.ops.filter(o => o.op === 'notice').length === 2, 'one batch moving two people\'s rows: one notice each', r.j.ops.filter(o => o.op === 'notice').map(o => o.notice.to));+ r = await as(an, '/requests');+ ok(r.j.mine.notices.length >= 1 && r.j.notices.some(n => n.to === 'Ana'), 'Ana reads her notices from /requests', r.j.mine);+ ok(/Drew Owens moved the dates of Ana's task "Ship", and Ana was told/.test(await md()), 'the changelog (and so the Chat digest) has one line for it', '');+ // The off switch: the owner's, logged.+ r = await asPost(an, '/access', { requests: 'off' });+ ok(r.status === 403, 'only the plan\'s owner can turn requests off', r.status);+ r = await asPost(dr, '/access', { requests: 'off' });+ ok(r.status === 200 && r.j.requests === 'off', 'the owner turns them off', r.j);+ r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Zed' } }]);+ ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Zed' && r.j.requested.length === 0, 'and an owner change is direct again', r.j);+ ok(/Drew Owens turned requests off for this plan/.test(await md()), 'which the changelog says', '');+ await asPost(dr, '/access', { requests: 'on' });+ stop(Q);+ } carbon.close(); }
test/ui_test.mjs+198@@ -917,6 +917,7 @@ async function main() { if (wantAny('quick add')) await ownServers(quickAddFlow, browser); if (wantAny('milestone deps')) await ownServers(milestoneDepsFlow, browser); if (wantAny('row select')) await ownServers(rowSelectFlow, browser);+ if (wantAny('requests')) await ownServers(requestsFlow, browser); if (wantAny('viewport')) { // ── Viewport matrix ──@@ -1318,6 +1319,19 @@ const MANIFEST = [ // 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 }, { sel: '.row-sel', within: 'tableBody', label: 'table row: the handle, selects the row (hover only)', safe: true },+ // Requests (0.80.0). Rendered only for a signed-in person on a plan with requests on, and+ // the coverage gate runs --no-auth, so none is required here; the `requests` flow (its own+ // server with sign-in) drives every one of them.+ { sel: '#btnReq', label: 'Requests inbox button', required: false, terminal: true, by: 'requests' },+ { sel: '.req-pill', label: 'a row\'s pending request pill (opens the inbox)', required: false, terminal: true, by: 'requests' },+ { sel: '.rq-row', label: 'inbox: the row link', required: false, terminal: true, by: 'requests' },+ { sel: '.rq-in', label: 'inbox: the note field', required: false, terminal: true, by: 'requests' },+ { sel: '.rq-b', label: 'inbox and editor: Accept, Decline, Withdraw', required: false, terminal: true, by: 'requests' },+ { sel: '#imReqYes', label: 'item editor: Accept the request', required: false, terminal: true, by: 'requests' },+ { sel: '#imReqNo', label: 'item editor: Decline the request', required: false, terminal: true, by: 'requests' },+ { sel: '#imReqWd', label: 'item editor: Withdraw the request', required: false, terminal: true, by: 'requests' },+ { sel: '#rqAi', label: 'inbox: let my AI answer requests for me', required: false, terminal: true, by: 'requests' },+ { sel: '.pp-acc', label: 'Setup, People: the account behind an owner name', required: false, terminal: true, by: 'requests' }, ]; function ctrlMatches(c, d) { let ok;@@ -4508,6 +4522,190 @@ async function milestoneDepsFlow(browser) { // the Table's ID cell, that selects the row and opens nothing. Drew: nowhere on a row just // selected it, so deleting one meant opening the editor and pressing its Delete. Walked // with the mouse and the keyboard as a person would, and each change checked on the server.+// ── Requests (0.80.0): an owner change onto somebody else is a request they answer ──+// Its own server with sign-in on (a fake Adom identity service on loopback), since the+// rest of the suite runs --no-auth, where nobody could answer a request. Two people, Drew+// (the plan's owner) and Ana, each in their own browser context.+async function requestsFlow(browser) {+ section = 'requests';+ const people = { drew: { id: 'u1', name: 'drew2', display_name: 'Drew Owens' }, ana: { id: 'u2', name: 'ana', display_name: 'Ana Lopez' } };+ const intents = new Map(); let nextWho = 'drew', seq = 0;+ const carbon = http.createServer((req, res) => {+ const send = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };+ const who = String(req.headers.authorization || '').replace(/^Bearer S-/, '');+ if (req.method === 'POST' && req.url === '/auth/intents') { const t = 'T' + (++seq); intents.set(t, nextWho); send(200, { token: t, auth_url: 'http://example.invalid/a' }); return; }+ const m = req.url.match(/^\/auth\/intents\/([^/]+)\/status$/);+ if (m) { const w = intents.get(m[1]); send(200, w ? { state: 'authenticated', session_token: 'S-' + w } : { state: 'pending' }); return; }+ const p = people[who];+ if (req.url === '/user' && p) { send(200, { id: p.id, name: p.name, display_name: p.display_name }); return; }+ if (req.url === '/user/memberships' && p) { send(200, [{ organization: { id: 'o1', name: 'adom' } }]); return; }+ send(401, { error: 'no' });+ });+ const carbonPort = await new Promise(r => carbon.listen(0, '127.0.0.1', () => r(carbon.address().port)));+ try {+ const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'not-started', ...x });+ const plan = { project: { title: 'Requests Test', appName: 'requests-test', view: { mode: 'gantt', grain: 'week' } },+ phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],+ items: [T('b1', 'Build one', 0), T('b2', 'Build two', 0), T('b3', 'Build three', 0, { owner: '' }), T('s1', 'Ship one', 1, { owner: 'Ana' }), T('v1', 'Vendor parts', 1, { owner: 'Vendor Co' }),+ { type: 'milestone', name: 'Ship gate', date: '2026-11-02', phase: 1 }] };+ const dir = mkdtempSync(join(TMP, 'requests-'));+ const file = join(dir, 'plan.json');+ writeFileSync(file, JSON.stringify(plan, null, 1));+ const port = await freePort();+ const proc = spawn(process.execPath, [join(ROOT, 'server.js'), '--data', file, '--port', String(port)],+ { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: Object.assign({}, process.env, { ADOM_CARBON_API: `http://127.0.0.1:${carbonPort}` }) });+ let log = ''; proc.stdout.on('data', d => log += d); proc.stderr.on('data', d => log += d);+ const url = `http://127.0.0.1:${port}/`;+ for (let i = 0; i < 100; i++) { try { const r = await fetch(url + 'health'); if (r.ok && log.includes(`:${port}`)) break; } catch {} await sleep(100); }+ servers.push({ proc, url, dir, file, port, log: () => log });+ const token = readFileSync(join(dir, 'plan-agent-token'), 'utf8').trim();+ await fetch(url + 'access', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ owner: 'drew2' }) });+ const signIn = async who => { nextWho = who; const st = await (await fetch(url + 'auth/start', { method: 'POST' })).json();+ const r = await fetch(url + 'auth/poll?token=' + st.token); await r.json(); return ((r.headers.get('set-cookie') || '').match(/adom_gantt_sid=([^;]+)/) || [])[1]; };+ const person = async (who, opts = {}) => { const sid = await signIn(who); const ctx = await browser.createBrowserContext();+ await ctx.setCookie({ name: 'adom_gantt_sid', value: sid, domain: '127.0.0.1', path: '/' });+ return openPage(browser, url, Object.assign({ ctx }, opts)); };+ const D = await person('drew'), A = await person('ana');+ const until = async (fn, ms = 5000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(120); } return fn(); };+ const row = async id => ((await savedPlan(url, { minWait: 300 })).items || []).find(r => r.id === id) || {};+ const reqs = async () => (await (await fetch(url + 'requests')).json()).requests || [];+ const pill = (pg, id) => pg.evaluate(id => { const r = document.querySelector(`.label-row[data-id="${id}"]`); const p = r && r.querySelector('.req-pill'); return p ? { text: p.textContent, mine: p.classList.contains('mine') } : null; }, id);+ const badge = pg => pg.$eval('#reqN', e => e.textContent);+ const ownerPill = (pg, id) => pg.evaluate(id => { const r = document.querySelector(`.label-row[data-id="${id}"]`); const p = r && r.querySelector('.owner-pill'); return p ? p.textContent : null; }, id);+ const undoKey = async pg => { await pg.keyboard.down('Control'); await pg.keyboard.press('KeyZ'); await pg.keyboard.up('Control'); await settle(pg, 300); };+ const redoKey = async pg => { await pg.keyboard.down('Control'); await pg.keyboard.down('Shift'); await pg.keyboard.press('KeyZ'); await pg.keyboard.up('Shift'); await pg.keyboard.up('Control'); await settle(pg, 300); };++ check('the Requests button is in the header for a signed-in person', await D.$eval('#btnReq', b => window.__uiVis(b)) && (await badge(D)) === '');+ // 1. Drew sets Ana as the owner in the Table: it asks her, and the row stays his.+ await D.bringToFront(); await view(D, 'table');+ await D.click('#tableView tr[data-key="t:b1"] td[data-field="owner"]'); await settle(D, 100);+ await D.keyboard.down('Control'); await D.keyboard.press('KeyA'); await D.keyboard.up('Control'); await D.keyboard.type('Ana'); await D.keyboard.press('Enter'); await settle(D, 600);+ let q = (await reqs()).find(x => x.uid && x.state === 'pending');+ check('an owner set to somebody else in the Table becomes a pending request; the row keeps its owner', (await row('b1')).owner === 'Drew' && q && q.to === 'Ana' && q.from.name === 'Drew Owens', JSON.stringify([await row('b1'), q]));+ check('the Table shows the owner and the pending pill', await D.$eval('#tableView tr[data-key="t:b1"] td[data-field="owner"]', td => td.textContent.includes('Drew') && !!td.querySelector('.req-pill') && td.querySelector('.req-pill').textContent === '→ Ana?'));+ check('the toast says it asked rather than assigned', /Asked Ana to take "Build one"/.test(await D.$eval('#saveInd', e => e.textContent)), await D.$eval('#saveInd', e => e.textContent));+ await view(D, 'gantt');+ check('the chart row shows the pending pill after the owner', (await pill(D, 'b1'))?.text === '→ Ana?' && (await ownerPill(D, 'b1')) === 'Drew', JSON.stringify(await pill(D, 'b1')));+ // 2. Ana sees it live: the badge, the pill in the accent.+ check('Ana\'s badge counts it, live', await until(async () => (await badge(A)) === '1'), await badge(A));+ check('Ana\'s row shows it as hers to answer', await until(async () => (await pill(A, 'b1'))?.mine === true), JSON.stringify(await pill(A, 'b1')));+ if (KEEP) { await D.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]); }+ // 3. Ana opens the inbox and accepts with a note.+ await A.bringToFront(); await A.click('#btnReq'); await settle(A, 200);+ check('the inbox opens with the request under "For you"', await A.$eval('#reqPanel', p => p.classList.contains('show') && p.textContent.includes('For you') && p.textContent.includes('Build one') && p.textContent.includes('Drew Owens asked you')));+ await shot(A, 'requests-inbox-dark');+ await A.type('#reqPanel .rq-in', 'yes, after Friday');+ await A.click('#reqPanel .rq-b.yes'); await settle(A, 800);+ q = (await reqs()).find(x => x.id === q.id);+ check('accept: the row is Ana\'s on the server, with her note', (await row('b1')).owner === 'Ana' && q.state === 'accepted' && q.reply === 'yes, after Friday', JSON.stringify([await row('b1'), q]));+ check('Ana\'s page shows her as the owner, with no pill, and her badge clears', await until(async () => (await ownerPill(A, 'b1')) === 'Ana' && !(await pill(A, 'b1')) && (await badge(A)) === ''));+ check('Drew\'s page shows Ana as the owner, live', await until(async () => (await ownerPill(D, 'b1')) === 'Ana' && !(await pill(D, 'b1'))), await ownerPill(D, 'b1'));+ check('Drew\'s badge counts her answer', await until(async () => (await badge(D)) === '1'), await badge(D));+ await D.bringToFront(); await D.click('#btnReq'); await settle(D, 200);+ check('Drew sees the answer under "You asked": accepted, by whom, and the note', await D.$eval('#reqPanel', p => /You asked/.test(p.textContent) && /accepted/.test(p.textContent) && p.textContent.includes('Ana Lopez') && p.textContent.includes('yes, after Friday')));+ await shot(D, 'requests-asked-dark');+ check('and seeing it clears his badge', (await badge(D)) === '');+ await D.keyboard.press('Escape'); await settle(D, 100);+ check('Escape closes the inbox', !(await D.$eval('#reqPanel', p => p.classList.contains('show'))));+ // 4. Ana takes her accept back with Ctrl+Z: pending again, and the row Drew's again.+ await A.bringToFront(); await A.mouse.click(5, 300); await undoKey(A); await settle(A, 600);+ q = (await reqs()).find(x => x.id === q.id);+ check('Ana\'s Ctrl+Z takes back her accept: the request is pending and the row is Drew\'s again', q.state === 'pending' && (await row('b1')).owner === 'Drew', JSON.stringify([q.state, (await row('b1')).owner]));+ // 5. She declines from the item editor instead.+ await openItemEditor(A, 'Build one'); await settle(A, 200);+ check('the item editor says who asked her, with Accept and Decline', await A.$eval('#imReq', e => window.__uiVis(e) && e.textContent.includes('asked you to take this') && !!e.querySelector('#imReqYes') && !!e.querySelector('#imReqNo')));+ await shot(A, 'requests-editor-dark');+ await A.click('#imReqNo'); await settle(A, 800);+ q = (await reqs()).find(x => x.id === q.id);+ check('decline: the row stays Drew\'s', q.state === 'declined' && (await row('b1')).owner === 'Drew', JSON.stringify(q));+ await A.keyboard.press('Escape');+ // 6. Drew, in the item editor: the owner and a status in one Apply, one undo for both.+ await D.bringToFront(); await openItemEditor(D, 'Build two'); await settle(D, 200);+ await D.$eval('#imOwner', e => { e.value = 'Ana'; e.dispatchEvent(new Event('input', { bubbles: true })); });+ await D.select('#imStatus', 'in-progress'); await D.click('#imApply'); await settle(D, 800);+ let b2 = await row('b2'); q = (await reqs()).find(x => x.uid === b2.uid && x.state === 'pending');+ check('the editor\'s Apply lands the status and asks Ana for the owner', b2.status === 'in-progress' && b2.owner === 'Drew' && q && q.to === 'Ana', JSON.stringify([b2, q]));+ await D.mouse.click(5, 300); await undoKey(D); await settle(D, 600);+ b2 = await row('b2');+ check('one Ctrl+Z takes back both: the status, and the request (withdrawn)', b2.status === 'not-started' && (await reqs()).find(x => x.id === q.id).state === 'withdrawn', JSON.stringify([b2.status, (await reqs()).find(x => x.id === q.id)]));+ await redoKey(D); await settle(D, 600);+ b2 = await row('b2');+ check('and Ctrl+Shift+Z puts both back: the status, and the request pending again', b2.status === 'in-progress' && (await reqs()).find(x => x.id === q.id).state === 'pending', JSON.stringify([b2.status, (await reqs()).find(x => x.id === q.id)]));+ // 7. Withdraw from the inbox.+ await D.click('#btnReq'); await settle(D, 200);+ await D.click('#reqPanel .rq-b[data-a="withdraw"]'); await settle(D, 700);+ check('Drew withdraws it from the inbox', (await reqs()).find(x => x.id === q.id).state === 'withdrawn');+ await D.keyboard.press('Escape');+ // 8. Taking a task yourself is direct, from the owner pill.+ await D.evaluate(() => document.querySelector('.label-row[data-id="b3"] .owner-pill').click()); await settle(D, 200);+ await D.keyboard.type('Drew'); await D.keyboard.press('Enter'); await settle(D, 700);+ check('setting yourself as the owner is direct, not a request', (await row('b3')).owner === 'Drew' && !(await reqs()).some(x => x.uid === (plan.items[2].uid || '') && x.state === 'pending') && !(await pill(D, 'b3')), JSON.stringify(await row('b3')));+ // 9. A new task for Ana is made unassigned and asked of her.+ await D.click('#btnNew'); await settle(D, 200);+ await D.type('#ncName', 'For Ana'); await D.$eval('#ncOwner', e => { e.value = 'Ana'; e.dispatchEvent(new Event('input', { bubbles: true })); });+ await D.click('#ncCreate'); await settle(D, 900);+ const made = ((await savedPlan(url, { minWait: 300 })).items || []).find(r => r.name === 'For Ana');+ q = made && (await reqs()).find(x => x.uid === made.uid);+ check('a new task for somebody else is made unassigned and asked of them', made && !made.owner && q && q.state === 'pending' && q.created === true, JSON.stringify([made, q]));+ check('Ana gets it live', await until(async () => (await badge(A)) === '1'), await badge(A));+ // 10. A name nobody with an account answers to: assigned directly, marked quietly.+ await D.bringToFront(); await view(D, 'gantt');+ check('an owner name with no Adom account is marked (dotted), and a known one is not', await D.evaluate(() => { const v = document.querySelector('.label-row[data-id="v1"] .owner-pill'), a = document.querySelector('.label-row[data-id="s1"] .owner-pill');+ return !!v && v.classList.contains('unlinked') && /not linked to an Adom account/.test(v.title) && !!a && !a.classList.contains('unlinked'); }));+ // 11. The People card on Setup: the owner links Vendor Co to Ana's account.+ await view(D, 'setup'); await settle(D, 400);+ check('Setup lists every owner name with its account, Vendor Co not linked', await D.$eval('#suPeople', c => window.__uiVis(c) && /Vendor Co/.test(c.textContent) && /not linked/.test(c.textContent) && /Ana Lopez/.test(c.textContent)));+ await D.evaluate(() => document.getElementById('suPeople').scrollIntoView({ block: 'center' })); await settle(D, 200);+ await shot(D, 'requests-people-dark');+ const anaAcc = await D.evaluate(() => { const o = [...document.querySelectorAll('.pp-acc[data-name="Vendor Co"] option')].find(x => /Ana Lopez/.test(x.textContent)); return o ? o.value : ''; });+ await D.select('.pp-acc[data-name="Vendor Co"]', anaAcc); await settle(D, 900);+ const pv = await (await fetch(url + 'people?name=Vendor%20Co')).json();+ check('picking an account links the name on the server', pv.lookup && pv.lookup.how === 'linked' && pv.lookup.accounts[0].name === 'Ana Lopez', JSON.stringify(pv.lookup));+ check('Ana\'s tab drops the mark live', await until(async () => A.evaluate(() => { const v = document.querySelector('.label-row[data-id="v1"] .owner-pill'); return !!v && !v.classList.contains('unlinked'); })));+ await view(D, 'gantt');+ // 12. Moving the dates of Ana's task tells her: a notice in her inbox, not a request.+ const nBefore = +(await badge(A) || 0);+ await openItemEditor(D, 'Ship one'); await settle(D, 200);+ await D.$eval('#imEnd', e => { e.value = '2026-10-16'; e.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); });+ await D.click('#imApply'); await settle(D, 900);+ check('the date lands at once (it is not a request)', (await row('s1')).end === '2026-10-16', JSON.stringify(await row('s1')));+ check('Ana\'s badge counts the notice, live', await until(async () => +(await badge(A) || 0) === nBefore + 1), `${nBefore} -> ${await badge(A)}`);+ await A.bringToFront(); await A.click('#btnReq'); await settle(A, 200);+ check('her inbox lists it under "Changes to your tasks", with the dates it had', await A.$eval('#reqPanel', p => /Changes to your tasks/.test(p.textContent) && /Drew Owens moved the dates of your task/.test(p.textContent) && /Ship one/.test(p.textContent) && !!p.querySelector('.rq-was')));+ await shot(A, 'requests-notice-dark');+ // 13. Ana lets her AI answer for her; the agent key then answers a request in her name.+ await A.click('#rqAi'); await settle(A, 800);+ const dl = (await (await fetch(url + 'requests', { headers: { Authorization: 'Bearer ' + token } })).json()).delegates || {};+ check('the "let my AI answer" switch is stored on the server', Object.values(dl).some(v => v && v.on && v.name === 'Ana Lopez'), JSON.stringify(dl));+ await A.keyboard.press('Escape');+ const b2r = await row('b2');+ const ask = await (await fetch(url + 'ops', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ ops: [{ op: 'req', uid: b2r.uid, to: 'Ana' }] }) })).json();+ const aid = ask.requested && ask.requested[0] && ask.requested[0].id;+ const ans = await (await fetch(url + 'ops', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ ops: [{ op: 'req.answer', id: aid, accept: true, for: 'ana' }] }) })).json();+ check('with her permission the agent accepts for her', !!aid && (await row('b2')).owner === 'Ana' && !(ans.refused || []).length, JSON.stringify([ask.requested, ans.refused]));+ check('every tab shows the row as Ana\'s', await until(async () => (await ownerPill(D, 'b2')) === 'Ana' && (await ownerPill(A, 'b2')) === 'Ana'));+ // Light theme screenshots, and the phone width.+ if (KEEP) {+ for (const pg of [D, A]) { await pg.bringToFront(); await pg.click('#btnTheme'); await settle(pg, 200); }+ await A.bringToFront(); await A.click('#btnReq'); await settle(A, 200); await shot(A, 'requests-inbox-light'); await A.keyboard.press('Escape');+ await D.bringToFront(); await view(D, 'setup'); await settle(D, 300); await D.evaluate(() => document.getElementById('suPeople').scrollIntoView({ block: 'center' })); await settle(D, 200); await shot(D, 'requests-people-light'); await view(D, 'gantt');+ await D.bringToFront(); await view(D, 'table'); await settle(D, 200); await shot(D, 'requests-table-light'); await view(D, 'gantt');+ for (const pg of [D, A]) { await pg.click('#btnTheme'); await settle(pg, 150); }+ }+ await A.bringToFront(); await A.setViewport({ width: 393, height: 852 }); await settle(A, 400);+ check('at 393px the Requests button is on screen and the inbox fits', await A.evaluate(async () => { const b = document.getElementById('btnReq'); const r = b.getBoundingClientRect(); if (!(r.width > 0 && r.left >= 0 && r.right <= innerWidth)) return false;+ b.click(); await new Promise(x => setTimeout(x, 200)); const p = document.getElementById('reqPanel').getBoundingClientRect(); return p.left >= 0 && p.right <= innerWidth; }));+ await shot(A, 'requests-393');+ await A.keyboard.press('Escape'); await A.setViewport({ width: 1440, height: 900 });+ // The changelog says it in words, as the server wrote it.+ const md = await (await fetch(url + 'changelog?format=md')).text();+ check('the changelog says the AI accepted for Ana, and that Ana was told about her moved task', /Ana Lopez's AI accepted "Build two" for them/.test(md) && /moved the dates of Ana's task "Ship one", and Ana was told/.test(md), md.split('\n').filter(l => /AI|told/.test(l)).slice(0, 4).join(' | '));+ check('the changelog has the request, the accept, the take-back and the decline', /Drew Owens asked Ana to take "Build one"/.test(md) && /Ana Lopez accepted "Build one"/.test(md) && /took back their accept/.test(md) && /Ana Lopez declined "Build one"/.test(md), md.split('\n').filter(l => /ask|accept|declin/.test(l)).slice(0, 6).join(' | '));+ check('and no line claims Drew reassigned "Build one"', !/reassigned "Build one" from Drew to Ana/.test(md));+ check('the changelog chain is whole', (await (await fetch(url + 'health')).json()).integrity.ok === true);+ await D.close(); await A.close();+ } finally { carbon.close(); }+} async function rowSelectFlow(browser) { section = 'row select'; const T = (id, name, ph, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'in-progress', ...extra });
uninstall.sh