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.74.0
7 files changed
+222−45
docs/BUILD-NOTES.md+38@@ -3,6 +3,44 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-28 (0.74.0, workday clock, team hides scheduling, who is on which row, bare links)++Drew: durations "as decimals e.g. 14.5 weeks remaining, then a "/" and working day count";+hide Auto-schedule and the critical path from the team; "task highlighting to show what+people are working on, along w/ a little circle w/ their initial/pic"; and, after pasting a+copied link into an address bar and getting a Google search, links that sign you in if+needed and land on the row in the Gantt view.++Readouts: "16.6 wks / 79 workdays" (calendar weeks to one decimal; workdays without weekends+and observed federal holidays, the same bizBetween as the tooltips). The suite's oracle has+its own holiday calendar written from the rules. A whole-plan shift keeps the weeks; the+workdays legitimately change (weekends and holidays fall differently), so the shift checks+compare weeks and the oracle checks the workdays.++Team: #btnAuto and #btnCrit hidden for data-role=member, and their click handlers refuse.++Who is on which row: each tab posts the uids it has selected, open in the editor or focused in+the table (POST /live/focus?cid=, debounced 250 ms, at most 25, junk dropped); the server keeps+them on the live connection and sends them with every presence broadcast; every other tab+tints those rows on the chart and in the table in the person's colour (stable hash of their+id) with initials, or their Adom picture when Carbon has an https one. Your own tabs are left+out. Gone when the tab goes, like the presence.++Links: the copy is now the bare link (#row=<uid>-<name slug>); the named link stays in the+HTML flavour. The sign-in page already kept the fragment and reloaded to it, so a signed-out+reader signs in and lands on the row. Opening a link now always goes to the chart, opens the+section, and clears any view filter that would hide the row, saying so.++Load (Drew: "will this effectively support the team of ~12 people working at once"), measured+on the service container itself (1 vCPU, 4 GB) against a copy of the live plan (415 rows), 12+simulated tabs each holding the live stream and pulling the full state after every save by+someone else, as the page does: an edit every ~4 s per person (2.7 saves/s, far above real+use) gave save p50 23 ms / p95 213 ms, state pull p50 106 ms / p95 209 ms, no errors, the+server at about a third of its CPU and ~110 MB; 140 of ~385 save attempts hit a 409 and were+retried automatically. An edit every ~20 s per person (0.73 saves/s): save p50 18 ms / p95+234 ms, 4 conflicts, no errors. The cost that grows is the full-state pull (~255 KB per tab per+save by someone else); field-level ops would shrink both that and the conflicts.+ ## Build notes, 2026-10-28 (0.73.0, row links, the team's view, tags fold) Drew, sharing the plan with the team: "human copyable task ID/links so we can share in gchat
gantt.html+98−16@@ -1492,6 +1492,18 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 /* The team's view (0.73.0): Analytics and the plan's Sharing and Chat settings are the owner's. The owner can preview the team's view from the Sharing card. */ :root[data-role="member"] .vt-btn[data-view="analytics"], :root[data-role="member"] #suShare, :root[data-role="member"] #suChat { display: none !important; }+/* Auto-schedule and the critical path are the owner's too (0.74.0): one click re-times+ everybody's rows. */+:root[data-role="member"] #btnAuto, :root[data-role="member"] #btnCrit { display: none !important; }+/* Who is on which row (0.74.0): another person's selected or open rows carry their colour+ and a small initials circle, on the chart and in the table. */+.label-row.peer-focus { background: color-mix(in srgb, var(--peer) 12%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--peer) 70%, transparent); }+.bar-row.peer-focus .bar { box-shadow: 0 0 0 2px var(--peer); }+.data-table tr.peer-focus td { background: color-mix(in srgb, var(--peer) 10%, transparent); }+.peer-chips { display: inline-flex; gap: 2px; vertical-align: middle; pointer-events: auto; }+.label-row .peer-chips { position: absolute; right: 52px; top: 50%; transform: translateY(-50%); z-index: 10; }+.peer-chip { width: 16px; height: 16px; border-radius: 50%; background: var(--peer); color: #0d1117; font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; font-size: 8px; line-height: 16px; text-align: center; overflow: hidden; box-shadow: 0 0 0 1.5px var(--bg); flex-shrink: 0; }+.peer-chip img { width: 100%; height: 100%; object-fit: cover; display: block; } #memberPreview { position: fixed; top: 0; left: 50%; transform: translateX(-50%); z-index: 100002; display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--elevated); border: 1px solid var(--accent); border-top: none; border-radius: 0 0 8px 8px; font-size: 12px; color: var(--text); box-shadow: 0 6px 18px rgba(0,0,0,0.35); } .label-row:hover .row-edit { opacity: 1; pointer-events: auto; } .row-edit:hover { color: var(--accent); border-color: var(--accent); }@@ -4669,7 +4681,7 @@ function startLive(){ 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; }- if(m.type==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; renderPresence(); return; }+ if(m.type==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; liveFocus=Array.isArray(m.focus)?m.focus:[]; renderPresence(); paintPeerFocus(); return; } if(m.type==='rev'){ if(m.by===CLIENT_ID)return; if(typeof m.stateRev!=='number'||m.stateRev<=(baseRev||0))return;@@ -4692,6 +4704,57 @@ function stopLive(){ if(live){ live.close(); live=null; } } // Who has the plan open, at the right end of the legend. Nothing when this tab is alone: // a chip saying "you" on every plan would be noise. One chip per person (initials, the // name on hover, a count when they have several tabs), and the number of people.+// ── Who is on which row (0.74.0) ──+// Each tab tells the server which rows it has selected, open in the editor or focused in the+// table (POST /live/focus, a quarter second after it changes). Every other tab marks those+// rows in that person's colour with their initials (their picture when Adom has one), on the+// chart and in the table, so people can see what the others are in before they edit it.+// Your own tabs are left out.+let liveFocus=[], sentFocus='', focusTimer=null;+const PEER_COLORS=['#00b8b1','#8c6bf7','#e8b64a','#ef6f6c','#4ea8de','#7bc96f','#f28fbe','#c9a26b'];+function peerColor(key){ let h=0; for(const ch of String(key||''))h=(h*31+ch.charCodeAt(0))>>>0; return PEER_COLORS[h%PEER_COLORS.length]; }+function myFocusUids(){+ const s=new Set([...selected].filter(Boolean));+ if(edSubject&&edSubject.uid)s.add(edSubject.uid);+ if(typeof focusedKey!=='undefined'&&focusedKey){ const r=rowItem(focusedKey); if(r&&r.uid)s.add(r.uid); }+ return [...s].slice(0,25);+}+function scheduleFocus(){+ if(SNAPSHOT||!live)return;+ clearTimeout(focusTimer);+ focusTimer=setTimeout(()=>{+ const u=myFocusUids(), sig=JSON.stringify(u);+ if(sig===sentFocus)return; sentFocus=sig;+ fetch('live/focus?cid='+encodeURIComponent(CLIENT_ID),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({uids:u})}).catch(()=>{});+ },250);+}+function peerChipHtml(p){+ const inner=p.avatar?`<img src="${esc(p.avatar)}" alt="" onerror="this.remove()">`:'';+ return `<span class="peer-chip" style="--peer:${p.color}" title="${esc(p.name)} is on this row">${inner||esc(p.initials||'?')}</span>`;+}+function paintPeerFocus(){+ document.querySelectorAll('.peer-chips').forEach(e=>e.remove());+ document.querySelectorAll('.peer-focus').forEach(e=>{ e.classList.remove('peer-focus'); e.style.removeProperty('--peer'); });+ const byUid=new Map();+ liveFocus.forEach(f=>{+ if(f.cid===CLIENT_ID)return;+ if(f.id&&liveYou&&liveYou.id&&f.id===liveYou.id)return;+ const p={name:f.name,initials:f.initials,avatar:f.avatar,color:peerColor(f.id||f.name)};+ (f.uids||[]).forEach(u=>{ const a=byUid.get(u)||[]; if(!a.some(x=>x.name===p.name))a.push(p); byUid.set(u,a); });+ });+ if(!byUid.size)return;+ const rows=new Map(items.filter(r=>!isGroup(r)&&r.uid).map(r=>[r.uid,r]));+ const trs=tableBody?new Map([...tableBody.querySelectorAll('tr[data-key]')].map(t=>[t.dataset.key,t])):new Map();+ byUid.forEach((people,u)=>{+ const r=rows.get(u); if(!r)return;+ const chips='<span class="peer-chips">'+people.slice(0,3).map(peerChipHtml).join('')+'</span>';+ const mark=el=>{ if(!el)return; el.classList.add('peer-focus'); el.style.setProperty('--peer',people[0].color); };+ mark(r._lDiv); mark(r._bDiv);+ if(r._lDiv)r._lDiv.insertAdjacentHTML('beforeend',chips);+ const tr=trs.get(rowKey(r));+ if(tr){ mark(tr); const td=tr.querySelector('td.c-uid'); if(td)td.insertAdjacentHTML('beforeend',chips); }+ });+} function renderPresence(){ const leg=$('legend'); if(!leg)return; const old=$('presence'); if(old)old.remove();@@ -5399,14 +5462,17 @@ depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.class // field is its name, and that is the phase, which is a different thing to edit. // ── A link to one row (0.73.0) ── // Every task and milestone has a permanent ID (its uid, "#00042"), and a link to it is the-// plan's address plus #row=<uid>. Copying puts "#00042 Name: <link>" on the clipboard as-// text, and the same as a named link for anything that takes HTML (Google Chat's composer-// does). Opening the link selects the row, scrolls to it and marks it for a moment.-function rowLink(r){ return location.origin+location.pathname+'#row='+encodeURIComponent(r.uid||''); }+// plan's address plus #row=<uid>-<name>, the name only there so a bare link still says what+// it points at. Copying puts the BARE LINK on the clipboard as text (0.74.0: the text used to+// lead with "#00042 Name:", and pasted into an address bar that is a search, not a link), and+// a named link for anything that takes HTML. Opening the link (signing in first if needed:+// the sign-in page keeps the fragment and reloads to it) shows the row on the chart.+function rowSlug(r){ return String(r.name||r.label||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,48).replace(/-+$/,''); }+function rowLink(r){ const sl=rowSlug(r); return location.origin+location.pathname+'#row='+encodeURIComponent(r.uid||'')+(sl?'-'+sl:''); } function rowRef(r){ return '#'+(r.uid||'?')+' '+(r.name||r.label||''); } function copyRowLink(r,el){ if(!r||!r.uid){ flash(saveInd,'this row has no permanent ID yet'); return; }- const url=rowLink(r), txt=rowRef(r)+': '+url;+ const url=rowLink(r), txt=url; window.__lastRowLink=txt; // for the suite: headless Chrome does not show its clipboard const done=()=>{ flash(saveInd,'Copied a link to '+rowRef(r),2800); if(el){ el.classList.add('copied'); setTimeout(()=>el.classList.remove('copied'),1200); } }; try{@@ -5430,16 +5496,25 @@ function makeLinkBtn(r){ return b; } // Opening a link: #row=<uid> in the address, at load or pasted into an open tab.-function linkedUid(){ const m=String(location.hash||'').match(/(?:^#|&)row=([^&]+)/); return m?decodeURIComponent(m[1]):''; }+function linkedUid(){ const m=String(location.hash||'').match(/(?:^#|&)row=([0-9A-Za-z_]+)/); return m?m[1]:''; } function openRowLink(){ const u=linkedUid(); if(!u)return; try{ const h=String(location.hash).replace(/^#/,'').split('&').filter(kv=>kv&&!/^row=/.test(kv)).join('&'); history.replaceState(null,'',location.pathname+location.search+(h?'#'+h:'')); }catch(e){} const r=items.find(x=>!isGroup(x)&&x.uid===u); if(!r){ flash(saveInd,'There is no row #'+u+' in this plan (it may have been deleted)',4500); return; }- if(prefs.view!=='gantt'&&prefs.view!=='table')setView('gantt');+ // A link always lands on the chart, with the row on screen: its section opened, and any+ // view filter that would hide it switched off (0.74.0, Drew: "brought to that task in+ // gantt view and see the row selected/highlighted").+ if(prefs.view!=='gantt')setView('gantt'); if(r.phase!==undefined&&isCollapsed(r.phase))setCollapsed(r.phase,false); const hidden=(r.phase!==undefined&&!phaseVis[r.phase])||!rowShown(r)||(isMs(r)&&!msVis);+ if(hidden){+ if(r.phase!==undefined)phaseVis[r.phase]=true;+ if(isMs(r)&&!msVis){ msVis=true; msL.classList.remove('off'); }+ prefs.ownerFilter=''; prefs.tagFilter=[]; prefs.currentOnly=false; prefs.overdueOnly=false; savePrefs();+ applyTagView();+ } selectSole(r); const pulse=el=>{ if(!el)return; el.classList.remove('linked'); void el.offsetWidth; el.classList.add('linked'); setTimeout(()=>el.classList.remove('linked'),2800); }; if(prefs.view==='table'){@@ -5451,7 +5526,7 @@ function openRowLink(){ if(target&&target.scrollIntoView)target.scrollIntoView({block:'center',inline:'center'}); pulse(r._lDiv); pulse(r._bDiv); }- flash(saveInd,rowRef(r)+(hidden?' is hidden by your filters':''),4000);+ flash(saveInd,rowRef(r)+(hidden?': your filters were hiding it, so they are cleared':''),5000); } function makeEditBtn(r){ const b=document.createElement('button');@@ -6491,6 +6566,7 @@ function syncCopyBtn(){ } function updateSelCount(){ syncCopyBtn();+ scheduleFocus(); if(selected.size){ selCountEl.innerHTML=`<span class="n">${selected.size}</span> selected · Copy IDs`; selCountEl.title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits";@@ -8353,7 +8429,7 @@ function setEditorKeys(fn){ } // Closes the lists the PANEL opened, and only those: a Table cell's list is opened by the // same click that tells an open panel to close, and must survive it.-function closeEditor(){edSubject=null;setEditorKeys(null);if(cellList&&editorEl.contains(cellList.sel))closeCellList();if(ownerCombo&&editorEl.contains(ownerCombo.inp))closeOwnerCombo();editorEl.classList.remove('show','square','compact');editorEl.innerHTML='';}+function closeEditor(){edSubject=null;scheduleFocus();setEditorKeys(null);if(cellList&&editorEl.contains(cellList.sel))closeCellList();if(ownerCombo&&editorEl.contains(ownerCombo.inp))closeOwnerCombo();editorEl.classList.remove('show','square','compact');editorEl.innerHTML='';} function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');} // ── The keyboard on the menus (0.69.0) ── // Export, Import, Help and the who-card are one kind of menu, opened from a button that@@ -9350,6 +9426,10 @@ function fmtSpan(n){ return (Math.round(n/365.25*10)/10)+' years'; } function plural(n,w){ return n+' '+w+(n===1?'':'s'); }+// 0.74.0 (Drew): "14.5 wks / 73 workdays", weeks as a decimal and the working days left, so+// the time ticking away is always in view. Weeks are calendar weeks (days / 7, one decimal);+// workdays leave out weekends and observed federal holidays, as everywhere in the plan.+function fmtSpanWd(days,wd){ return (Math.round(days/7*10)/10).toFixed(1)+' wks / '+plural(wd,'workday'); } // Read at call time, never cached, so a tab left open overnight moves on with the // calendar (see the minute tick below). Tests pin the page's clock, not this. function spanToday(){ return isoDay(new Date()); }@@ -9371,7 +9451,7 @@ function syncPlanSpan(){ return; } const days=dayNum(last)-dayNum(first)+1;- dv.textContent=fmtSpan(days);+ dv.textContent=fmtSpanWd(days,bizBetween(first,last)); dur.dataset.state='ok'; dur.title=`${plural(days,'calendar day')} (${plural(bizBetween(first,last),'working day')}), from the plan's first date ${fmt(first)} to its last date ${fmt(last)}, both counted.${out}`; const T=dayNum(today), F=dayNum(first), L=dayNum(last);@@ -9382,7 +9462,7 @@ function syncPlanSpan(){ return; } const rem=L-T+1;- lv.textContent=fmtSpan(rem);+ lv.textContent=fmtSpanWd(rem,bizBetween(today,last)); left.dataset.state=T<F?'not-started':'running'; // Before the first date Remaining is longer than Duration, which is right (it counts // from today, as Drew asked) and would read as a mistake if nothing said why, so the@@ -10132,7 +10212,7 @@ function wireTagBox(box,initial,kind,onChange){ function openItemModal(r,carriedName,opts){ opts=opts||{}; closeEditor();- edSubject=r;+ edSubject=r; scheduleFocus(); const ms=r.type==='milestone'; const p=provOf(r)||{label:'',url:''}; // FIELD FOR FIELD THE NEW-ITEM PANEL, in the same order, in the same rows, with the@@ -11265,6 +11345,7 @@ function renderTable(){ }).join(''); markFocusedRow(); paintCritTable();+ paintPeerFocus(); } // ── Drag a lane ──@@ -11637,7 +11718,7 @@ function markFocusedRow(){ const tr=[...tableBody.querySelectorAll('tr[data-key]')].find(t=>t.dataset.key===focusedKey); if(tr)tr.classList.add('row-focus'); }-function setFocusedRow(k){ focusedKey=k||null; markFocusedRow(); }+function setFocusedRow(k){ focusedKey=k||null; markFocusedRow(); scheduleFocus(); } function visibleRowEls(){ return [...tableBody.querySelectorAll('tr[data-key]')]; } function focusedRowEl(){ return focusedKey?visibleRowEls().find(t=>t.dataset.key===focusedKey)||null:null; } // Keep the focused row in view, and move the pane by the least it can get away@@ -13034,8 +13115,9 @@ function syncPlanCtl(){ } function wirePlanCtl(){ const a=$('btnAuto'), c=$('btnCrit');- if(a)a.addEventListener('click',()=>{ if(!a.disabled)runAutoScheduleBusy(); });- if(c)c.addEventListener('click',()=>{ if(!c.disabled)toggleCritical(); });+ const team=()=>document.documentElement.dataset.role==='member';+ if(a)a.addEventListener('click',()=>{ if(!a.disabled&&!team())runAutoScheduleBusy(); });+ if(c)c.addEventListener('click',()=>{ if(!c.disabled&&!team())toggleCritical(); }); syncPlanCtl(); } async function startApp(){
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.73.0",+ "version": "0.74.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.73.0",+ "version": "0.74.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+26−2@@ -503,6 +503,14 @@ function liveSend(c, obj) { try { c.res.write('data: ' + JSON.stringify(obj) + ' function broadcastLive(obj, exceptCid) { for (const c of liveClients) if (!exceptCid || c.cid !== exceptCid) liveSend(c, obj); }+// What each tab is working on (0.74.0): the rows it has selected or open, sent by the tab+// (POST /live/focus) and broadcast with the presence, so every other tab can mark those+// rows with the person's initials. Kept per connection, gone when the tab goes.+function focusList() {+ const out = [];+ for (const c of liveClients) if (c.focus && c.focus.length) out.push({ cid: c.cid, id: c.who.id, name: c.who.name, initials: c.who.initials, avatar: c.who.avatar || '', uids: c.focus });+ return out;+} function presenceList() { const m = new Map(); for (const c of liveClients) {@@ -512,10 +520,11 @@ function presenceList() { } return [...m.values()]; }-function broadcastPresence() { broadcastLive({ type: 'presence', people: presenceList(), tabs: liveClients.size }); }+function broadcastPresence() { broadcastLive({ type: 'presence', people: presenceList(), tabs: liveClients.size, focus: focusList() }); } function whoOf(auth) { const u = auth && auth.user;- if (u) return { id: 'u:' + (u.id || u.username || u.displayName), name: u.displayName || u.username || 'Someone', initials: u.initials || '?' };+ if (u) return { id: 'u:' + (u.id || u.username || u.displayName), name: u.displayName || u.username || 'Someone', initials: u.initials || '?',+ avatar: /^https:\/\//.test(u.avatarUrl || '') ? u.avatarUrl : '' }; if (auth && auth.agent) return { id: 'agent', name: AGENT_ACTOR, initials: 'AI' }; return { id: '', name: 'Guest', initials: 'G' }; }@@ -2858,6 +2867,21 @@ 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/focus') {+ if (req.method !== 'POST') { jsonResp(res, 405, { ok: false, error: 'POST' }); return; }+ const cid = String((query.match(/(?:^|&)cid=([^&]*)/) || [])[1] || '').slice(0, 40);+ let body = {};+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch (e) { jsonResp(res, 400, { ok: false, error: 'body must be JSON' }); return; }+ const uids = [...new Set((Array.isArray(body && body.uids) ? body.uids : []).filter(u => typeof u === 'string' && /^[A-Za-z0-9_-]{1,24}$/.test(u)))].slice(0, 25);+ let known = false, changed = false;+ for (const c of liveClients) if (cid && c.cid === cid) {+ known = true; c.seen = Date.now();+ if (JSON.stringify(c.focus || []) !== JSON.stringify(uids)) { c.focus = uids; changed = true; }+ }+ if (changed) broadcastPresence();+ jsonResp(res, 200, { ok: true, known, uids });+ 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);
test/server_test.mjs+15@@ -672,6 +672,21 @@ async function main() { 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');+ // What each tab is on (0.74.0), broadcast with the presence.+ const watch = await fetch(P.base + '/live?cid=watch'); const wr = watch.body.getReader(); let wtxt = '';+ const pump = (async () => { for (;;) { const x = await wr.read(); if (x.done) return; wtxt += Buffer.from(x.value).toString(); } })().catch(() => {});+ await wait(200);+ let fr = await post(P, '/live/focus?cid=tabA', { uids: ['00001', '00002', 'bad uid!', 7, '00001'] });+ ok(fr.status === 200 && fr.j.known === true && JSON.stringify(fr.j.uids) === '["00001","00002"]', 'a tab says which rows it is on; junk and repeats are dropped', fr.j);+ await wait(300);+ const lastPresence = () => { const m = wtxt.split('\n').filter(l => l.startsWith('data: ')).map(l => { try { return JSON.parse(l.slice(6)); } catch (e) { return null; } }).filter(x => x && x.type === 'presence').pop(); return m; };+ let lp = lastPresence();+ ok(lp && Array.isArray(lp.focus) && lp.focus.some(f => f.cid === 'tabA' && JSON.stringify(f.uids) === '["00001","00002"]'), 'every tab is told, with the presence', lp && lp.focus);+ await post(P, '/live/focus?cid=tabA', { uids: [] }); await wait(300);+ lp = lastPresence();+ ok(lp && !lp.focus.some(f => f.cid === 'tabA'), 'letting go clears it', lp && lp.focus);+ ok((await post(P, '/live/focus?cid=tabA', { uids: Array.from({ length: 40 }, (_, i) => String(10000 + i)) })).j.uids.length === 25, 'at most 25 rows per tab');+ ok((await fetch(P.base + '/live/focus?cid=tabA')).status === 405, 'focus is POST only'); stop(P); } }
test/ui_test.mjs+43−25@@ -122,14 +122,15 @@ function seedPlan({ appName = 'launch-demo', title = 'Launch Demo: Jan 11, 2027' // app's formatter drifted, a test that asked the app what the answer is would drift // with it. Inclusive day counts, the same rule a bar follows. const dayNum = ds => { const [y, m, d] = ds.split('-').map(Number); return Math.round(Date.UTC(y, m - 1, d) / 86400000); };-function spanText(n) {- if (n <= 0) return '0 days';- if (n === 1) return '1 day';- if (n < 14) return n + ' days';- if (n < 183) { const w = Math.floor(n / 7), d = n % 7; return d ? `${w}w ${d}d` : `${w}w`; }- if (n < 730) return (Math.round(n / 30.4375 * 10) / 10) + ' months';- return (Math.round(n / 365.25 * 10) / 10) + ' years';-}+// 0.74.0: "16.6 wks / 79 workdays". Its own US federal holiday calendar (observed: Saturday+// to Friday, Sunday to Monday), written here from the rules, not taken from the page.+const isoOf = x => new Date(x * 86400000).toISOString().slice(0, 10);+function obsDay(y, m, d) { const t = new Date(Date.UTC(y, m - 1, d)), w = t.getUTCDay(); if (w === 6) t.setUTCDate(d - 1); if (w === 0) t.setUTCDate(d + 1); return t.toISOString().slice(0, 10); }+function nthDowO(y, m, dow, n) { const t = new Date(Date.UTC(y, m - 1, 1)); while (t.getUTCDay() !== dow) t.setUTCDate(t.getUTCDate() + 1); t.setUTCDate(t.getUTCDate() + 7 * (n - 1)); return t.toISOString().slice(0, 10); }+function lastDowO(y, m, dow) { const t = new Date(Date.UTC(y, m, 0)); while (t.getUTCDay() !== dow) t.setUTCDate(t.getUTCDate() - 1); return t.toISOString().slice(0, 10); }+function holidaysO(y) { return new Set([obsDay(y, 1, 1), nthDowO(y, 1, 1, 3), nthDowO(y, 2, 1, 3), lastDowO(y, 5, 1), obsDay(y, 6, 19), obsDay(y, 7, 4), nthDowO(y, 9, 1, 1), nthDowO(y, 10, 1, 2), obsDay(y, 11, 11), nthDowO(y, 11, 4, 4), obsDay(y, 12, 25), obsDay(y + 1, 1, 1)].filter(d => +d.slice(0, 4) === y)); }+function workdaysO(a, b) { let n = 0; for (let x = dayNum(a); x <= dayNum(b); x++) { const w = new Date(x * 86400000).getUTCDay(), ds = isoOf(x); if (w === 0 || w === 6 || holidaysO(+ds.slice(0, 4)).has(ds)) continue; n++; } return n; }+function spanText(a, b) { const n = dayNum(b) - dayNum(a) + 1, wd = workdaysO(a, b); return (Math.round(n / 7 * 10) / 10).toFixed(1) + ' wks / ' + wd + ' workday' + (wd === 1 ? '' : 's'); } const MN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const fmtDate = ds => { const [y, m, d] = ds.split('-').map(Number); return `${String(d).padStart(2, '0')}-${MN[m - 1]}-${y}`; }; // The app keeps its state inside a closure (one async IIFE), so the page has no@@ -172,8 +173,8 @@ async function expectReadouts(page, today, what) { const plan = await savedPlan(SRV); const { first, last, n } = spanOf(plan); const r = await readouts(page);- const wantDur = spanText(dayNum(last) - dayNum(first) + 1);- const wantLeft = dayNum(today) > dayNum(last) ? 'ended' : spanText(dayNum(last) - dayNum(today) + 1);+ const wantDur = spanText(first, last);+ const wantLeft = dayNum(today) > dayNum(last) ? 'ended' : spanText(today, last); check(`${what}: Project Duration = ${wantDur}`, r.dur === wantDur, `got ${r.dur} (first ${first}, last ${last})`); check(`${what}: Time Remaining = ${wantLeft}`, r.left === wantLeft, `got ${r.left}`); check(`${what}: start button = ${fmtDate(first)}`, r.start === fmtDate(first), `got ${r.start}`);@@ -394,11 +395,11 @@ async function main() { // ── 4 + 5. Exact start date and the two readouts, at every grain ── section = 'dates'; const base = await expectReadouts(page, TODAY, 'on load');- check('on load: duration is 16w 4d (116 days, 21-Sep-2026..14-Jan-2027)', base.dur === '16w 4d', base.dur);- check('on load: remaining is 15w 3d (108 days from 29-Sep-2026)', base.left === '15w 3d', base.left);+ check('on load: duration is 16.6 wks with its workdays (116 days, 21-Sep-2026..14-Jan-2027)', base.dur === spanText('2026-09-21', '2027-01-14') && /^16\.6 wks \/ \d+ workdays$/.test(base.dur), base.dur);+ check('on load: remaining is 15.4 wks with its workdays (108 days from 29-Sep-2026)', base.left === spanText('2026-09-29', '2027-01-14') && /^15\.4 wks/.test(base.left), base.left); check('duration tooltip states both exact dates', base.durTip.includes('21-Sep-2026') && base.durTip.includes('14-Jan-2027') && base.durTip.includes('116 calendar days'), base.durTip); check('remaining tooltip states today and the end date', base.leftTip.includes('29-Sep-2026') && base.leftTip.includes('14-Jan-2027'), base.leftTip);- check('the cancelled row to 30-Jun-2027 is left out, and the tooltip says so', base.dur === '16w 4d' && /1 cancelled row left out/.test(base.durTip), base.durTip);+ check('the cancelled row to 30-Jun-2027 is left out, and the tooltip says so', base.dur === spanText('2026-09-21', '2027-01-14') && /1 cancelled row left out/.test(base.durTip), base.durTip); check('while running there is no "not started" note', await page.$eval('#spanLeftN', e => e.textContent) === ''); const axes = []; for (const g of ['day', 'week', 'month', 'quarter', 'year']) {@@ -423,7 +424,9 @@ async function main() { await page.click('#datePop .dp-cell[data-month="9"]'); await settle(page); r = await expectReadouts(page, TODAY, 'after moving the start to October'); check('start moved to 21-Oct-2026 (exact day kept)', r.start === '21-Oct-2026', r.start);- check('a whole-plan shift keeps the duration', r.dur === base.dur, r.dur);+ // The length in weeks stays; the workdays follow the calendar (a shift moves weekends+ // and holidays in and out), and expectReadouts has already checked them.+ check('a whole-plan shift keeps the duration in weeks', r.dur.split(' / ')[0] === base.dur.split(' / ')[0], `${r.dur} vs ${base.dur}`); check('a whole-plan shift moves the remaining time', r.left !== base.left, r.left); await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page); r = await expectReadouts(page, TODAY, 'undo of the start shift');@@ -439,7 +442,7 @@ async function main() { await page.click('#startBtn'); await settle(page, 100); await page.click('#datePop .dp-link[data-act="today"]'); await settle(page); r = await expectReadouts(page, TODAY, 'after Today');- check('Today (at week grain) moves the start to the 1st of this month, 01-Sep-2026', r.start === '01-Sep-2026' && r.dur === base.dur, r.start);+ check('Today (at week grain) moves the start to the 1st of this month, 01-Sep-2026', r.start === '01-Sep-2026' && r.dur.split(' / ')[0] === base.dur.split(' / ')[0], `${r.start} ${r.dur}`); await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page); r = await expectReadouts(page, TODAY, 'undo of Today'); check('undo of Today goes back to the original start', r.start === '21-Sep-2026' && r.dur === base.dur && r.left === base.left, r.start);@@ -492,7 +495,7 @@ async function main() { await page.evaluate(() => { const i = document.querySelector('.cell-edit input'); i.value = '2027-01-21'; i.dispatchEvent(new Event('input', { bubbles: true })); }); await page.keyboard.press('Enter'); await settle(page, 300); r = await expectReadouts(page, TODAY, 'after a table deadline edit');- check('table edit moved the end to 21-Jan-2027 (17w 4d)', r.last === '2027-01-21' && r.dur === '17w 4d', `${r.last} ${r.dur}`);+ check('table edit moved the end to 21-Jan-2027 (17.6 wks)', r.last === '2027-01-21' && r.dur === spanText('2026-09-21', '2027-01-21'), `${r.last} ${r.dur}`); await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page); r = await expectReadouts(page, TODAY, 'undo of the table edit'); @@ -558,9 +561,9 @@ async function main() { section = 'dates: clock'; page = await openPage(browser, S.url, { today: '2026-09-01' }); r = await readouts(page);- check('before the plan starts: remaining counts from today (136 days = 19w 3d)', r.left === '19w 3d', r.left);+ check('before the plan starts: remaining counts from today (136 days = 19.4 wks)', r.left === spanText('2026-09-01', LAST), r.left); check('before the plan starts: state is not-started and the tooltip says so', r.leftState === 'not-started' && /not started/.test(r.leftTip), r.leftTip);- check('before the plan starts: duration unchanged', r.dur === '16w 4d', r.dur);+ check('before the plan starts: duration unchanged', r.dur === spanText('2026-09-21', LAST), r.dur); check('before the plan starts: the readout itself says "not started"', await page.$eval('#spanLeftN', e => e.textContent) === 'not started' && await page.$eval('#spanLeftN', e => window.__uiVis(e))); await page.close(); page = await openPage(browser, S.url, { today: '2027-02-01' });@@ -570,7 +573,7 @@ async function main() { await page.close(); page = await openPage(browser, S.url, { today: LAST }); r = await readouts(page);- check('on the last day: 1 day remaining', r.left === '1 day', r.left);+ check('on the last day: 0.1 wks / 1 workday remaining', r.left === spanText(LAST, LAST) && r.left === '0.1 wks / 1 workday', r.left); // A tab left open overnight: the date rolls over with no edit at all, and coming // back to the tab repaints (the minute tick does the same for a tab left in view). await page.evaluate(() => { window.__advanceClockDays(1); document.dispatchEvent(new Event('visibilitychange')); });@@ -859,7 +862,7 @@ async function main() { lg = await legendGeometry(page); check('all 20 phase chips visible and clickable', lg.chips.length === 20 && lg.chips.every(c => c.clickable && c.right <= vp.w)); r = await readouts(page);- check('duration readouts present', r.dur === '16w 4d' && r.left === '15w 3d', JSON.stringify(r));+ check('duration readouts present', r.dur === spanText('2026-09-21', '2027-01-14') && r.left === spanText('2026-09-29', '2027-01-14'), JSON.stringify(r)); const readoutVisible = await page.evaluate(() => { const e = document.getElementById('spanLeftV'); const x = e.getBoundingClientRect(); const s = document.querySelector('#hdr .subject').getBoundingClientRect(); return window.__uiVis(e) && x.width > 0 && x.right <= innerWidth && x.right <= s.right + 0.5; }); check('Time Remaining is on screen and not clipped', readoutVisible);@@ -3204,6 +3207,16 @@ async function collabFlow(browser) { // Presence: both tabs see two people here. 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)}`);+ // 0.74.0: the row one tab has selected is marked, with initials, in the other tab.+ const peerOn = (pg, id) => pg.evaluate(id => { const r = [...document.querySelectorAll('.label-row:not(.group)')].find(e => e.dataset.id === id); return r ? { on: r.classList.contains('peer-focus'), chip: (r.querySelector('.peer-chip') || {}).textContent || '' } : null; }, id);+ await A.bringToFront(); await clickBar(A, 'b2'); await settle(A, 300);+ check('a row selected in one tab is marked with initials in the other', await until(async () => { const p = await peerOn(B, 'b2'); return p && p.on && p.chip === 'G'; }), JSON.stringify(await peerOn(B, 'b2')));+ check('and not in the tab that selected it', !(await peerOn(A, 'b2')).on);+ await B.bringToFront(); await view(B, 'table');+ check('the table marks the same row', await until(async () => B.evaluate(() => { const t = document.querySelector('#tableView tr[data-key="t:b2"]'); return !!t && t.classList.contains('peer-focus') && !!t.querySelector('.peer-chip'); })));+ await view(B, 'gantt');+ await A.bringToFront(); await A.keyboard.press('Escape'); await A.keyboard.press('Escape'); await settle(A, 400);+ check('letting go of the row clears the mark in the other tab', await until(async () => { const p = await peerOn(B, 'b2'); return p && !p.on; }), JSON.stringify(await peerOn(B, 'b2'))); 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.@@ -3341,13 +3354,13 @@ async function rowLinksFlow(browser) { try { await browser.defaultBrowserContext().overridePermissions(X.url.replace(/\/$/, ''), ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); } catch (e) {} const page = await openPage(browser, X.url); const last = () => page.evaluate(() => window.__lastRowLink || '');- const want = (u, n) => `#${u} ${n}: ${base}#row=${u}`;+ const want = (u, n) => `${base}#row=${u}-${n.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; // Chart: the link button on the row. const vis = await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Link task')); const b = e && e.querySelector('.row-link'); if (!b) return null; const r = b.getBoundingClientRect(); return { w: r.width, op: +getComputedStyle(b).opacity }; }); check('every chart row shows a copy-link button without hovering', !!vis && vis.w > 0 && vis.op > 0.3, JSON.stringify(vis)); await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Link task')); e.querySelector('.row-link').dataset.t = '1'; }); await page.click('.row-link[data-t="1"]'); await settle(page, 300);- check('the chart button copies "#ID Name: link"', (await last()) === want(u1, 'Link task'), await last());+ check('the chart button copies the bare link, with the name readable in it', (await last()) === want(u1, 'Link task'), await last()); check('and does not open the editor or rename the row', !(await page.$('.editor.show')) && !(await page.$('.label-row input'))); // Table: the ID column. await view(page, 'table');@@ -3362,13 +3375,17 @@ async function rowLinksFlow(browser) { check('and it copies the same link, leaving the editor open', (await last()) === want(u1, 'Link task') && !!(await page.$('.editor.show'))); await page.keyboard.press('Escape'); await settle(page, 200); // Opening a link: a collapsed section opens, the row is selected and the fragment is consumed.- await page.goto(base + '#row=' + u2); await ready(page); await settle(page, 600);+ await page.goto(want(u2, 'Far task')); await ready(page); await settle(page, 600); const opened = await page.evaluate(() => ({ sel: !!document.querySelector('.bar.selected'), hash: location.hash, flash: document.getElementById('saveInd').textContent })); check('opening a link selects that row and says which', opened.sel && opened.flash.includes('#' + u2 + ' Far task'), JSON.stringify(opened)); check('and the link is consumed, so a reload does not jump again', !/row=/.test(opened.hash), opened.hash);+ // From the table, behind a filter that hides the row: the link goes to the chart, clears+ // the filter and selects the row. await view(page, 'table');- await page.evaluate(u => { location.hash = '#row=' + u; }, u1); await settle(page, 600);- check('a link pasted into an open table tab focuses that row', await page.evaluate(() => { const t = document.querySelector('#tableView tr.row-focus'); return t ? t.dataset.key : ''; }) === 't:l1');+ await page.select('#tblOwner', '__unassigned__'); await settle(page, 300);+ await page.evaluate(u => { location.hash = '#row=' + u; }, u1); await settle(page, 700);+ const lk = await page.evaluate(() => ({ view: document.documentElement.dataset.view, sel: [...document.querySelectorAll('.bar.selected')].map(b => b.dataset.id), owner: (document.getElementById('ganttOwner') || {}).value, flash: document.getElementById('saveInd').textContent }));+ check('a link opened from the table goes to the chart, clears the filter hiding the row, and selects it', lk.view === 'gantt' && lk.sel.join() === 'l1' && lk.owner === '' && /filters were hiding it/.test(lk.flash), JSON.stringify(lk)); await page.evaluate(() => { location.hash = '#row=99999'; }); await settle(page, 400); check('a link to a row that is gone says so', /no row #99999/.test(await page.$eval('#saveInd', e => e.textContent))); // Tags fold: ten tags start folded; a chosen tag stays out.@@ -3389,6 +3406,7 @@ async function rowLinksFlow(browser) { await page.click('#sharePreview'); await settle(page, 300); const pv = await page.evaluate(() => ({ role: document.documentElement.dataset.role, an: !!document.querySelector('.vt-btn[data-view="analytics"]').offsetHeight, banner: !!document.getElementById('memberPreview'), view: document.documentElement.dataset.view })); check('"See what the team sees" hides Analytics, shows a banner and goes to the chart', pv.role === 'member' && !pv.an && pv.banner && pv.view === 'gantt', JSON.stringify(pv));+ check('and hides Auto-schedule and the critical path (they re-time everybody\'s rows)', await page.evaluate(() => !document.getElementById('btnAuto').offsetWidth && !document.getElementById('btnCrit').offsetWidth)); await view(page, 'setup'); check('in the team view, Setup keeps sections and tags but not Sharing or Chat', await page.evaluate(() => !document.querySelector('#suShare').offsetHeight && !document.querySelector('#suChat').offsetHeight && !!document.querySelector('#suSections').offsetHeight)); await page.click('#previewBack'); await settle(page, 300);