← Commit history
README.md+3
@@ -64,6 +64,8 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Dependency arrows.** Teal routes with rounded elbows and arrowheads show task relationships: out of the source bar's end sideways, down, and into the next bar's start sideways again, steering round any bar in the way rather than through it. Click any bar to see incoming ("needs") and outgoing ("blocks") dependencies. Add or remove dependencies inline. +**Provenance links stay intact.** A row's provenance can be plain text ("Added by Drew") or a link to where it came from (a Chat thread, a document). A plain one is edited like any field. A linked one is shown as the link in the Table and in the item editor and its text cannot be changed there, so it always names what it opens (0.71.0).+ **Current and Overdue.** Two chips in the legend, for reviewing a plan with the team. **Current** (0.70.0) shows only what has started (start date today or earlier) and is not complete or cancelled: the work under way, plus anything that should have started and has not. Future and finished rows are hidden, in the chart and the table. **Overdue** shows only what is past its deadline and not done. Turn both on for just the overdue rows. Each counts tasks and milestones separately, and neither changes the plan.  **Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh. Since 0.64.0 the legend also leads with an **Owner** pick (every owner with their row count, plus **Unassigned**, shared with the table's owner filter) and an **Overdue** chip: rows whose end date or milestone date has passed without being marked complete or cancelled get a red edge and ring, and the chip shows only them. Overdue is worked out from the dates every time the page draws and at midnight; it is never written into the plan.@@ -440,6 +442,7 @@ loopback are open. `/health` is public and leaks nothing but counts. | `/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 | | `/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) | | `/save` | POST | Persist edits |
SKILL.md+4−2
@@ -171,7 +171,7 @@ items. | end | YYYY-MM-DD | yes | End date. Required | | phase | number | yes | Index into phases[] | | owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter |-| provenance | object or string | no | Where this row came from. `{"label":"POR","url":"https://..."}` or just `"Drew added this"`. Only http and https urls render as links; anything else shows as text |+| provenance | object or string | no | Where this row came from. `{"label":"POR","url":"https://..."}` or just `"Drew added this"`. Only http and https urls render as links; anything else shows as text. A LINKED provenance is fixed in the app (0.71.0): the Table cell and the item editor show it as the link and never as a text field, so its text stays the name of what it opens; a plain-text one is edited like any field. Change a link through the data file, an import or the agent API | | status | string | no | One of: "not-started", "in-progress", "complete", "blocked", "cancelled". That is the order the app shows them in everywhere, and since 0.56.0 it is the LIFECYCLE order: the road a row travels when nothing goes wrong, then the two ways off it. The order comes from one list (`STATUS_ORDER`), so the item and create panels, the table's status filter, the inline pill list, the analytics status card, the per-person card's five columns and the table's status-column collation cannot disagree, and sorting by status now ranks a complete row ahead of a blocked one. Membership is unchanged: the old "live" and "planned" are still read and shown as in progress / not started, and "canceled", "won't do", "abandoned" and "dropped" all read as cancelled | | description | string | no | Tooltip body text, and the Notes column in the table | | deps | string[] | no | Array of task IDs this depends on |@@ -250,6 +250,8 @@ rather than a category. - **The changelog actor is the signed-in identity**, taken from the session and never from the request body, so a shared plan's history reads "Drew Owens moved X" - **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field - **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart+- **Who has it open (0.71.0).** With two or more tabs on the plan, the legend ends with each person's initials and "N tabs"; the tooltip names them, with each person's tab count. A tab checks in with the server once a minute (`POST /live/ping?cid=`) and whenever it comes back into view; a stream not heard from for 150 s (`ADOM_GANTT_LIVE_STALE_MS`) is closed and leaves the count, and a tab that reconnects replaces its old entry. Before this, connections a proxy kept open after their tabs closed were counted (one plan showed "30 here" for one person). A tab the server has forgotten (the laptop slept) reopens its stream on its next check-in.+- **The Table's count says what it hides (0.71.0).** "333 of 415 items" has a tooltip listing why the rest are not shown: in collapsed sections, in sections switched off in the legend, by the legend filters (naming them: owner, tags, Current, Overdue), milestones switched off, the status filter, the search. Each hidden row counts once, under the first rule that hides it. - **Saving: every open tab is live, and two people no longer clobber each other (0.66.0).** Each tab holds a server-sent-events stream (`GET /live`). When a save lands, every other tab is told the new revision and who made it, pulls the state and merges it under its own unsaved work: rows this tab has not touched take the server's values, rows it has keep this tab's, and so do the section list, the row order and the tag registry when this tab changed them. A tab that moved every row (a plan shift, Auto-schedule) keeps all its rows. So a refresh has nothing to clobber, and two people editing different rows in the same second both land. A save is still whole-state and still carries the revision it was based on; a refused save (409) now pulls, merges and saves again on its own, and the red banner ("Your changes are not saving") is for a SECOND refusal in a row, which means the connection and not the plan. The one case this does not yet cover: two people editing the SAME row within a save of each other end with that row as the later saver had it; field-level ops, the next checkpoint, fix that. An import replaces the plan, so every other tab reloads with a toast saying who imported. The legend shows who else has the plan open (initials, the name on hover, "N here"), nothing when you are alone. The exported snapshot has no server and so no stream. - **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. **The Deps button glows** in the accent, breathing on the same 2.6s the critical path does, for exactly as long as the mode is on, and its tooltip carries the instruction in and out of the mode: "Adding dependencies. Click the task that comes first, then the one that waits on it. Click an arrow to pick it. Esc leaves the mode." There is no info bar under the legend any more, so entering and leaving the mode moves nothing on the page. **Creating a row leaves the mode first**: Shift+N and the New button both exit it (armed source dropped, wash cleared, picked arrow released, glow off) and then open the create panel, so you never end up typing a name over a chart that is still armed for a pairing. **The row under the pointer lights up**, name cell and chart band together, washed in that row's own phase colour, so a 20px bar and the name a pane away read as one row and you can see which task you are about to click. One row at a time, and it goes the instant the mode does. The armed first task wears the normal selection outline so you can see what the pair is waiting on. The pair resets and the mode stays on, so a run of dependencies is one sitting. Clicking a pair that ALREADY has a dependency REMOVES it, which is the same removal as the editor's ×: one undo entry, one changelog event. Refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. **Clicking an arrow picks it**, in this mode only, and **Delete** twice removes the dependency it stands for (the first press arms and says so, the second commits; any other key, a click or Esc withdraws the question). Outside the mode the arrows take no clicks at all. Esc lets go of a picked arrow, then a half-made pick, then leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder, and **no resize edges**. The two 8px drag handles are hidden outright in the mode, out of paint and out of hit-testing together, so the whole face of a bar is one clean pick target: on a one-day bar, which the app draws at a 6px floor, the handles covered the entire bar twice over and every aim at it returned a resize cursor instead of the bar. Leaving the mode restores them exactly, by any of the ways out. Milestones never had an edge handle, so the mode leaves their hit strip and its pointer cursor untouched. Ctrl+Z undoes the last add or removal without leaving the mode - **The batch panel's Delete** removes the WHOLE selection, milestones included, in one press. It is the compact panels' danger button in the compact panels' place: a muted ghost, right-aligned by `margin-left:auto` so it is clear of Apply and Cancel, red on hover, last in the panel's Tab walk. It lands on the same `deleteRows` the chart's armed Delete key and the table's Delete use, so it is one `multi` undo entry restoring every row where it stood, one changelog batch, dependencies on the departed rows dropped and their uids retired, whichever hand did it; only the `via` differs (`batch-delete` against `chart-delete`). One press and no arming, unlike the key: the key acts on a selection that may be entirely off screen and asks twice, where a button inside the panel that IS the selection made visible has already been aimed. The panel's headline counts the TASKS the date fields will move and says how many milestones it is leaving alone; the Delete's tooltip names the total it will actually remove, so the two counts cannot be read as one@@ -406,7 +408,7 @@ named in its own column. | Effort | `effort` in hours, or "not est." (click to type `16`, `2d`, `1w`; empty clears it; sorts by hours, blanks last) | blank, a milestone carries no effort | | Notes | `description`, truncated | same | | Tags | `tags`, the row's own, as pills, then its section's, dashed (0.60.0) | same |-| Provenance | `provenance` as a chip, linked when the url is http(s) | same |+| Provenance | `provenance` as a chip, linked when the url is http(s). A linked one is not editable (0.71.0) | same |  **Full editor.** Hovering a task or milestone row in the chart's label column reveals an Edit button that opens every field. Group rows have a collapse chevron instead. The table intentionally has no modal: its cells are all editable in place. 
docs/BUILD-NOTES.md+34
@@ -3,6 +3,40 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-22 (0.71.0, provenance links stay intact; presence counts real tabs)++Drew: "provenance links should not allow their text to be edited, they should remain+intact." A provenance is plain text or `{label, url}`. Both the Table cell and the item+editor edited the label of a linked one and kept the url underneath, so a link could end+up saying something other than what it opens; and in the Table, clicking the link opened+it AND turned the cell into a text box in the same click. A linked provenance is now+fixed in both places: the Table cell is not `editable` (so neither a click nor Tab opens+it, and the link just opens), the item editor shows the link where the field was, and+both commits refuse to touch a linked one. Plain-text provenance is unchanged.++Drew also asked what "333 of 415 items" and "DO 30 here" mean. The first is the Table's+count with filters or collapsed sections hiding rows; it now has a tooltip that says what+hides them (each hidden row counted once, under the first rule that hides it). The second+is the presence strip, and 30 was wrong: his old server held 30 established SSE+connections, all from 127.0.0.1, i.e. the editor's proxy keeping sockets open after their+tabs had gone. The server only dropped a client on a close event, which a proxy may never+pass on. Now a tab checks in once a minute (`POST /live/ping?cid=`, and on becoming+visible); a stream unheard for 150 s is closed and leaves the count; a reconnect with the+same cid replaces its old entry; a tab the server has forgotten reopens its stream. The+label reads "N tabs" instead of "N here".++Also: the 0.70.0 full run failed the coverage "no fiction" gate on #chatHookClear and+#shareOrg (both from 0.68.0, whose full run was deferred): each renders only in a state the+walk does not create (a webhook set; org sharing). Marked `required: false`; the notify+and share flows drive them.++Tests: a provenance flow (linked cell not editable and opens nothing on click, plain cell+editable, editor shows the link and keeps it through Apply, plain one still a field, the+count tooltip names the search and says when nothing is hidden), seven server checks on+presence (reconnect is one tab and closes the old socket, a second tab counts, a quiet tab+is dropped and its stream closed while a pinging one stays, a forgotten tab is told so,+POST only), and the collab flow's label check now reads "2 tabs".+ ## Build notes, 2026-10-22 (0.70.0, the Current filter)  Drew: "a filter for tasks that only shows tasks that have started prior to today and are
gantt.html+78−14
@@ -1216,6 +1216,11 @@ body.tbl-picking .cell-input { user-select: text; } a.prov-chip { border-color: rgba(0,184,177,0.35); color: var(--accent); } a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; } .data-table td.c-prov.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; }+/* A LINKED provenance is fixed (0.71.0): the link opens its source and its text is never+   retyped, in the table or in the item editor. A plain one stays editable. */+.data-table td.c-prov.prov-locked { cursor: default; }+.de-note-row .prov-fixed { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.6; padding: 2px 9px; border-radius: 999px; border: 1px solid rgba(0,184,177,0.35); color: var(--accent); text-decoration: none; }+.de-note-row .prov-fixed:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; } /* Delete: quiet until you are on the row, destructive-red only on the button    itself, and never wider than the glyph so it cannot push the other columns. */ .data-table th.th-del { width: 30px; }@@ -4617,10 +4622,28 @@ async function pullAndMerge(){ } // The stream of announcements from the server, and the presence it carries. let live=null, livePeople=[], liveTabs=0, liveYou=null, liveMerges=0, liveLastActor='';+// Once a minute, and whenever the tab comes back into view, the tab tells the server it is+// still open (0.71.0). The server forgets a stream it has not heard from in a couple of+// minutes, so the count in the legend is tabs that are open, not sockets a proxy kept+// after the tab went away. If the server has forgotten THIS tab (the laptop slept), the+// stream is opened again.+const LIVE_PING_MS=60000;+let livePing=null;+function pingLive(){+  if(SNAPSHOT||!live)return;+  const wasOpen=live.readyState===1;+  fetch('live/ping?cid='+encodeURIComponent(CLIENT_ID),{method:'POST'}).then(r=>r.ok?r.json():null).then(d=>{+    if(d&&d.known===false&&wasOpen&&live){ stopLive(); startLive(); }+  }).catch(()=>{});+} function startLive(){   if(SNAPSHOT||live||!window.EventSource)return;   const es=new EventSource('live?cid='+encodeURIComponent(CLIENT_ID));   live=es;+  if(!livePing){+    livePing=setInterval(pingLive,LIVE_PING_MS);+    document.addEventListener('visibilitychange',()=>{ if(!document.hidden)pingLive(); });+  }   es.onmessage=ev=>{     let m; try{ m=JSON.parse(ev.data); }catch(e){ return; }     if(m.type==='hello'){ liveYou=m.you||null; if(typeof m.stateRev==='number'&&m.stateRev>(baseRev||0)&&!saveInFlight)pullAndMerge().catch(()=>{}); return; }@@ -4653,9 +4676,9 @@ function renderPresence(){   if(liveTabs<2||!livePeople.length)return;   const el=document.createElement('span'); el.className='presence'; el.id='presence';   const names=livePeople.map(p=>p.name+(p.n>1?' ('+p.n+' tabs)':'')).join(', ');-  el.title='Also looking at this plan right now: '+names+'. Their saves land here live.';+  el.title='Open right now, in '+liveTabs+' tabs: '+names+'. What is saved in any of them shows up here live.';   el.innerHTML=livePeople.map(p=>`<span class="presence-chip"${liveYou&&p.name===liveYou.name?' data-you="1"':''} title="${esc(p.name)}${p.n>1?' ('+p.n+' tabs)':''}">${esc(p.initials||'?')}</span>`).join('')-    +`<span class="presence-n">${liveTabs===1?'1 here':liveTabs+' here'}</span>`;+    +`<span class="presence-n">${liveTabs===1?'1 tab':liveTabs+' tabs'}</span>`;   leg.appendChild(el); } // For the suite: pause and resume this tab's stream, and read what it knows.@@ -10042,7 +10065,9 @@ function openItemModal(r,carriedName,opts){       <div id="imEffWrap"><label>effort <span class="de-hint">hours</span></label><input type="text" id="imEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}"></div>     </div>     <div class="de-note-row"><label>notes</label><input type="text" id="imDesc" placeholder="Shown on hover and in the table"></div>-    <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="imProv"></div>+    <div class="de-note-row"><label>provenance <span class="de-hint">${p.url?'a linked source, kept as it is':'where this came from'}</span></label>${p.url+      ?`<a class="prov-fixed" id="imProvLink" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" title="${esc(p.label+', '+p.url)}">${esc(p.label)}</a>`+      :'<input type="text" id="imProv">'}</div>     <div class="de-note-row"><label>tags <span class="de-hint">Enter or a comma adds one</span></label>${tagBoxHtml('imTags','Tags on this row')}</div>     <div class="de-note-row" id="imNoteWrap"><label>label on the bar <span class="de-hint">a few characters, e.g. 6m</span></label><input type="text" id="imNote" maxlength="24" placeholder="e.g. 6m"></div>     <div class="de-dep-section" id="imDepsWrap"><label>dependencies</label><ul class="dep-list" id="imDepList"></ul>@@ -10103,7 +10128,7 @@ function openItemModal(r,carriedName,opts){   if(!ms)$('imAddDep').addEventListener('apppick',function(){ if(this.value&&!dNeeds.includes(this.value)){ dNeeds.push(this.value); renderDeps(); $('imAddDep').focus(); } });   $('imStatus').value=statusOf(r);   $('imDesc').value=r.description||'';-  $('imProv').value=p.label||'';+  if($('imProv'))$('imProv').value=p.label||'';   // The row's OWN tags only: the section's are the section's, edited on Setup.   const tagBox=wireTagBox($('imTags'),ownTags(r),'im');   const secT=phaseTagsOf(r);@@ -10163,9 +10188,12 @@ function openItemModal(r,carriedName,opts){       if(JSON.stringify(dNeeds)!==JSON.stringify(r.deps||[])){ r.deps=dNeeds.slice(); depsMoved=true; }       unblocked.forEach(b=>{ b.deps=(b.deps||[]).filter(d=>d!==r.id); depsMoved=true; });     }-    const pl=$('imProv').value.trim();-    if(!pl)delete r.provenance;-    else r.provenance=p.url?{label:pl,url:p.url}:pl;+    // A linked provenance is left exactly as it was (0.71.0); a plain one is the field.+    if(!p.url){+      const pl=$('imProv').value.trim();+      if(!pl)delete r.provenance;+      else r.provenance=pl;+    }     // Tags ride in the same undo entry as every other field (editSnapshot carries them).     // A row that had none and still has none is left without the key; one cleared here     // keeps `[]`, the sidecar's "cleared".@@ -10853,6 +10881,9 @@ function provOf(r){   return null; } function provLabel(r){ const p=provOf(r); return p?p.label:''; }+// A provenance with a link is a citation: the text names the source the link opens, so it+// is kept exactly as it came (0.71.0). Only a plain-text provenance is edited in the app.+function provLinked(r){ const p=provOf(r); return !!(p&&p.url); } // What goes in the sidecar and the changelog: the bare string when there is no // url, so a hand-written provenance stays hand-written. function provValue(r){ const p=provOf(r); return p ? (p.url?{label:p.label,url:p.url}:p.label) : ''; }@@ -10943,6 +10974,38 @@ function sortValueOf(r,key){   if(key==='effort'){ const e=effortOf(r); return e===null?'':String(Math.round(e*10)).padStart(8,'0'); }   return deadlineOf(r); }+// What the search box looks through, one place for the filter and for the count's reasons.+function tableSearchHay(r){+  return `${r.name||''} ${ownerOf(r)} ${phaseNameOf(r)} ${effTags(r).join(' ')} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();+}+// "333 of 415 items" says how many; its tooltip says why (0.71.0). Each hidden row is put+// down to the FIRST rule that hides it, in the order tableRows applies them.+function tableHiddenWhy(shown,total){+  const q=(searchInp.value||'').trim().toLowerCase(), st=statusSel.value, ks=tagFilterKeys();+  const n={collapsed:0,section:0,legend:0,ms:0,status:0,search:0};+  items.forEach(r=>{+    if(isGroup(r))return;+    if(r.phase!==undefined&&isCollapsed(r.phase)){ n.collapsed++; return; }+    if(r.phase!==undefined&&!phaseVis[r.phase]){ n.section++; return; }+    if(!rowShown(r,ks)){ n.legend++; return; }+    if(isMs(r)&&!msVis){ n.ms++; return; }+    if(st&&statusOf(r)!==st){ n.status++; return; }+    if(q&&tableSearchHay(r).indexOf(q)<0){ n.search++; return; }+  });+  const on=[];+  if(ownerFilterOn())on.push('owner '+(prefs.ownerFilter===OWNER_NONE?'Unassigned':prefs.ownerFilter));+  if(tagFilterOn())on.push('tags '+prefs.tagFilter.join(', '));+  if(prefs.currentOnly)on.push('Current');+  if(prefs.overdueOnly)on.push('Overdue');+  const why=[];+  if(n.collapsed)why.push(n.collapsed+' in collapsed sections');+  if(n.section)why.push(n.section+' in sections switched off in the legend');+  if(n.legend)why.push(n.legend+' by the legend filters'+(on.length?' ('+on.join('; ')+')':''));+  if(n.ms)why.push(n.ms+' milestones, switched off in the legend');+  if(n.status)why.push(n.status+' by the status filter');+  if(n.search)why.push(n.search+' by the search');+  return 'Showing '+shown+' of the plan\'s '+total+' tasks and milestones. Hidden: '+why.join('; ')+'.';+} function tableRows(){   const q=(searchInp.value||'').trim().toLowerCase();   const st=statusSel.value, ks=tagFilterKeys();@@ -10962,10 +11025,7 @@ function tableRows(){     if(isMs(r)&&!msVis)return;     const o=ownerOf(r);     if(st&&statusOf(r)!==st)return;-    if(q){-      const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${effTags(r).join(' ')} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();-      if(hay.indexOf(q)<0)return;-    }+    if(q&&tableSearchHay(r).indexOf(q)<0)return;     rows.push(r);   });   if(manualOrder())return rows;      // already in plan order@@ -11050,6 +11110,7 @@ function renderTable(){   const rows=tableRows();   const total=items.filter(r=>!isGroup(r)).length;   countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;+  countEl.title = rows.length===total ? 'Every task and milestone in the plan is listed.' : tableHiddenWhy(rows.length,total);   if(!rows.length){     // Collapse all empties the table as surely as a filter does, and "no items match"     // would send the reader looking at the filters for a cause that is not there.@@ -11081,7 +11142,8 @@ function renderTable(){       +`<td class="c-deps${(()=>{const d=depsOf(r);return (d.needs.length||d.blocks.length)?'':' empty';})()}">${depCell(r)}</td>`       +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`       +tagsCell(r,ed)-      +`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>`+      +(provLinked(r)?`<td class="c-prov prov-locked" data-field="prov" title="A linked source: click the link to open it. Its text is kept as it is.">${provCell(r)}</td>`+          :`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>`)       +`<td class="c-del">${editable?`<button class="row-del" type="button" tabindex="-1" title="Delete this row from the plan">&times;</button>`:''}</td>`       +'</tr>';   }).join('');@@ -11945,8 +12007,9 @@ function startCellEdit(td,caret){     el.innerHTML=phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('');     el.value=String(r.phase);   } else if(field==='prov'){-    // The label only. A url is a thing you paste from somewhere, not something-    // you retype in a grid cell, so it is preserved as-is underneath.+    // A linked provenance never gets here (its cell is not editable, 0.71.0); a plain+    // one is edited as text.+    if(provLinked(r))return;     el=document.createElement('input');el.type='text';     el.placeholder='where this came from';el.value=provLabel(r);   } else if(field==='effort'){@@ -12103,6 +12166,7 @@ function commitCell(r,field,v){   } else if(field==='prov'){     v=v.trim();     const cur=provOf(r);+    if(cur&&cur.url)return;              // a linked source is kept as it is (0.71.0)     if((cur?cur.label:'')===v)return;     snap();     if(!v)delete r.provenance;
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.70.0",+  "version": "0.71.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.70.0",+  "version": "0.71.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+33−2
@@ -524,7 +524,11 @@ function liveOpen(req, res, auth, query) {   res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform',     'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });   res.write('retry: 3000\n\n');-  const c = { res, cid, who: whoOf(auth), auth };+  // One tab, one entry (0.71.0). A tab whose stream dropped and came back (a proxy cut it,+  // 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) {} }+  const c = { res, cid, who: whoOf(auth), auth, seen: Date.now() };   liveClients.add(c);   liveSend(c, { type: 'hello', stateRev: stateRev(), you: c.who });   broadcastPresence();@@ -532,6 +536,23 @@ function liveOpen(req, res, auth, query) {   const bye = () => { clearInterval(hb); if (liveClients.delete(c)) broadcastPresence(); };   req.on('close', bye); res.on('close', bye); res.on('error', bye); }+// A tab says it is still open once a minute (POST /live/ping, 0.71.0). A connection not+// heard from in LIVE_STALE_MS belongs to a tab that is gone while a proxy in between kept+// the socket open: seen 2026-10-22, 30 such connections behind an editor's proxy, shown as+// "30 here" for one person with a couple of tabs. It is closed and leaves the count.+const LIVE_STALE_MS = +process.env.ADOM_GANTT_LIVE_STALE_MS || 150000;+function reapLive() {+  const now = Date.now();+  let n = 0;+  for (const c of [...liveClients]) {+    if (now - (c.seen || 0) <= LIVE_STALE_MS) continue;+    liveClients.delete(c);+    try { c.res.end(); } catch (e) {}+    n++;+  }+  if (n) broadcastPresence();+  return n;+} // After the sharing changes, the open streams of people it no longer lets in are told and // closed, so nobody goes on watching a plan that has stopped being shared with them. function dropRefusedLive() {@@ -2837,6 +2858,14 @@ const server = http.createServer(async (req, res) => {     // belt and braces for a sidecar dropped in underneath a running server: the     // 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 === '/live/ping') {+      if (req.method !== 'POST') { jsonResp(res, 405, { ok: false, error: 'POST' }); return; }+      const cid = String((query.match(/(?:^|&)cid=([^&]*)/) || [])[1] || '').slice(0, 40);+      let known = false;+      for (const c of liveClients) if (cid && c.cid === cid) { c.seen = Date.now(); known = true; }+      jsonResp(res, 200, { ok: true, known, tabs: liveClients.size });+      return;+    }      if (urlPath === '/access') {       const a = accessView();@@ -3193,11 +3222,13 @@ ensureStateV4(); // small file read every 15 seconds and nothing else. const chatTimer = setInterval(() => { chatTick().catch(() => {}); try { dailyBackupTick(); } catch (e) {} }, CHAT_TICK_MS); if (chatTimer.unref) chatTimer.unref();+const liveReaper = setInterval(reapLive, Math.max(200, Math.min(30000, Math.floor(LIVE_STALE_MS / 3))));+if (liveReaper.unref) liveReaper.unref(); server.listen(PORT, HOST, () => {   console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://${HOST.includes(':') ? '[' + HOST + ']' : HOST}:${PORT}`);   console.log(`State file: ${STATE_FILE}`);   console.log(`Data dir: ${DATA_DIR}`);-  console.log(`Endpoints: /health /data /state /live /access /notify /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+  console.log(`Endpoints: /health /data /state /live /live/ping /access /notify /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /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+28
@@ -644,6 +644,34 @@ async function main() {   stop(L2);   carbon.close();   }++  // ── Presence counts open tabs (0.71.0): a tab that reconnects replaces its old entry,+  // and a stream whose tab stops checking in is closed and leaves the count.+  {+  const P = await start('presence', basePlan(false), null, 8946, { ADOM_GANTT_LIVE_STALE_MS: '1500' });+  const wait = ms => new Promise(r => setTimeout(r, ms));+  const open = async cid => {+    const r = await fetch(P.base + '/live?cid=' + cid); const rd = r.body.getReader();+    const done = (async () => { for (;;) { const x = await rd.read(); if (x.done) return true; } })().catch(() => true);+    return { done };+  };+  const ended = (s, ms = 1500) => Promise.race([s.done, wait(ms).then(() => false)]);+  const ping = async cid => (await post(P, '/live/ping?cid=' + cid, {})).j || {};+  const a1 = await open('tabA'); await wait(200);+  await open('tabA'); await wait(200);+  let pj = await ping('tabA');+  ok(pj.known === true && pj.tabs === 1, 'a tab that reconnects is one tab, not two', pj);+  ok(await ended(a1) === true, 'and its old connection is closed');+  const b = await open('tabB'); await wait(200);+  ok((await ping('tabB')).tabs === 2, 'a second tab counts');+  for (let i = 0; i < 7; i++) { await wait(450); await ping('tabA'); }+  pj = await ping('tabA');+  ok(pj.tabs === 1 && pj.known === true, 'a tab that stops checking in drops out of the count; one that pings stays', pj);+  ok(await ended(b) === true, 'and the quiet one\'s stream is closed');+  ok((await ping('tabB')).known === false, 'its next ping says it was forgotten, so the tab reopens its stream');+  ok((await fetch(P.base + '/live/ping?cid=tabA')).status === 405, 'ping is POST only');+  stop(P);+  } }  try { await main(); }
test/ui_test.mjs+45−3
@@ -845,6 +845,7 @@ async function main() {     if (wantAny('collab')) await collabFlow(browser);     if (wantAny('notify')) await notifyFlow(browser);     if (wantAny('share')) await shareFlow(browser);+    if (wantAny('provenance')) await provFlow(browser);      if (wantAny('viewport')) {       // ── Viewport matrix ──@@ -1144,9 +1145,12 @@ const MANIFEST = [   { sel: '#chatTest', label: 'setup: send a test message to Google Chat', terminal: true, by: 'notify' },   { sel: '#chatHook', label: 'setup: the owner\'s webhook field', terminal: true, by: 'notify' },   { sel: '#chatHookSave', label: 'setup: set the webhook', terminal: true, by: 'notify' },-  { sel: '#chatHookClear', label: 'setup: remove the webhook', terminal: true, by: 'notify' },+  // Renders only once a webhook is set, which the coverage walk does not do (a real-looking+  // Chat URL would have the notifier post to it). The notify flow sets one and removes it.+  { sel: '#chatHookClear', label: 'setup: remove the webhook', terminal: true, by: 'notify', required: false },   { sel: '#shareMode', label: 'setup: who can open the plan', terminal: true, by: 'share' },-  { sel: '#shareOrg', label: 'setup: which organization', terminal: true, by: 'share' },+  // Renders only when the plan is shared with an org; the share flow switches to that mode.+  { sel: '#shareOrg', label: 'setup: which organization', terminal: true, by: 'share', required: false },   { 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 },@@ -3189,7 +3193,7 @@ async function collabFlow(browser) {   const presence = pg => pg.evaluate(() => { const e = document.getElementById('presence'); return e ? e.querySelector('.presence-n').textContent : ''; });    // Presence: both tabs see two people here.-  check('both tabs show "2 here" in the legend', await until(async () => (await presence(A)) === '2 here' && (await presence(B)) === '2 here'), `${await presence(A)} / ${await presence(B)}`);+  check('both tabs show "2 tabs" in the legend', await until(async () => (await presence(A)) === '2 tabs' && (await presence(B)) === '2 tabs'), `${await presence(A)} / ${await presence(B)}`);   check('the chips carry initials with the name on hover', await A.$eval('#presence .presence-chip', e => e.textContent.length >= 1 && !!e.title));    // 1. A edits one row; B sees it without reloading.@@ -3312,6 +3316,44 @@ async function notifyFlow(browser) {   check('the second click removes it', (await (await fetch(X.url + 'notify')).json()).configured === false && c.state === 'not set up', JSON.stringify(c));   await page.close(); fake.close(); }+// 0.71.0: a LINKED provenance is fixed (a link, never a text field, in the table and the+// item editor), a plain one is still edited; and the table's count says what it hides.+async function provFlow(browser) {+  section = 'provenance';+  const T = (id, name, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: 0, owner: 'Drew', ...extra });+  const LINK = { label: 'Chat thread', url: 'https://example.com/thread/1' };+  const X = await startServer({ project: { title: 'Prov Test', appName: 'prov-test' }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }],+    items: [T('l1', 'Linked one', { provenance: LINK }), T('p1', 'Plain one', { provenance: 'Added by Drew' }), T('n1', 'Nothing here', {})] }, 'prov');+  const page = await openPage(browser, X.url);+  await view(page, 'table');+  const cell = id => `#tableView tr[data-key="t:${id}"] td[data-field="prov"]`;+  const lc = await page.$eval(cell('l1'), td => ({ ed: td.classList.contains('editable'), locked: td.classList.contains('prov-locked'), href: (td.querySelector('a.prov-chip') || {}).href || '', text: td.textContent.trim() }));+  check('a linked provenance cell is a link and is not editable', !lc.ed && lc.locked && lc.href === LINK.url && lc.text === LINK.label, JSON.stringify(lc));+  await page.$eval(cell('l1'), td => td.dispatchEvent(new MouseEvent('click', { bubbles: true }))); await settle(page, 200);+  check('clicking that cell opens no text field', !(await page.$('.cell-edit')));+  check('a plain provenance cell stays editable', await page.$eval(cell('p1'), td => td.classList.contains('editable')));+  await page.click(cell('p1')); await settle(page, 200);+  check('and opens a text field holding its text', (await page.$eval('.cell-edit input', e => e.value).catch(() => '')) === 'Added by Drew');+  await page.keyboard.press('Escape'); await settle(page, 150);+  await view(page, 'gantt');+  await openItemEditor(page, 'Linked one');+  const ed = await page.evaluate(() => { const a = document.getElementById('imProvLink'); return { input: !!document.getElementById('imProv'), href: a ? a.href : '', text: a ? a.textContent : '' }; });+  check('the item editor shows a linked provenance as the link, with no field to retype it', !ed.input && ed.href === LINK.url && ed.text === LINK.label, JSON.stringify(ed));+  await setVal(page, '#imDesc', 'reviewed'); await page.click('#imApply'); await settle(page, 300);+  const saved = (await savedPlan(X.url)).items.find(r => r.id === 'l1') || {};+  check('applying another change leaves the provenance exactly as it was', saved.description === 'reviewed' && saved.provenance && saved.provenance.label === LINK.label && saved.provenance.url === LINK.url, JSON.stringify(saved));+  await openItemEditor(page, 'Plain one');+  check('a plain provenance is still a text field in the editor', (await page.$eval('#imProv', e => e.value).catch(() => '')) === 'Added by Drew');+  await page.keyboard.press('Escape'); await settle(page, 200);+  // The count says why rows are missing.+  await view(page, 'table');+  await page.click('#tblSearch'); await page.keyboard.type('plain'); await settle(page, 400);+  const cnt = await page.$eval('#tblCount', e => ({ t: e.textContent, title: e.title }));+  check('a filtered table\'s count says how many rows are hidden and by what', cnt.t === '1 of 3 items' && /Hidden: 2 by the search/.test(cnt.title), JSON.stringify(cnt));+  await page.click('#tblSearch', { clickCount: 3 }); await page.keyboard.press('Backspace'); await settle(page, 300);+  check('and with nothing hidden it says every row is listed', /Every task and milestone/.test(await page.$eval('#tblCount', e => e.title)));+  await page.close();+} // The Sharing card (0.68.0). Under --no-auth the page is the operator, so it may change the // setting; the card says that sign-in is off. The gate itself is proven server-side // against a fake Adom identity service (test/server_test.mjs).