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.67.0
9 files changed
+534−10
SKILL.md+34@@ -290,6 +290,40 @@ View preferences (theme, milestone style, cell size, today marker, name column w `localStorage` per browser. They are a view concern, so they are not written to the data file. The project start is NOT a view preference: it edits real dates. +## Google Chat digests (0.67.0)++Changes to a plan can be posted to a Google Chat space through an incoming webhook, as+**digests**: when the plan has been quiet for two minutes (or every ten minutes while+editing goes on), one message lists what changed, grouped by person, each change in the+changelog's own words, with links to the plan and to every change since the last post.+One Chat thread per plan per day. The server's own bookkeeping (the chain's genesis, uid+backfills, sidecar upgrades) is left out; the agent's edits are in, under its name.++- **Pushed by the server, not polled.** Every change already goes through the changelog,+ so the server reads past the last revision it posted on a 15-second tick. Nothing is+ held in memory: a restart resumes from that revision, so nothing is lost or posted+ twice. A failed post is retried (30 seconds, doubling, at most 15 minutes, or what Chat+ asks for) and the error shows on the Setup card.+- **The webhook is a credential.** It is kept in `<data>-chat.json` (mode 0600) beside the+ plan, never sent to a page, never logged, and scrubbed from any error. Only a request+ from this machine or one carrying the agent token can set it (or the public address+ used for links); a page cannot. Only Google Chat webhook URLs are accepted.+- **Cadence**: `live` (the digests above, the default), `hourly`, `daily` (5 pm Central),+ or `off`. Turning it back on starts from then rather than dumping the gap.+- **Setup page card**: whether it is set up, the space (its first characters only), the+ last post, anything waiting, the last error, a cadence picker and "Send a test message".++```bash+TOK=$(cat <plan>-agent-token)+curl -s -X POST localhost:8901/notify -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \+ -d '{"webhook":"https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=...","publicUrl":"https://gantt-xxxx.adom.cloud"}'+curl -s -X POST localhost:8901/notify -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"test":true}'+curl -s localhost:8901/notify # status, never the URL+```++Tuning for tests: `ADOM_GANTT_CHAT_QUIET_MS`, `_MAX_MS`, `_TICK_MS`, `_HOURLY_MS`, `_TZ`,+and `ADOM_GANTT_CHAT_ALLOW_LOOPBACK=1` to let a fake webhook on 127.0.0.1 through.+ ## Data entry: one rule per kind of field (0.65.0) An audit of every place a value is typed or picked, so the same kind of value is always
docs/BUILD-NOTES.md+28@@ -3,6 +3,34 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-14 (0.67.0, changes posted to Google Chat as digests)++Step 1 of the sharing plan Drew approved (docs/plan-collab.md, "Sharing with the team"):+"see changelogs coming in regularly ... to a private gchat space via webhook".++The notifier lives in the server, because the server already writes every change to the+changelog through one append path. It keeps no queue in memory: on a 15-second tick it+reads the changelog past the last revision it posted (the cursor, on disk) and decides+whether a digest is due. Due means the plan has been quiet for two minutes, or the+oldest unposted change is ten minutes old. One message lists the changes grouped by+person, in the changelog's own sentences without the gesture, at most eight per person,+with links to the plan and to the changes since the last post (when the plan's public+address is set). Threaded per plan per day with the webhook's `threadKey`. A failed post+backs off and retries; the cursor only moves on success, so a restart or an outage posts+everything exactly once. Hourly and daily cadences use the same cursor.++The webhook is treated as the credential it is: a 0600 file beside the plan, set only+from this machine or with the agent token (the plan's owner gets a field for it with the+sharing setting in 0.68.0), never in a response, a page or a log, and scrubbed from+Chat's own error text. GET /notify reports the space by its first four characters.++Tests: 21 new server checks against a fake Chat on loopback (grouping and counts, the+words, links and thread, the bookkeeping left out, a failure retried and posted once,+nothing posted twice across a restart, off and back on, the test message, no leak of the+key or token, the 0600 file), and a browser flow for the Setup card (not set up, refused+from a page, on with the space masked, cadence, test message, an edit in the app arriving+as a digest).+ ## Build notes, 2026-10-14 (0.66.2, the New panel stops carrying the notes) Drew: "do not persist note field between new task creation". The New panel's memory
docs/plan-collab.md+79−5@@ -135,11 +135,13 @@ no longer collide either. ## Decisions (Drew, 2026-10-07) 1. **Field-level ops, last writer wins per field.** Agreed.-2. **Reach.** Collaborators open the plan at the container's cloud URL- (`https://<container>.adom.cloud/proxy/<port>/`) once the port is mapped. That URL- names the container, not a secret, and the proxy does not gate who can open it: the- app's own Sign in with Adom is the gate, so the permission model below is enforced on- the server on every request, never only in the page.+2. **Reach. CORRECTED 2026-10-14.** The 2026-10-07 note here was wrong on two counts.+ The `/proxy/<port>/` URL sits behind the container OWNER's Adom login, so teammates+ cannot open it (and sharing it would expose every port on the machine). And the+ workspace this app was being built and previewed in is not a cloud container at all:+ it is the local Hydrogen workspace on Drew's laptop (`slug: hdlocal`), which has no+ cloud slug to map a port on and goes down whenever the laptop sleeps. See "Sharing+ with the team" below for what reaches a team. 3. **Confidential: every reader signs in with an Adom account. No anonymous links.** Per plan, a sharing setting on the Setup page, enforced server-side on every request: - **Only me**: the owner (the account that set it, or the first signed-in account).@@ -155,3 +157,75 @@ no longer collide either. Checkpoint 3 changes accordingly: the "view-only token link" item is replaced by the per-plan sharing setting above (no tokens; identity is always the Adom session).++## Sharing with the team, and changes posted to Google Chat (recommendation, 2026-10-14)++Drew: "share a gantt project with the team ... all have full control of the app ... see+changelogs coming in regularly (maybe real time, or polling every few minutes or every+hour?) to a private gchat space via webhook."++### Where the shared plan runs: its own small cloud container++- A dedicated Adom cloud container (class `small`), owned by Drew, running only+ adom-gantt for the shared plan(s), installed from the wiki like anywhere else.+- `adom-cli carbon containers port-add <slug> --port <port> --prefix gantt` gives it a+ permanent `https://gantt-<random>.adom.cloud` address. That address is public at the+ network level by design; the app's own Sign in with Adom is the gate on every request+ (page, data, saves, the live channel), which is why the org gate below comes first.+- Supervised the way the production Adom services are (the standalone-service pattern:+ started by the container's entrypoint, a watchdog every 2 minutes restarts it), so it+ survives a crash and a restart.+- NOT Drew's laptop workspace (offline whenever the laptop sleeps, no port mapping), and+ NOT the container ACL (adding anyone to a container's ACL permanently disables its+ session token; teammates need the app, not the machine).+- Backups: the plan, sidecar and changelog are copied to a private wiki page nightly, and+ before every import, so a bad import or a lost container is a restore, not a loss.+- To verify while building: whether an idle cloud container sleeps (the docs say a+ sleeping container returns 502 until it wakes), and how to keep this one awake.++### Who gets in: everyone in the Adom org, with full control++- At sign-in the server reads the person's org memberships from Carbon (`GET+ /user/memberships`, with the token it already holds for that moment) and keeps only the+ org names in the session.+- A per-plan setting on the Setup page: **Only me / My org (adom) / Anyone signed in to+ Adom**. Everyone it admits has full control: every edit, every lens, Setup, import and+ export. Anyone else who signs in gets a page saying the plan is not shared with them.+- Two things stay with the plan's owner, because they are a credential and the lock on+ the door: the sharing setting itself, and the Chat webhook.+- Before inviting the whole team: field-level ops (checkpoint 2). Today two people editing+ the SAME row within a save of each other lose one side's change without a word; rare+ for one person in two tabs, likely for a team.++### Changes into a private Google Chat space: pushed by the server, as digests++- **A Chat incoming webhook** on a private space (Drew creates the space and adds the+ webhook in Chat: Apps & integrations, Webhooks). Messages arrive from the webhook's+ own name ("Gantt"), not from Drew. The URL is a secret: stored on the server in a+ 0600 file, set by the owner, never sent to the page or the wiki.+- **Pushed by the gantt server itself, not polled.** Every change already goes through+ one append path into the changelog, so the server knows the instant something changed;+ a poller on another machine would need that machine awake and would only ever be slower.+- **Digests, not one message per change.** A drag is one change; a batch edit or an+ auto-schedule is dozens. So the server collects changes per person and posts when that+ person has been quiet for 2 minutes, or every 10 minutes during continuous editing:+ "*Ana* · 6 changes · <plan>: moved X +3d (12 Oct to 15 Oct); assigned Y to Kai; ... and 3+ more", with links to the plan and to the changelog since the last post. Imports, plan+ shifts and Auto-schedule get one line each, the way the changelog already collapses them.+- **One Chat thread per plan per day** (the webhook's `threadKey`), so the space reads as+ a daily log rather than a wall.+- **Nothing lost, nothing doubled.** The last posted changelog revision is kept on disk;+ a failed post is retried with backoff and a restart resumes from it. Chat allows about+ one message a second per space, which digests stay far below.+- **Cadence is a setting:** near real time (the default above), hourly, or a daily+ summary; any of them can be turned off without touching the webhook.+- This half does not need the team or the cloud container: it works on any adom-gantt+ server, so it can run against Drew's own plan from day one.++### Proposed order++1. Chat digests (0.67.0): usable immediately, on whatever server Drew uses today.+2. Org gate and the sharing setting, owner-only settings, backup before import (0.68.0).+3. The cloud container, the port mapping, nightly backups, the plan moved over; Drew+ uses it first, alone.+4. Field-level ops (checkpoint 2), then decide whether to invite the team.
gantt.html+64@@ -1260,6 +1260,14 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .su-card .su-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } .su-card .su-head h3 { margin: 0 0 3px; } .su-card .su-n { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); }+/* The Google Chat card (0.67.0). */+.chat-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 6px 0 4px; }+.chat-row label { font-size: 11px; color: var(--text2); }+.chat-row select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 12px; padding: 4px 8px; max-width: 100%; }+.chat-row select:disabled, .chat-row button:disabled { opacity: 0.45; cursor: default; }+.chat-status { font-size: 11.5px; color: var(--text2); line-height: 1.5; }+.chat-status .bad { color: var(--alarm); }+#chatState.on { color: var(--accent); } .sec-add { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; } .sec-add input[type="text"] { flex: 1; min-width: 0; max-width: 360px; } .setup-view input[type="text"], .setup-view select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }@@ -1700,6 +1708,21 @@ 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="suChat">+ <div class="su-head"><h3>Google Chat</h3><span class="su-n" id="chatState"></span></div>+ <p class="an-sub">Changes to this plan are posted to a Google Chat space as digests: grouped by person, in one thread per day. The space's webhook is a credential, so it is set on the server and never reaches this page.</p>+ <div class="chat-row">+ <label for="chatCadence">Post</label>+ <select id="chatCadence" aria-label="How often changes are posted to Google Chat">+ <option value="live">A couple of minutes after each burst of editing</option>+ <option value="hourly">Once an hour</option>+ <option value="daily">Once a day, at 5 pm Central</option>+ <option value="off">Off</option>+ </select>+ <button type="button" class="de-btn secondary" id="chatTest">Send a test message</button>+ </div>+ <div class="chat-status" id="chatStatus"></div>+ </div> </div></div> </div> </div>@@ -12444,6 +12467,7 @@ function renderAnalytics(){ // prefs: that runs before this line does. const VIEWS=VIEW_LIST; function applyView(){+ if(prefs.view==='setup'&&typeof renderChatCard==='function')renderChatCard(); const v = VIEWS.includes(prefs.view) ? prefs.view : 'gantt'; document.documentElement.setAttribute('data-view',v); const vt=$('viewToggle');@@ -12455,6 +12479,45 @@ function applyView(){ // The chart-only controls come and go with the lens, so the header's fit changes. if($('hdr')&&$('hdr').firstElementChild)fitHeader(); }+// ── The Google Chat card on Setup (0.67.0) ──+// Read from the server each time the page is shown (GET /notify carries no secret: the+// space is shown as its first characters, the webhook never). The cadence is set here; the+// webhook itself is set on the server.+function agoText(iso){+ const t=Date.parse(iso); if(!t)return '';+ 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';+}+async function renderChatCard(){+ const card=$('suChat'); if(!card)return;+ if(SNAPSHOT){ card.style.display='none'; return; }+ let st=null;+ try{ const r=await fetch('notify'); if(r.ok)st=await r.json(); }catch(e){}+ const sel=$('chatCadence'), test=$('chatTest'), stEl=$('chatStatus'), state=$('chatState');+ if(!st){ state.textContent='unavailable'; state.className='su-n'; stEl.textContent='The server did not answer.'; sel.disabled=test.disabled=true; return; }+ sel.value=st.cadence; sel.disabled=!st.configured; test.disabled=!st.configured||st.cadence==='off';+ state.textContent=!st.configured?'not set up':st.cadence==='off'?'off':'on'; state.className='su-n'+(st.configured&&st.cadence!=='off'?' on':'');+ if(!st.configured){ stEl.textContent='No space is set up for this plan yet. The plan\'s owner adds a Google Chat incoming webhook on the server.'; return; }+ const parts=['Posting to '+st.space+'.'];+ if(st.lastPostAt)parts.push('Last post '+agoText(st.lastPostAt)+'.');+ if(st.pending)parts.push(st.pending+' change'+(st.pending===1?'':'s')+' waiting for the next digest.');+ stEl.innerHTML=esc(parts.join(' '))+(st.lastError?' <span class="bad">The last post failed: '+esc(st.lastError.message||'')+' It will be tried again.</span>':'');+}+function wireChatCard(){+ const sel=$('chatCadence'), test=$('chatTest'); if(!sel||!test)return;+ sel.addEventListener('change',async()=>{+ try{ const r=await fetch('notify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cadence:sel.value})});+ flash(saveInd,r.ok?'Chat posting: '+sel.options[sel.selectedIndex].text.toLowerCase():'could not change the Chat setting',3000); }catch(e){ flash(saveInd,'could not reach the server',3000); }+ renderChatCard();+ });+ test.addEventListener('click',async()=>{+ test.disabled=true;+ try{ const r=await fetch('notify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({test:true})});+ const d=await r.json().catch(()=>({}));+ flash(saveInd,r.ok?'test message sent to Google Chat':'the test did not go through: '+(d.error||r.status),4500); }catch(e){ flash(saveInd,'could not reach the server',3000); }+ renderChatCard();+ });+} function setView(v){ if(!VIEWS.includes(v))return; if(prefs.view===v)return;@@ -12549,6 +12612,7 @@ applyCollapse(); // not mistaken for a fresh round of user changes. lastSnap=snapSemantic(); startLive();+wireChatCard(); // The page is built and the plan is in. The suite waits for this rather than for the // network to go quiet, which it never does once the live stream is open (0.66.0). document.documentElement.dataset.ready='1';
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.66.2",+ "version": "0.67.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
page.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.66.2",+ "version": "0.67.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
server.js+207−1@@ -593,6 +593,163 @@ function appendLog(events, actor) { return { rev, added: rows.length, rows }; } +// ── Google Chat digests (0.67.0) ──+//+// Changes to the plan are posted to a Google Chat space through an incoming webhook, as+// DIGESTS: when the plan has been quiet for two minutes, or every ten minutes while+// editing goes on, one message lists what changed, grouped by person, in one thread per+// plan per day. Pushed from here and not polled from elsewhere, because every change+// already goes through appendLog above; the notifier just reads the changelog past the+// last revision it posted, so nothing is held in memory and a restart loses nothing.+//+// The webhook URL carries a key and a token, so it is a credential. It lives in one file+// beside the plan (`<data>-chat.json`, mode 0600), it is never sent to a page or written+// to a log, and in this release only this machine or the agent token can set it. The+// same file holds the cadence and the cursor (the last revision posted).+var CHAT_FILE = DATA_FILE.replace(/\.json$/, '-chat.json');+var CHAT_QUIET_MS = +process.env.ADOM_GANTT_CHAT_QUIET_MS || 120000;+var CHAT_MAX_MS = +process.env.ADOM_GANTT_CHAT_MAX_MS || 600000;+var CHAT_TICK_MS = +process.env.ADOM_GANTT_CHAT_TICK_MS || 15000;+var CHAT_HOURLY_MS = +process.env.ADOM_GANTT_CHAT_HOURLY_MS || 3600000;+var CHAT_TZ = process.env.ADOM_GANTT_CHAT_TZ || 'America/Chicago';+var CHAT_DAILY_HOUR = 17;+var CHAT_CADENCES = ['live', 'hourly', 'daily', 'off'];+// The server's own bookkeeping is not news: the chain's genesis, uid backfills, a sidecar+// upgrade. The agent's edits ARE news, and say whose they are.+var CHAT_SKIP_ACTIONS = new Set(['chain.genesis', 'uid.assign', 'state.upgrade']);+var 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 : {}; }+ catch (e) { return {}; }+}+function chatSave(o) {+ fs.writeFileSync(CHAT_FILE, JSON.stringify(o, null, 1) + '\n', { encoding: 'utf8', mode: 0o600 });+ try { fs.chmodSync(CHAT_FILE, 0o600); } catch (e) {}+}+// Only Google Chat's own webhook endpoint, so this can never be pointed at anything else.+// The test suite runs a fake on loopback, allowed only when it asks for it.+function chatWebhookOk(u) {+ if (typeof u !== 'string' || u.length > 600) return false;+ if (/^https:\/\/chat\.googleapis\.com\/v1\/spaces\/[A-Za-z0-9_-]+\/messages\?[^#\s]*\bkey=[^&\s]+[^#\s]*\btoken=[^&\s]+/.test(u)) return true;+ return process.env.ADOM_GANTT_CHAT_ALLOW_LOOPBACK === '1' && /^http:\/\/127\.0\.0\.1:\d+\//.test(u);+}+// Never echo the key or the token, in an error, a status or a log line.+function chatScrub(text, cfg) {+ let t = String(text || '');+ const u = cfg && cfg.webhook;+ if (u) { try { const q = new URL(u).searchParams; for (const k of ['key', 'token']) { const v = q.get(k); if (v) t = t.split(v).join('***'); } } catch (e) {} t = t.split(u).join('(the webhook)'); }+ return t.slice(0, 300);+}+function chatSpace(cfg) { const m = /\/spaces\/([A-Za-z0-9_-]+)/.exec((cfg && cfg.webhook) || ''); return m ? 'spaces/' + m[1].slice(0, 4) + '...' : ''; }+function chatParts(t) {+ const f = new Intl.DateTimeFormat('en-CA', { timeZone: CHAT_TZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false });+ const p = {}; for (const x of f.formatToParts(new Date(t))) p[x.type] = x.value;+ return { day: `${p.year}-${p.month}-${p.day}`, hour: (+p.hour) % 24 };+}+function chatPostable(e) {+ if (!e || typeof e !== 'object' || typeof e.rev !== 'number') return false;+ if (CHAT_SKIP_ACTIONS.has(e.action)) return false;+ return !/^adom-gantt( integrity)?$/.test(String(e.actor || ''));+}+function chatTitle() { try { return oneLine(loadData().project.title) || 'Plan'; } catch (e) { return 'Plan'; } }+function chatEsc(t) { return String(t).replace(/</g, '\u2039').replace(/>/g, '\u203a'); }+function chatBase(cfg) { const u = cfg && cfg.publicUrl; return typeof u === 'string' && /^https:\/\/[^\s<>|]+$/.test(u) ? u.replace(/\/+$/, '') : ''; }+// One line per change, the changelog's own sentence with the person (the section head+// says who) and the gesture ("(table-edit)") taken off.+function chatLine(e) {+ const who = e.actor || 'user';+ let t = describeEvent(Object.assign({}, e, { via: undefined }));+ if (t.startsWith(who + ' ')) t = t.slice(who.length + 1);+ return chatEsc(oneLine(t)).slice(0, 280);+}+function chatDigest(events, cfg) {+ const base = chatBase(cfg);+ const byWho = new Map();+ for (const e of events) { const w = e.actor || 'user'; if (!byWho.has(w)) byWho.set(w, []); byWho.get(w).push(e); }+ const n = events.length;+ const out = [`*${chatEsc(chatTitle())}* · ${n} change${n === 1 ? '' : 's'}${base ? ` · <${base}/|open the plan>` : ''}`];+ for (const [who, evs] of byWho) {+ out.push('', `*${chatEsc(who)}*${byWho.size > 1 || evs.length > 1 ? ` · ${evs.length}` : ''}`);+ evs.slice(0, 8).forEach(e => out.push('• ' + chatLine(e)));+ if (evs.length > 8) out.push(`• and ${evs.length - 8} more`);+ }+ if (base) out.push('', `<${base}/changelog?since=${events[0].rev - 1}&format=md|Every change since the last post>`);+ let text = out.join('\n');+ if (text.length > 3900) text = text.slice(0, 3880) + '\n…';+ return text;+}+function chatThreadKey(now) {+ return 'adom-gantt-' + path.basename(DATA_FILE).replace(/\.json$/, '').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 60) + '-' + chatParts(now).day;+}+async function chatPost(cfg, text, now) {+ let u = cfg.webhook;+ u += (u.includes('?') ? '&' : '?') + 'messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD';+ let r;+ try {+ r = await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=UTF-8' },+ body: JSON.stringify({ text, thread: { threadKey: chatThreadKey(now || Date.now()) } }) });+ } catch (e) { const err = new Error('could not reach Google Chat'); err.status = 0; throw err; }+ if (!r.ok) {+ const body = await r.text().catch(() => '');+ const err = new Error(chatScrub('Google Chat answered ' + r.status + (body ? ': ' + body.replace(/\s+/g, ' ') : ''), cfg));+ err.status = r.status; err.retryAfter = +(r.headers.get('retry-after') || 0);+ throw err;+ }+}+// The one place a digest is decided and sent. Run on a tick; never two at once.+async function chatTick(force) {+ if (chatBusy) return;+ const cfg = chatLoad();+ if (!cfg.webhook || !chatWebhookOk(cfg.webhook)) return;+ const cadence = CHAT_CADENCES.includes(cfg.cadence) ? cfg.cadence : 'live';+ if (cadence === 'off') return;+ const now = Date.now();+ if (!force && now < chatNextTry) return;+ const log = readLog();+ const head = log.length ? log[log.length - 1].rev : 0;+ if (typeof cfg.cursor !== 'number') { const c = chatLoad(); c.cursor = head; chatSave(c); return; }+ const pend = log.filter(e => e && typeof e.rev === 'number' && e.rev > cfg.cursor);+ if (!pend.length) return;+ const post = pend.filter(chatPostable);+ const top = pend[pend.length - 1].rev;+ if (!post.length) { const c = chatLoad(); c.cursor = top; chatSave(c); return; }+ const tsOf = e => Date.parse(e.ts) || now;+ const first = tsOf(post[0]), last = tsOf(post[post.length - 1]);+ let due = !!force;+ if (!due && cadence === 'live') due = now - last >= CHAT_QUIET_MS || now - first >= CHAT_MAX_MS;+ if (!due && cadence === 'hourly') due = now - (Date.parse(cfg.lastPostAt) || first) >= CHAT_HOURLY_MS;+ if (!due && cadence === 'daily') { const p = chatParts(now); due = p.hour >= CHAT_DAILY_HOUR && cfg.lastDailyDay !== p.day; }+ if (!due) return;+ chatBusy = true;+ try {+ await chatPost(cfg, chatDigest(post, cfg), now);+ const c = chatLoad();+ c.cursor = top; c.lastPostAt = new Date().toISOString(); c.posts = (c.posts || 0) + 1;+ if (cadence === 'daily') c.lastDailyDay = chatParts(now).day;+ delete c.lastError; chatSave(c);+ chatBackoff = 0; chatNextTry = 0;+ } catch (e) {+ const c = chatLoad();+ c.lastError = { at: new Date().toISOString(), message: chatScrub(e.message, cfg) };+ chatSave(c);+ // Back off: 30s, doubling, at most 15 minutes, or whatever Chat asks for.+ chatBackoff = Math.min(15 * 60000, chatBackoff ? chatBackoff * 2 : 30000);+ if (e.retryAfter) chatBackoff = Math.max(chatBackoff, e.retryAfter * 1000);+ chatNextTry = now + chatBackoff;+ } finally { chatBusy = false; }+}+function chatStatus() {+ const cfg = chatLoad();+ const configured = !!(cfg.webhook && chatWebhookOk(cfg.webhook));+ let pending = 0;+ if (configured && typeof cfg.cursor === 'number') { try { pending = readLog().filter(e => e && e.rev > cfg.cursor && chatPostable(e)).length; } catch (e) {} }+ return { ok: true, configured, space: configured ? chatSpace(cfg) : '',+ cadence: CHAT_CADENCES.includes(cfg.cadence) ? cfg.cadence : 'live',+ lastPostAt: cfg.lastPostAt || null, posts: cfg.posts || 0,+ lastError: cfg.lastError ? cfg.lastError : null, pending, publicUrl: chatBase(cfg) || null,+ timezone: CHAT_TZ, dailyHour: CHAT_DAILY_HOUR, quietMinutes: CHAT_QUIET_MS / 60000, maxMinutes: CHAT_MAX_MS / 60000 };+}+ // Every importer runs this before it writes anything. A task with no dates or a // milestone with no date is not a slightly-wrong row: the chart is a function of // dates, so it is a row that cannot be drawn, and it used to be accepted with@@ -2546,6 +2703,51 @@ const server = http.createServer(async (req, res) => { // client is v4-only on purpose, and the one translator lives on this side. if (urlPath === '/live') { liveOpen(req, res, auth, query); return; } + if (urlPath === '/notify') {+ if (req.method === 'GET') { jsonResp(res, 200, chatStatus()); return; }+ if (req.method !== 'POST') { jsonResp(res, 405, { ok: false, error: 'GET or POST' }); return; }+ let body;+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch (e) { jsonResp(res, 400, { ok: false, error: 'body must be JSON' }); return; }+ if (!body || typeof body !== 'object' || Array.isArray(body)) { jsonResp(res, 400, { ok: false, error: 'body must be an object' }); return; }+ // The webhook and the plan's public address are set from this machine or with the+ // agent token, never from a page: one is a credential, the other goes into every+ // message. (The plan's owner gets a field for them with the sharing setting.)+ const admin = !!auth.agent || isDirectLoopback(req);+ const cfg = chatLoad();+ const headRev = currentRev();+ if ('webhook' in body) {+ if (!admin) { jsonResp(res, 403, { ok: false, error: 'the webhook is set on the server, not from a page' }); return; }+ if (body.webhook === null || body.webhook === '') { delete cfg.webhook; delete cfg.lastError; }+ else if (!chatWebhookOk(body.webhook)) { jsonResp(res, 400, { ok: false, error: 'that is not a Google Chat incoming webhook URL' }); return; }+ else { cfg.webhook = body.webhook; cfg.cursor = headRev; delete cfg.lastError; if (!cfg.cadence) cfg.cadence = 'live'; }+ }+ if ('publicUrl' in body) {+ if (!admin) { jsonResp(res, 403, { ok: false, error: 'the public address is set on the server' }); return; }+ if (body.publicUrl === null || body.publicUrl === '') delete cfg.publicUrl;+ else if (typeof body.publicUrl !== 'string' || !/^https:\/\/[^\s<>|]+$/.test(body.publicUrl)) { jsonResp(res, 400, { ok: false, error: 'publicUrl must be an https address' }); return; }+ else cfg.publicUrl = body.publicUrl.replace(/\/+$/, '');+ }+ if ('cadence' in body) {+ if (!CHAT_CADENCES.includes(body.cadence)) { jsonResp(res, 400, { ok: false, error: 'cadence is one of ' + CHAT_CADENCES.join(', ') }); return; }+ // Turning it back on starts from now: a gap while it was off is not dumped into+ // the space all at once.+ if ((cfg.cadence === 'off') && body.cadence !== 'off') cfg.cursor = headRev;+ cfg.cadence = body.cadence;+ }+ chatSave(cfg);+ if (body.test) {+ if (!cfg.webhook || !chatWebhookOk(cfg.webhook)) { jsonResp(res, 409, { ok: false, error: 'no webhook is set' }); return; }+ const label = { live: 'as digests, a couple of minutes after each burst of editing', hourly: 'once an hour', daily: 'once a day', off: 'nowhere: posting is off' }[cfg.cadence || 'live'];+ try {+ await chatPost(cfg, `*${chatEsc(chatTitle())}* · test message from adom-gantt. Changes to this plan will be posted here ${label}.`, Date.now());+ const c = chatLoad(); c.lastPostAt = new Date().toISOString(); delete c.lastError; chatSave(c);+ } catch (e) { jsonResp(res, 502, Object.assign(chatStatus(), { ok: false, error: chatScrub(e.message, cfg) })); return; }+ }+ if (body.flush) await chatTick(true);+ jsonResp(res, 200, chatStatus());+ return;+ }+ if (urlPath === '/state') { const integrity = integrityStatus(); if (fs.existsSync(STATE_FILE)) {@@ -2798,11 +3000,15 @@ ensureUids('startup'); // as uids, which requires every row to have one. ensureStateV4(); +// The digest tick: decides on its own whether anything is due, so a quiet plan costs one+// small file read every 15 seconds and nothing else.+const chatTimer = setInterval(() => { chatTick().catch(() => {}); }, CHAT_TICK_MS);+if (chatTimer.unref) chatTimer.unref(); server.listen(PORT, '127.0.0.1', () => { console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`); console.log(`State file: ${STATE_FILE}`); console.log(`Data dir: ${DATA_DIR}`);- console.log(`Endpoints: /health /data /state /live /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+ console.log(`Endpoints: /health /data /state /live /notify /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`); const integ = integrityStatus(); console.log(integ.ok ? `Changelog integrity: OK (hash chain verified${integ.chained ? '' : ', not yet chained'})`
test/server_test.mjs+75−2@@ -57,14 +57,14 @@ async function freePort(from) { throw new Error('no free port in 8940-8949'); } -async function start(name, plan, sidecar, fromPort) {+async function start(name, plan, sidecar, fromPort, extraEnv) { const dir = path.join(TMP, name); fs.mkdirSync(dir, { recursive: true }); const data = path.join(dir, 'plan.json'); fs.writeFileSync(data, JSON.stringify(plan, null, 2) + '\n'); if (sidecar) fs.writeFileSync(path.join(dir, 'plan-state.json'), JSON.stringify(sidecar)); const port = await freePort(fromPort);- const p = spawn(process.execPath, [SERVER, '--data', data, '--port', String(port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'] });+ const p = spawn(process.execPath, [SERVER, '--data', data, '--port', String(port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'], env: Object.assign({}, process.env, extraEnv || {}) }); let log = ''; p.stdout.on('data', d => { log += d; }); p.stderr.on('data', d => { log += d; });@@ -419,6 +419,79 @@ async function main() { const missE = wantE.filter(l => !mdE.includes(l)); ok(missE.length === 0, 'the markdown changelog reads effort events as sentences, junk included', missE); stop(E);++ // ── Google Chat digests (0.67.0), against a fake Chat on loopback ──+ {+ const http = await import('node:http');+ const posts = []; let failNext = 0;+ const fake = http.createServer((req, res) => {+ let b = ''; req.on('data', d => b += d); req.on('end', () => {+ if (failNext > 0) { failNext--; res.writeHead(500); res.end('boom with token=SECRETTOKEN in it'); return; }+ try { posts.push({ url: req.url, body: JSON.parse(b) }); } catch (e) { posts.push({ url: req.url, body: null }); }+ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}');+ });+ });+ const fakePort = await new Promise(r => fake.listen(0, '127.0.0.1', () => r(fake.address().port)));+ const hook = `http://127.0.0.1:${fakePort}/v1/spaces/TEST/messages?key=SECRETKEY&token=SECRETTOKEN`;+ const fastEnv = { ADOM_GANTT_CHAT_ALLOW_LOOPBACK: '1', ADOM_GANTT_CHAT_QUIET_MS: '1200', ADOM_GANTT_CHAT_MAX_MS: '6000', ADOM_GANTT_CHAT_TICK_MS: '300' };+ const CH = await start('chat', basePlan(false), null, 8946, fastEnv);+ const wait = ms => new Promise(r => setTimeout(r, ms));+ const until = async (fn, ms = 6000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await wait(100); } return fn(); };+ let st = await getJ(CH, '/notify');+ ok(st.configured === false && st.cadence === 'live', 'GET /notify reports not configured on a fresh plan', st);+ let r = await post(CH, '/notify', { webhook: 'https://example.com/hook' });+ ok(r.status === 400, 'a URL that is not a Chat webhook is refused', r.status);+ r = await post(CH, '/notify', { webhook: hook, publicUrl: 'https://gantt-test.adom.cloud' });+ ok(r.status === 200 && r.j.configured === true, 'the webhook is set from this machine', r.j);+ ok(!JSON.stringify(r.j).includes('SECRET'), 'the status never carries the key or the token', r.j);+ const cf = path.join(CH.dir, 'plan-chat.json');+ ok(fs.existsSync(cf) && (fs.statSync(cf).mode & 0o777) === 0o600, 'the webhook is kept in a 0600 file beside the plan', (fs.statSync(cf).mode & 0o777).toString(8));+ // Two people, three changes; the server's own bookkeeping is not posted.+ await post(CH, '/log', { events: [+ { action: 'owner', id: 'etch', name: 'Etch the cards', from: { owner: '' }, to: { owner: 'Kai' }, actor: 'Ana Lopez', via: 'table-edit' },+ { action: 'dates', id: 'ship', name: 'Ship', from: { start: '2026-10-12', end: '2026-10-16' }, to: { start: '2026-10-15', end: '2026-10-19' }, shift: { start: 3, end: 3 }, actor: 'Ana Lopez', via: 'drag-move' },+ { action: 'status', id: 'ship', name: 'Ship', from: { status: 'not started' }, to: { status: 'in progress' }, actor: 'Kai Smith', via: 'table-edit' },+ { action: 'uid.assign', count: 1, actor: 'adom-gantt' },+ ] });+ ok(posts.length === 0, 'nothing is posted while the burst is still going');+ ok(await until(() => posts.length === 1), 'one digest after the plan has been quiet', posts.length);+ const d0 = posts[0] && posts[0].body;+ const t0 = (d0 && d0.text) || '';+ ok(/\*Ana Lopez\* · 2/.test(t0) && /\*Kai Smith\*/.test(t0), 'grouped by person, with counts', t0);+ ok(t0.includes('assigned "Etch the cards" to Kai') && t0.includes('moved +3d on "Ship"') && !t0.includes('(table-edit)'), 'each change in the changelog\'s own words, without the gesture', t0);+ ok(!/uid/.test(t0) && t0.includes('3 changes'), 'the server\'s own bookkeeping is left out', t0);+ ok(t0.includes('<https://gantt-test.adom.cloud/|open the plan>') && t0.includes('/changelog?since='), 'with a link to the plan and to the changes', t0);+ ok(d0 && d0.thread && /^adom-gantt-plan-\d{4}-\d{2}-\d{2}$/.test(d0.thread.threadKey) && /messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD/.test(posts[0].url), 'in the plan\'s thread for the day', [d0 && d0.thread, posts[0].url]);+ st = await getJ(CH, '/notify');+ ok(st.pending === 0 && st.posts === 1 && !!st.lastPostAt, 'the status says it posted and nothing is pending', st);+ // A failed post is retried and posted once.+ failNext = 1;+ await post(CH, '/log', { events: [{ action: 'desc', id: 'etch', name: 'Etch the cards', from: { desc: '' }, to: { desc: 'two passes' }, actor: 'Ana Lopez' }] });+ ok(await until(async () => !!(await getJ(CH, '/notify')).lastError), 'a failed post is recorded', await getJ(CH, '/notify'));+ st = await getJ(CH, '/notify');+ ok(!JSON.stringify(st).includes('SECRET'), 'and the error does not leak the key or the token', st.lastError);+ r = await post(CH, '/notify', { flush: true });+ ok(posts.length === 2 && posts[1].body.text.includes('two passes') && !r.j.lastError, 'the retry posts it, once', posts.length);+ // A restart does not post anything twice.+ stop(CH); await wait(400);+ const CH2 = await start('chat', basePlan(false), null, 8946, fastEnv);+ await wait(2000);+ ok(posts.length === 2, 'a restart resumes from the last posted change: nothing is posted twice', posts.length);+ // Off: nothing is posted, and turning it back on does not dump the gap.+ await post(CH2, '/notify', { cadence: 'off' });+ await post(CH2, '/log', { events: [{ action: 'owner', id: 'ship', name: 'Ship', from: { owner: '' }, to: { owner: 'Zed' }, actor: 'Ana Lopez' }] });+ await wait(2000);+ ok(posts.length === 2, 'with posting off, nothing is posted');+ await post(CH2, '/notify', { cadence: 'live' });+ await wait(2000);+ ok(posts.length === 2, 'turning it back on starts from now, not from the gap');+ // A test message goes at once.+ r = await post(CH2, '/notify', { test: true });+ ok(r.status === 200 && posts.length === 3 && /test message from adom-gantt/.test(posts[2].body.text), 'a test message posts at once', posts.length);+ r = await post(CH2, '/notify', { cadence: 'weekly' });+ ok(r.status === 400, 'an unknown cadence is refused');+ stop(CH2); fake.close();+ } } try { await main(); }
test/ui_test.mjs+45@@ -839,6 +839,7 @@ async function main() { if (wantAny('row tags')) await rowTagsFlow(browser); if (wantAny('effort')) await effortFlow(browser); if (wantAny('collab')) await collabFlow(browser);+ if (wantAny('notify')) await notifyFlow(browser); if (wantAny('viewport')) { // ── Viewport matrix ──@@ -1134,6 +1135,8 @@ const MANIFEST = [ { sel: '#bTag', label: 'batch: tag to add', terminal: true, by: 'row tags' }, { sel: '#bTagAdd', label: 'batch: add the tag to all', terminal: true, by: 'row tags' }, { sel: '#bEffort', label: 'batch: effort to set', terminal: true, by: 'effort' },+ { sel: '#chatCadence', label: 'setup: how often changes go to Google Chat', terminal: true, by: 'notify' },+ { sel: '#chatTest', label: 'setup: send a test message to Google Chat', terminal: true, by: 'notify' }, { sel: '#bEffortSet', label: 'batch: set the effort on all', terminal: true, by: 'effort' }, { sel: '.bt-x', label: 'batch: remove a tag from all', terminal: true, by: 'row tags', key: c => c.ds.tag, pin: ['long-lead'] }, { sel: '#legendSecs', label: 'legend: fold or unfold the section chips', safe: true, twice: true },@@ -3190,6 +3193,48 @@ async function collabFlow(browser) { await A.close(); await ctx.close(); } +// ── Google Chat digests (0.67.0): the Setup card, against a fake Chat on loopback ──+async function notifyFlow(browser) {+ section = 'notify';+ const http = await import('node:http');+ const posts = [];+ const fake = http.createServer((req, res) => { let b = ''; req.on('data', d => b += d); req.on('end', () => { try { posts.push(JSON.parse(b)); } catch { posts.push(null); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}'); }); });+ const fp = await new Promise(r => fake.listen(0, '127.0.0.1', () => r(fake.address().port)));+ const env0 = { ...process.env };+ Object.assign(process.env, { ADOM_GANTT_CHAT_ALLOW_LOOPBACK: '1', ADOM_GANTT_CHAT_QUIET_MS: '1500', ADOM_GANTT_CHAT_MAX_MS: '8000', ADOM_GANTT_CHAT_TICK_MS: '300' });+ const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew' });+ const X = await startServer({ project: { title: 'Notify Test', appName: 'notify-test', view: { mode: 'setup' } },+ phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }], items: [T('n1', 'Notify one', 0), T('n2', 'Notify two', 0)] }, 'notify');+ for (const k of ['ADOM_GANTT_CHAT_ALLOW_LOOPBACK', 'ADOM_GANTT_CHAT_QUIET_MS', 'ADOM_GANTT_CHAT_MAX_MS', 'ADOM_GANTT_CHAT_TICK_MS']) { if (k in env0) process.env[k] = env0[k]; else delete process.env[k]; }+ const post = (body) => fetch(X.url + 'notify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());+ const page = await openPage(browser, X.url);+ await view(page, 'setup'); await settle(page, 400);+ const card = () => page.evaluate(() => ({ state: document.getElementById('chatState').textContent, status: document.getElementById('chatStatus').textContent,+ cadence: document.getElementById('chatCadence').value, disabled: document.getElementById('chatCadence').disabled, test: document.getElementById('chatTest').disabled }));+ let c = await card();+ check('with no webhook, the card says so and its controls are off', c.state === 'not set up' && c.disabled && c.test && /No space is set up/.test(c.status), JSON.stringify(c));+ const wr = await fetch(X.url + 'notify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '1.2.3.4' }, body: JSON.stringify({ webhook: `http://127.0.0.1:${fp}/v1/spaces/UITEST/messages?key=K&token=T` }) });+ check('a request that is not from this machine cannot set the webhook', wr.status === 403, String(wr.status));+ await post({ webhook: `http://127.0.0.1:${fp}/v1/spaces/UITEST/messages?key=K&token=T` });+ await view(page, 'gantt'); await view(page, 'setup'); await settle(page, 400);+ c = await card();+ const leaks = await page.evaluate(() => /UITEST\/messages|key=K|token=T/.test(document.getElementById('suChat').outerHTML + document.body.innerText));+ check('once set, the card says on, names the space by its first characters, never the URL', c.state === 'on' && /spaces\/UITE\.\.\./.test(c.status) && !c.disabled && !leaks, JSON.stringify(c));+ await page.select('#chatCadence', 'hourly'); await settle(page, 400);+ check('the cadence is set from the card', (await (await fetch(X.url + 'notify')).json()).cadence === 'hourly');+ await page.select('#chatCadence', 'live'); await settle(page, 300);+ await page.click('#chatTest'); await settle(page, 600);+ check('Send a test message posts one', posts.length === 1 && /test message from adom-gantt/.test(posts[0].text), JSON.stringify(posts));+ // An edit in the app becomes a digest once the plan is quiet.+ await view(page, 'table');+ await page.click('#tableView tr[data-key="t:n1"] td[data-field="owner"]'); await settle(page, 100);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+ await page.keyboard.type('Kai'); await page.keyboard.press('Enter');+ const t0 = Date.now(); while (Date.now() - t0 < 8000 && posts.length < 2) await sleep(200);+ check('an edit in the app arrives as a digest a moment after the plan goes quiet', posts.length === 2 && /reassigned "Notify one" from Drew to Kai/.test(posts[1].text), JSON.stringify(posts[1]));+ await page.close(); fake.close();+}+ function cleanupSync() { if (cleanedUp) return; cleanedUp = true; for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }