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.78.1
8 files changed
+480−88
README.md+5@@ -387,6 +387,11 @@ them in the order they arrive and tells every open tab, so: to the same row both keep theirs; removing one takes out that one only. - **Moves merge too.** A move is "this row goes after that one", replayed on whatever order the plan has by then, so two people reordering different rows both keep their order.+- **Somebody else's change costs your tab only what it changed (0.78.1).** A row somebody+ drags moves only that row on everybody else's screen, an edit redraws only that row of the+ table, and the server keeps the plan parsed in memory instead of reading it from disk for+ every change. On the team plan a tab that is only watching went from about 200 ms per moved+ row to under 20. - **A tab that falls out of step puts itself right (0.77.1).** If a tab ever disagrees with the server about which section a row is in, it notices on the next change anybody makes, fetches the whole plan and corrects itself. Before 0.77.1 it could not, and on 2026-11-03
docs/BUILD-NOTES.md+65@@ -3,6 +3,71 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. The release checklist is [RELEASING.md](RELEASING.md). +## Build notes, 2026-11-03 (0.78.1, touch only what changed)++Measured before anything was changed, and after each change: a copy of the team plan as it+stood at 18:03 UTC (506 rows, 51 sections, 184 arrows on screen), the 0.77.1 build against+this one, headless Chrome on a laptop CPU, each tab scrolled to the middle of the plan and+changes arriving 2.6 times a second, the rate the order fight ran at. Time is main-thread time+per change on a tab that only watches (`.gantt-gate/perf/perf-lab.mjs`, 30 s windows); the+server's is CPU per batch with three tabs listening (`.gantt-gate/perf/srvprof/srv-prof.mjs`).++| per change | 0.77.1 Gantt | 0.78.1 Gantt | 0.77.1 Table | 0.78.1 Table |+|---|---|---|---|---|+| a row moved by somebody else | 225 ms (58% of a core, every one a long task) | 16 ms (4.3%, none) | 137 ms | 17 ms |+| a status edit | 37 ms | 17 ms | 91 ms | 35 ms |+| a date edit | 46 ms | 25 ms | 309 ms (80% of a core) | 40 ms |++The server: 27.6 ms of CPU per batch on 0.77.1, 12.3 ms now; the reply, 19 ms at the median+and 285 ms at worst before, 12 ms and 18 ms now.++**What changed.**+- A row order change moves only the rows whose place changed: the rows on a longest run already+ in order stay, each other one goes in front of its new neighbour (`placeInOrder`). It used to+ re-append all ~1,000 row nodes and then repaint every label, bar, the legend, the filters and+ the timeline. Rows only trade places inside their sections in an order change, so no header+ moves: the pinned header is not measured again, the reader's place is read off the rows with+ the headers left pinned (two restyles of the whole chart less), the arrow layer keeps its+ height, its classes and every row's measurements outside the span that moved.+- Arrows are reused, not rebuilt. A route is worked out again only when an end moved or a bar+ that moved lies in its band, and a plain route that no moved bar now crosses is kept, which+ is exactly when the router would draw it again.+- The table patches sorted tables, new orders and rows that start or stop passing a filter:+ markup only for the rows that changed or newly show, and only those and the rows out of+ place are touched. The table sorts by deadline unless somebody picked plan order, so the old+ patch, which needed plan order, almost never ran. The Deps column reads a who-blocks-whom+ index built once per pass instead of scanning the plan for every row.+- After a change from somebody else, the all-rows comparison for unsent edits runs only when+ this tab did something since it last found nothing: every save asked for, every undo step and+ every gesture counts. What is unsent is still read off the page and the shadow.+- The server keeps the data file and the sidecar parsed, keyed by what stat() says (inode,+ size, modified and changed times), with a contents check for a file read within the clock's+ granularity of a write, as git does for its index; its own writes put their text in place as+ they land, imports and compact forget it, and every caller gets a copy of its own. A batch is+ still answered only after its write has landed.++**Left over.** The Table still spends about 25 ms per changed row in the browser's own layout+and paint of a 500-row table. Routes that detour (16 of the 184 on this plan) are searched+again whenever a moved bar lies in their band, about 3.5 ms a change. Every field edit still+runs a few whole-plan passes for the chips and the plan span, about 4 ms.++**Tests.** server 199/0. collab `--only docs,sections,fuzz` 36/0; its first run found 2+failures in `sections`: a page that missed a change and caught up holds an order the server+does not, though it made no edit, and the edit count had skipped the comparison that sends it.+A catch-up now always compares, and so does an announcement whose rebase finds moves of the+page's own. UI on the flows this touches (live, collapse all, scroll sync, table filters,+owner and overdue, sections, collab, leak, the editors, new item fields, tags, type ahead,+pick by mouse) 598/0. Full UI 881/0 with the standing 7 controls untested. Every lab window+also checked that both+panes show the tab's own order and that the order is the server's.++**Merged onto 0.78.0.** The branch was built on 0.77.1. Where both releases changed the+arrows, 0.78.0's ends stay (`depEnd`, a task or a milestone in either milestone style) and+take a task's numbers from the row records this release keeps, and a milestone's record+carries its id, as 0.78.0's obstacle did, so its own arrows still meet its points. Gates on+main after the merge, under the suite lock: server 222, collab 100, ui 916 (the+standing 7 controls untested), nothing failed.+ ## Build notes, 2026-11-03 (0.78.0, dependencies to and from milestones, issue #7) Stephen's request (adom/adom-gantt#7): "A milestone should be able to wait on tasks, a task
gantt.html+343−80@@ -3227,7 +3227,17 @@ const redoStack = []; // Declared here, beside pushUndo, rather than beside the functions that set them, so // they exist before anything can push. See withOneUndo and phaseKey. let undoGroup=null, phaseKeySeq=0;+// Edits this page made, counted (0.78.1), and the count at which computeOps last found+// nothing to send. Declared here for the same reason: pushUndo bumps it.+let localEditN=0, cleanAtN=-1;+function markLocalEdit(){ localEditN++; }+// True while a live change that only reorders rows is applied (0.78.1): rows then move only+// inside their own sections, so no section header moves, no section changes height and no+// arrow comes or goes, and the work those would need is left out (reflowRows, drawDeps,+// keepPlaceRows).+let orderOnlyMerge=false; function pushUndo(sn){+ markLocalEdit(); if(undoGroup){ undoGroup.push(sn); return; } // collected by withOneUndo sealUndo(); // Where the plan's outside changes stood when this was done, so its undo can tell a field@@ -3345,12 +3355,63 @@ function reflowRows(){ // re-appending ~1000 nodes and re-reading every header's position was ~300 ms a merge, // twice, for nothing (0.75.1). const same=paneOrderMatches();- items.forEach((r,i)=>{r._itemIdx=i;if(!same){labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);}if(r.id&&barEls[r.id])barEls[r.id].idx=i;});- if(!same)items.filter(r=>r.type==='milestone'&&r._msLine).forEach(r=>bars.appendChild(r._msLine));- recolorByPhase();+ items.forEach((r,i)=>{r._itemIdx=i;if(r.id&&barEls[r.id])barEls[r.id].idx=i;});+ if(!same){+ // Only the rows whose place changed are moved (0.78.1). Somebody else's drag used to+ // re-append every row in both panes, about 1,000 nodes, so every open page restyled+ // and laid out the whole chart again: ~200 ms a moved row. The rows on a longest run+ // already in order stay put; each other one goes in front of its new neighbour. A pane+ // that is not in the shape this expects is rebuilt the old way, which always works.+ const rules=items.filter(r=>r.type==='milestone'&&r._msLine).map(r=>r._msLine);+ const ok=placeInOrder(labelsEl,items.map(r=>r._lDiv),el=>el.classList.contains('label-row'))>=0+ &&placeInOrder(bars,items.map(r=>r._bDiv),el=>el.classList.contains('bar-row'))>=0+ &&placeInOrder(bars,rules,el=>el.classList.contains('ms-line'))>=0+ &&paneOrderMatches();+ if(!ok){+ items.forEach(r=>{labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);});+ rules.forEach(el=>bars.appendChild(el));+ }+ }+ // An order-only merge changes no row's section, so no colour (0.78.1).+ if(!orderOnlyMerge)recolorByPhase(); if(same)return; keepTailLast();- updatePinnedHeader();+ // No header moved in an order-only merge, so which one is pinned cannot have changed; asking+ // forced a layout of the whole chart on every page for each row somebody moved (0.78.1).+ if(!orderOnlyMerge)updatePinnedHeader();+}+// The fewest moves that put a pane's rows in the given order (0.78.1): the rows on a longest+// run already in order stay where they are, and every other one is inserted in front of its+// new neighbour, so one row moved by somebody else is one node moved here. The rows must sit+// together in the pane (the tail room, the rules or the arrow layer before or after them, not+// between); -1 when they do not, or when the pane holds a row the list does not, and the+// caller rebuilds the pane instead. Returns how many nodes it moved.+function placeInOrder(parent,want,isRow){+ const pos=new Map(); want.forEach((el,i)=>{ if(el)pos.set(el,i); });+ const seq=[]; let started=false, ended=false, endRef=null;+ for(const el of parent.children){+ if(isRow(el)){+ if(ended)return -1;+ const p=pos.get(el); if(p===undefined)return -1;+ seq.push(p); started=true;+ } else if(started&&!ended){ ended=true; endRef=el; }+ }+ // Longest increasing run of the wanted positions, in DOM order (patience sort).+ const tails=[], tailAt=[], prev=new Array(seq.length);+ for(let i=0;i<seq.length;i++){+ const v=seq[i]; let lo=0, hi=tails.length;+ while(lo<hi){ const mid=(lo+hi)>>1; if(tails[mid]<v)lo=mid+1; else hi=mid; }+ tails[lo]=v; tailAt[lo]=i; prev[i]=lo>0?tailAt[lo-1]:-1;+ }+ const keep=new Set();+ for(let i=tails.length?tailAt[tails.length-1]:-1;i>=0;i=prev[i])keep.add(seq[i]);+ let ref=endRef, moved=0;+ for(let i=want.length-1;i>=0;i--){+ const el=want[i]; if(!el)continue;+ if(!keep.has(i)&&(el.parentNode!==parent||el.nextSibling!==ref)){ parent.insertBefore(el,ref); moved++; }+ ref=el;+ }+ return moved; } function paneOrderMatches(){ // Rows in items order, and nothing out of place around them: no row after the names'@@ -3410,6 +3471,12 @@ function applyOrder(order){ // The same order the panes already show (every live merge that only changed fields): // nothing to re-append, no layout to force (0.75.1). if(before.length===items.length&&before.every((r,i)=>r===items[i]))return true;+ if(orderOnlyMerge){+ let lo=0, hi=items.length-1;+ while(lo<items.length&&before[lo]===items[lo])lo++;+ while(hi>=0&&before[hi]===items[hi])hi--;+ depSpan=depSpan?[Math.min(depSpan[0],lo),Math.max(depSpan[1],hi)]:[lo,hi];+ } reflowRows(); drawDeps(); return true;@@ -5128,7 +5195,7 @@ function refreshViewChips(){ // How long a burst of edits gathers before it goes out (0.77.0, was 800 ms): a batch is a // few fields now, not the whole plan, so the team sees an edit sooner. const SAVE_DEBOUNCE_MS=400;-function scheduleSave(via){refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncPlanSpan();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},SAVE_DEBOUNCE_MS);}+function scheduleSave(via){markLocalEdit();refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncPlanSpan();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},SAVE_DEBOUNCE_MS);} // Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog // line for the save it belongs to. A tab closed inside that window would leave the edit in // neither, so anything still pending goes out synchronously when the page is being put@@ -5193,8 +5260,8 @@ function saveToFile(){ if(inflight||syncBusy){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},250); return; } clearTimeout(saveRetry); saveRetry=null; sealUndo();- const ops=computeOps();- if(!ops.length){ if(unsavedCount||logQueue.length)onSaveOk('saved'); return; }+ const at=localEditN, ops=computeOps();+ if(!ops.length){ cleanAtN=at; if(unsavedCount||logQueue.length)onSaveOk('saved'); return; } const b={batch:CLIENT_ID+'.'+(++batchN),ops,rev:null,vals:new Map(),fields:new Map(),adds:new Set(),dels:new Set(),order:null,struct:curStruct(),sent:{}}; ops.forEach(op=>{ if(op.op==='row'||op.op==='add'){@@ -5275,6 +5342,10 @@ async function pullOnce(force){ // since takes the server's value, which is the batch's, or a later one's. if(inflight&&inflight.rev!==null&&inflight.rev<=baseRev){ const b=inflight; inflight=null; applyOps([],{settle:b}); } liveMerges++;+ // A catch-up replaces this tab's copy of the server's plan wholesale, so whatever the page+ // holds that the server does not shows only now, whatever this tab did or did not do: the+ // comparison runs (0.78.1).+ cleanAtN=-1; afterSync(); } // The server's whole state as the ops that would turn the shadow into it, so a catch-up is@@ -5323,8 +5394,19 @@ function stateOps(st){ // and a tab that was blocked for want of a server is unblocked by having reached it. function afterSync(){ if(saveBlocked&&/could not be reached|refused the save \((5\d\d|0)\)/.test(blockedWhy))clearConflict('back in sync');- if(!saveTimer&&!inflight&&computeOps().length){ saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},150); }-}+ // Only when this page did something since computeOps last found nothing (0.78.1). What+ // the page has unsent is still read off the page and the shadow, never kept in a list;+ // the count only says whether it is worth looking. Every save asked for, every undo step+ // and every gesture on the page counts. A page nobody is touching compared all ~500 rows+ // after every change anybody made, about 5 ms each, three times a second in a meeting.+ if(saveTimer||inflight||localEditN===cleanAtN)return;+ const at=localEditN;+ if(computeOps().length)saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},150);+ else cleanAtN=at;+}+// A gesture counts before anything it does: a key held for a move saves 600 ms later, and an+// announcement landing in between must still look.+['pointerdown','keydown','input','change','drop','paste','cut'].forEach(t=>document.addEventListener(t,markLocalEdit,true)); // An announcement: a batch the server applied, with the revision it made. The next one in // line is applied as it stands; this tab's own batch coming back is its confirmation; a // gap, or a save that carried no ops, means a catch-up.@@ -5349,9 +5431,13 @@ function applyOps(ops,ctx){ ctx=ctx||{}; sealUndo(); const T={rows:new Set(),fields:new Set(),fresh:new Set(),structure:false,order:false,phases:false,reg:false,dates:false,cal:false};- const mark=placeMark();- const range0=prefs.view==='gantt'?JSON.stringify(scanRange()):'';- liveMerging=true;+ // A batch that only reorders rows cannot move the plan's span, so the two whole-plan+ // scans that compare it before and after are skipped (0.78.1), and the reader's place is+ // read off the rows alone (placeMarkRows).+ const orderOnly=!ctx.settle&&Array.isArray(ops)&&ops.length>0&&ops.every(o=>o&&o.op==='order');+ const mark=orderOnly?placeMarkRows():placeMark();+ const range0=prefs.view==='gantt'&&!orderOnly?JSON.stringify(scanRange()):null;+ liveMerging=true; orderOnlyMerge=orderOnly; try{ withTable(()=>withDeps(()=>{ inRowBatch(()=>{@@ -5370,10 +5456,15 @@ function applyOps(ops,ctx){ repaintAfter(T,range0); })); if(T.structure)keepPlace(mark);- if(edSubject&&(T.rows.has(edSubject)||T.structure))syncEditorDates(edSubject);- }finally{ liveMerging=false; }+ else if(T.order)orderOnly?keepPlaceRows(mark):keepPlace(mark);+ if(edSubject&&(T.rows.has(edSubject)||T.structure||T.order))syncEditorDates(edSubject);+ }finally{ liveMerging=false; orderOnlyMerge=false; } if(T.structure||T.phases||T.reg||T.dates||T.cal)lastSnap=snapSemantic();- else if(T.rows.size)patchSnap(T.rows);+ else{+ if(T.rows.size)patchSnap(T.rows);+ // The changelog's baseline takes the new order and nothing else (0.78.1).+ if(T.order&&lastSnap)lastSnap.order=items.map(rowOrderKey);+ } if(T.resync)requestResync(T.resync); return T; }@@ -5573,6 +5664,9 @@ function rebaseOrder(F,mine,T,grouped){ const mineLocal=localOld.filter(k=>!T.fresh.has(k)); const base=groupOrder(mine&&mine.order?mine.order:old).filter(k=>liveSet.has(k)&&!T.fresh.has(k)); const pend=orderMoves(base,mineLocal);+ // Moves this page holds and the server does not are unsent changes, however they came about+ // (a page that missed a change and caught up holds some it never made): afterSync must look.+ if(pend.length)markLocalEdit(); const res=F.filter(k=>liveSet.has(k)); const inRes=new Set(res); localOld.forEach((k,i)=>{@@ -5589,7 +5683,12 @@ function rebaseOrder(F,mine,T,grouped){ if(groupOrder(fl).join('\n')!==fl.join('\n')&&!computeOps().some(o=>o.op==='row'&&o.set&&'phase' in o.set))T.resync='sections'; } if(res.join('\n')===raw.join('\n'))return;- if(applyOrder(res)){ T.structure=true; T.order=true; }+ // The same rows in another order (applyOrder takes nothing else): their sections, the+ // headers' places and every row's own paint stand as they were, so the repaint moves the+ // rows that moved and the arrows, and nothing else (0.78.1). It used to be a structural+ // change: every label, bar, the legend, the filters and the whole timeline, ~200 ms on+ // every open page for each row somebody dragged.+ if(applyOrder(res))T.order=true; } // What a batch sent and the server did not take, where this tab has not changed it since, // goes back to the server's value; otherwise it would be sent again with every save.@@ -5634,15 +5733,16 @@ function repaintAfter(T,range0){ if(T.dates||T.cal){ renderDatesCard(); renderCalCard(); } const geo=T.structure||T.phases||T.dates||T.cal||['s','e','date','deps','name','phase'].some(f=>T.fields.has(f)); if(prefs.view==='gantt'){- if(T.structure||T.phases||T.dates||T.cal||JSON.stringify(scanRange())!==range0)rebuildTimeline();+ // range0 is null when the batch could not have moved the plan's span (0.78.1).+ if(T.structure||T.phases||T.dates||T.cal||(range0!==null&&JSON.stringify(scanRange())!==range0))rebuildTimeline(); else if(geo)refreshChartGeometry(); } else if(geo)timelineStale=true; // Not while a cell is being typed into: the repaint would close that editor under the // person's hands. The model is current either way, and finishing the cell repaints.- if(!tblEditing&&prefs.view==='table'&&(rows.length||T.structure||T.phases)){+ if(!tblEditing&&prefs.view==='table'&&(rows.length||T.structure||T.phases||T.order)){ if(T.structure||T.phases||!patchTableRows(rows,T.fields))renderTable(); }- if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup'&&(T.structure||T.phases||T.reg||T.dates||T.cal||rows.length))renderSections(true);+ if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup'&&(T.structure||T.phases||T.order||T.reg||T.dates||T.cal||rows.length))renderSections(true); if(rows.length||T.structure||T.dates||T.cal){ syncPlanSpan(); refreshViewChips(); } syncCollapseAllBtn(); syncPlanCtl(); if(critOn()&&(geo||T.fields.has('status')))refreshCritical();@@ -6785,6 +6885,35 @@ function placeMark(){ return null; }); }+// The reader's place for a change that only reorders rows (0.78.1). The rows move only inside+// their own sections then, so no header moves and none can be the place to keep: the rows are+// read where they stand, with the pinned headers left pinned, which spares the two restyles of+// the whole chart that unpinnedRead costs per change. Gives what placeMark gives otherwise: the+// first row on screen whose bottom is below the band, or nothing when placeMark would have+// stopped on a header, which an order change cannot move.+function placeMarkRows(){+ if(prefs.view!=='gantt'||timelineEl.scrollTop<1)return null;+ const paneTop=timelineEl.getBoundingClientRect().top, band=chartViewBand().top;+ let prev=null;+ for(let i=0;i<items.length;i++){+ const r=items[i];+ if(!r._bDiv||r._collapsed)continue;+ if(isDerivedGroup(r)){ prev=r; continue; }+ const b=r._bDiv.getBoundingClientRect();+ if(!b.height)continue;+ if(b.bottom>band+1)return prev&&isDerivedGroup(prev)&&b.top>band+1?null:{r,i,y:b.top-paneTop};+ prev=r;+ }+ return null;+}+// And keeping it: only when that row moved, which it can only have done inside its section.+function keepPlaceRows(mark){+ if(!mark||prefs.view!=='gantt'||!mark.r._bDiv)return;+ const d=mark.r._bDiv.getBoundingClientRect().top-timelineEl.getBoundingClientRect().top-mark.y;+ if(Math.abs(d)<0.5)return;+ timelineEl.scrollTop+=d;+ syncLabelScroll(); updatePinnedHeader();+} function keepPlace(mark){ if(prefs.view!=='gantt')return; const shows=x=>x._bDiv&&!x._collapsed;@@ -7071,8 +7200,10 @@ function layoutMsCaps(){ } function keepTailLast(){- if(typeof labelTail!=='undefined'&&labelTail&&labelTail.parentNode) labelsEl.appendChild(labelTail);- if(typeof barTail!=='undefined'&&barTail&&barTail.parentNode) timelineEl.appendChild(barTail);+ // Already last is left alone (0.78.1): appending a node that is in place still takes it+ // out and puts it back, which restyles it and lays the pane out again.+ if(typeof labelTail!=='undefined'&&labelTail&&labelTail.parentNode&&labelsEl.lastElementChild!==labelTail) labelsEl.appendChild(labelTail);+ if(typeof barTail!=='undefined'&&barTail&&barTail.parentNode&&timelineEl.lastElementChild!==barTail) timelineEl.appendChild(barTail); } function sizeTailroom(){ const pane=timelineEl.clientHeight||0;@@ -7227,6 +7358,8 @@ function depEnd(r){ return {out:[m.cx+m.half,m.cy],in:[m.cx-m.half,m.cy]}; } const be=r.id&&barEls[r.id]; if(!be)return null;+ const rec=depRowCache&&depRowCache.rows.get(r);+ if(rec&&rec.h){ const y=rec.top+rec.h/2; return {out:[rec.bx1,y],in:[rec.bx0,y]}; } const row=sb0(be), b=be.bar, y=row.offsetTop+row.offsetHeight/2; return {out:[b.offsetLeft+b.offsetWidth,y],in:[b.offsetLeft,y]}; }@@ -7243,27 +7376,60 @@ function depEndsOf(s,t){ // dimmed one is still on screen, so it still is. function depRects(){ const obs=[], lanes=[]; let gap=Infinity;- items.forEach(r=>{- const bd=r._bDiv; if(!bd||!bd.offsetHeight)return;- const top=bd.offsetTop;+ // Each row measured once into a record the arrows read their ends from too (0.78.1). A+ // merge that only reorders rows re-measures only the rows between the first and last place+ // that changed (depSpan): every row outside them has the same rows above it, so it stands+ // where it stood. The rest of the chart's geometry is checked unchanged first (depGeoSig).+ const sig=depGeoSig(), reuse=orderOnlyMerge&&depSpan&&depRowCache&&depRowCache.sig===sig;+ const next=new Map();+ items.forEach((r,i)=>{+ let rec=reuse&&(i<depSpan[0]||i>depSpan[1])?depRowCache.rows.get(r):undefined;+ if(rec===undefined)rec=depRowRec(r);+ next.set(r,rec);+ if(!rec||!rec.h)return; // The boundary below this row, which is where a horizontal run is free.- lanes.push(top+bd.offsetHeight);- if(r.id&&barEls[r.id]){- const b=barEls[r.id].bar; if(!b.offsetWidth)return;- const y0=top+b.offsetTop, h=b.offsetHeight;- gap=Math.min(gap,b.offsetTop,bd.offsetHeight-b.offsetTop-h);- obs.push({id:r.id,x0:b.offsetLeft,x1:b.offsetLeft+b.offsetWidth,y0,y1:y0+h});- return;- }- // A milestone's diamond. Its own arrows (0.78.0) leave and arrive at its points, so it- // carries its id like a bar does and is not in their way at the points they use.- const m=msDiamondBox(r); if(!m)return;- obs.push({id:r.id||null,x0:m.cx-m.half,x1:m.cx+m.half,y0:m.cy-m.half,y1:m.cy+m.half});+ lanes.push(rec.top+rec.h);+ gap=Math.min(gap,rec.gap);+ if(rec.obs)obs.push(rec.obs); });+ depRowCache={sig,rows:next}; depSpan=null; // Never claim more clearance than the layout has. The row height and the bar height // both scale with the text size, and so does the band between them. return {obs,lanes,pad:Math.max(0.5,Math.min(DEP_PAD,gap===Infinity?DEP_PAD:gap*0.4))}; }+// One row as the routing sees it: where it is, how tall, its bar's two ends, and the box its+// bar or diamond takes as an obstacle (none for a row that is not on screen).+let depRowCache=null, depSpan=null;+function depRowRec(r){+ const bd=r._bDiv; if(!bd)return null;+ const top=bd.offsetTop, h=bd.offsetHeight, rec={top,h,bx0:0,bx1:0,obs:null,gap:Infinity};+ if(r.id&&barEls[r.id]){+ const b=barEls[r.id].bar, bw=b.offsetWidth, bl=b.offsetLeft;+ rec.bx0=bl; rec.bx1=bl+bw;+ if(!h||!bw)return rec;+ const bt=b.offsetTop, bh=b.offsetHeight;+ rec.gap=Math.min(bt,h-bt-bh);+ rec.obs={id:r.id,k:r,x0:bl,x1:bl+bw,y0:top+bt,y1:top+bt+bh};+ return rec;+ }+ if(!h)return rec;+ // A milestone diamond is a rotated square, so its box is not the box CSS laid out:+ // the 45 degree turn happens about the centre and the translateX(-50%) rides along+ // with it. Small, but a route through one still reads as a mistake.+ const d=r._msEl; if(!d||!d.offsetWidth)return rec;+ const w=d.offsetWidth,dh=d.offsetHeight,off=w*Math.SQRT1_2/2,half=Math.hypot(w,dh)/2;+ const cx=d.offsetLeft+w/2-off, cy=top+d.offsetTop+dh/2-off;+ // Its own arrows (0.78.0) leave and arrive at its points, so it carries its id like a bar does.+ rec.obs={id:r.id||null,k:r,x0:cx-half,x1:cx+half,y0:cy-half,y1:cy+half};+ return rec;+}+// Everything besides the order that moves rows or bars: the width, the text size, the milestone+// style, the folded sections, which rows are dimmed, how many rows there are. The kept records+// are used only while it is unchanged.+function depGeoSig(){+ let dim=0; items.forEach((r,i)=>{ if(r._bDiv&&r._bDiv.classList.contains('dim'))dim=(dim*31+i)%1000000007; });+ return [W,prefs.fs||1,document.documentElement.dataset.ms||'',items.length,[...collapsed].sort().join(','),dim].join('|');+} // Everything the routing needs, measured once per redraw rather than once per arrow. The // rows are walked exactly once here: this runs on every mousemove of a bar drag, so the // DOM is read in one pass and every arrow then works off the numbers.@@ -7582,14 +7748,20 @@ function withDeps(fn){ // The chart's geometry while another lens is on screen (0.75.1): there is nothing to measure // there (the panes are hidden), so the work is owed, and the switch back pays it once. let timelineStale=false;+// The arrows on screen, by the dependency each one draws (0.78.1), so a redraw reuses them.+const depEls=new Map();+// What the last redraw routed around: every obstacle by its row, the lanes, the clearance.+let depLast=null; function drawDeps(){ if(depDefer){ depRedrawOwed=true; return; } if(prefs.view!=='gantt'){ timelineStale=true; return; }- depSvg.innerHTML=''; // The layer lives inside the bars, so bars.scrollHeight below counts the layer's own // old height: it could grow and never shrink, and after a filter the chart stayed as // tall as the whole plan while the names shrank to the rows on screen (0.74.1).- depSvg.setAttribute('height','1');+ // Not in an order-only merge (0.78.1): the rows only trade places, so the chart's height+ // stands, and shrinking the layer to measure it would lay the chart out twice more.+ const keepH=orderOnlyMerge;+ if(!keepH)depSvg.setAttribute('height','1'); // Every measurement first, then every node, built off the page and added at once // (0.75.1): the old loop read a bar's position right after writing the previous arrow, // which forced a fresh layout per read, hundreds per redraw.@@ -7606,13 +7778,33 @@ function drawDeps(){ // drawn from nowhere to nowhere if(r._collapsed||sItem._collapsed)return; // Measure the row rather than assuming 17, which was half of a 34px row and- // put every arrow in the wrong place the moment the text scale moved it.+ // put every arrow in the wrong place the moment the text scale moved it. A task's+ // end comes from depRects' record of its row (0.78.1), read once per redraw, not per arrow. const S=endOf(sItem),T=endOf(r); if(!S||!T)return; const [[x1,y1],[x2,y2]]=depEndsOf(S,T); edges.push({depId,to:r.id,sItem,x1,y1,x2,y2}); });});- const frag=document.createDocumentFragment(); let maxY=0;+ // Each arrow is one path and one head, kept from the last redraw while its dependency is+ // still drawn: only a route, a head or a colour that changed is written (0.78.1). Every+ // redraw used to throw the layer away and build all ~500 nodes again, on every open page,+ // for every row somebody moved.+ // A route depends on its two ends and on the obstacles and lanes in its own band, nothing+ // else (depPts). So it is worked out again only when an end moved or an obstacle that+ // changed since the last redraw, where it was or where it is, falls in that band by the+ // same test depPts uses; any change to the lanes, the clearance or the width reroutes+ // all of them (0.78.1). Every arrow re-searched every route on each redraw before.+ const lanesSig=ctx.lanes.join(','), prevD=depLast, moved=[], movedNow=[];+ let rerouteAll=!prevD||prevD.pad!==ctx.pad||prevD.xMax!==ctx.xMax||prevD.lanes!==lanesSig;+ const obsNow=new Map(); ctx.obs.forEach(o=>obsNow.set(o.k,o));+ if(!rerouteAll){+ prevD.obs.forEach((o,k)=>{ const n=obsNow.get(k);+ if(!n||n.x0!==o.x0||n.x1!==o.x1||n.y0!==o.y0||n.y1!==o.y1){ moved.push(o); if(n){ moved.push(n); movedNow.push(n); } } });+ obsNow.forEach((n,k)=>{ if(!prevD.obs.has(k)){ moved.push(n); movedNow.push(n); } });+ }+ depLast={obs:obsNow,pad:ctx.pad,xMax:ctx.xMax,lanes:lanesSig};+ const reaches=(lo,hi)=>moved.some(o=>o.y1+ctx.pad>lo&&o.y0-ctx.pad<hi);+ const keepK=new Set(), want=[]; let maxY=0, madeNew=false; edges.forEach(({depId,to,sItem,x1,y1,x2,y2})=>{ // Which dependency this route IS, carried on the drawn elements. Attributes // rather than extra nodes: the layer is redrawn wholesale on every geometry@@ -7621,15 +7813,47 @@ function drawDeps(){ // The SOURCE row's phase, derived once per edge and put on both halves of it, so // the line and the head it ends in are one colour and cannot drift apart. const stroke=depStrokeFor(sItem);- const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2,ctx,{from:depId,to}));- p.setAttribute('data-from',depId);p.setAttribute('data-to',to);paintDepEdge(p,stroke);frag.appendChild(p);- const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`);- a.setAttribute('data-from',depId);a.setAttribute('data-to',to);paintDepEdge(a,stroke);frag.appendChild(a);+ let k=depId+'>'+to; while(keepK.has(k))k+='+'; keepK.add(k);+ let e=depEls.get(k);+ if(!e){ madeNew=true;+ const p=document.createElementNS('http://www.w3.org/2000/svg','path'), a=document.createElementNS('http://www.w3.org/2000/svg','polygon');+ p.setAttribute('data-from',depId);p.setAttribute('data-to',to);a.setAttribute('data-from',depId);a.setAttribute('data-to',to);+ e={p,a,d:null,pts:null,stroke:undefined}; depEls.set(k,e);+ }+ // A route that was the plain Z (the first shape depPts tries, taken whenever it crosses+ // nothing) is still the one depPts would draw when no bar that moved now lies across it:+ // taking bars away cannot block it, and nothing else about it changed.+ const self={from:depId,to};+ let fresh=rerouteAll||e.d===null||e.x1!==x1||e.y1!==y1||e.x2!==x2||e.y2!==y2;+ if(!fresh&&reaches(Math.min(y1,y2),Math.max(y1,y2))){+ const zx=e.zx;+ fresh=zx===undefined||movedNow.some(o=>depHit([o],self,ctx.pad,x1,y1,zx,y1)||depHit([o],self,ctx.pad,zx,y1,zx,y2)||depHit([o],self,ctx.pad,zx,y2,x2,y2));+ }+ let d=e.d;+ if(fresh){+ const pts=depPts(x1,y1,x2,y2,ctx,self); d=roundedPath(pts,DEP_R);+ // Remember whether it came out as the plain Z, and where its drop runs.+ const xa=x1+DEP_STUB, xb=x2-DEP_STUB, want=Math.min(Math.max(x1+(x2-x1)*0.5,xa),xb);+ e.zx=xb>=xa&&pts.length===4&&pts[0][0]===x1&&pts[0][1]===y1&&pts[1][0]===want&&pts[1][1]===y1&&pts[2][0]===want&&pts[2][1]===y2&&pts[3][0]===x2&&pts[3][1]===y2?want:undefined;+ }+ const pts=`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`;+ e.x1=x1; e.y1=y1; e.x2=x2; e.y2=y2;+ if(e.d!==d){ e.p.setAttribute('d',d); e.d=d; }+ if(e.pts!==pts){ e.a.setAttribute('points',pts); e.pts=pts; }+ if(e.stroke!==stroke){ paintDepEdge(e.p,stroke); paintDepEdge(e.a,stroke); e.stroke=stroke; }+ want.push(e.p,e.a); maxY=Math.max(maxY,y1,y2);});- depSvg.appendChild(frag);- depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));- // The redraw threw away the classes, so the pick, the armed state, the critical- // path and the cancelled dim are put back from the state that outlives the DOM.+ depEls.forEach((e,k)=>{ if(!keepK.has(k))depEls.delete(k); });+ // Anything else in the layer goes, as the wholesale redraw always did, and the arrows+ // stand in edge order, moving only the ones out of place.+ const wantSet=new Set(want);+ [...depSvg.childNodes].forEach(n=>{ if(!wantSet.has(n))n.remove(); });+ placeInOrder(depSvg,want,()=>true);+ if(!keepH)depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));+ // The pick, the armed state, the critical path and the cancelled dim are put back from the+ // state that outlives the DOM. An order-only merge changes none of them and makes no new+ // arrow, and the arrows it kept still carry theirs (0.78.1).+ if(keepH&&!madeNew)return; paintDepSel(); paintCritDeps(); paintCancelledDeps();@@ -12362,24 +12586,62 @@ function tableRowIn(r){ // and nothing else is built. Falls back to the whole table (false) when the change could // move a row (a sort, a filter it now passes or fails) or show in another row's cell (a // name or a dependency, which the Deps column of the rows around it names).+//+// Since 0.78.1 a sorted table, a new order and rows that start or stop passing a filter are+// patched too, the way a document editor applies somebody else's keystroke: the rows to show+// and their order are worked out again (filters and the sort are cheap), markup is made only+// for the rows that changed or newly show, and reconcileTable touches only those and the rows+// out of place. The table sorts by deadline unless somebody picked plan order, so the old+// patch, which needed plan order, almost never ran: every open table rebuilt itself on every+// change anyone made, ~300 ms a date edit. function patchTableRows(rows,fields){- if(!tableBody||!tableRowsCache||!manualOrder()||fields.has('deps')||fields.has('name')||fields.has('phase'))return false;+ if(!tableBody||!tableRowsCache||fields.has('deps')||fields.has('name')||fields.has('phase'))return false; if(tblEditing)return false;- const kids=tableBody.children, c=tableRowsCache;- if(kids.length!==c.keys.length)return false;- for(const r of rows){- const k=rowKey(r), i=c.keys.indexOf(k);- if((i>=0)!==tableRowIn(r))return false;- if(i<0)continue;- const h=tableRowHtml(r);- if(h===c.html[i])continue;- const t=document.createElement('template'); t.innerHTML=h;- tableBody.replaceChild(t.content.firstElementChild,kids[i]);- c.html[i]=h;- }+ const c=tableRowsCache, at=new Map(c.keys.map((k,i)=>[k,i]));+ const touched=new Set(rows.map(r=>rowKey(r)));+ let now, keys, html;+ withDepIndex(()=>{+ now=tableRows();+ if(!now.length)return;+ keys=now.map(r=>rowKey(r));+ html=now.map((r,i)=>{ const j=at.get(keys[i]); return j===undefined||touched.has(keys[i])||tableForceKeys.has(keys[i])?tableRowHtml(r):c.html[j]; });+ });+ if(!now.length)return false; // the empty message is renderTable's+ reconcileTable(keys,html);+ tableForceKeys.clear();+ tableRowsCache={keys,html};+ syncTableCount(now.length); markFocusedRow(); paintCritTable(); paintPeerFocus(); return true; }+// The table's body brought to `keys` and `html` touching only what changed (0.78.1): a row+// whose markup is the same keeps its element, a changed or new row gets a new one, a row no+// longer listed goes, and only rows out of place are moved. With nothing to keep (the first+// render, the empty message) or most rows new, the body is written in one go, which is faster.+function reconcileTable(keys,html){+ const c=tableRowsCache, kids=tableBody.children;+ let byKey=null;+ if(c&&kids.length===c.keys.length){+ byKey=new Map();+ for(let i=0;i<kids.length;i++){ const el=kids[i]; if(!el.dataset||el.dataset.key!==c.keys[i]){ byKey=null; break; } byKey.set(c.keys[i],{el,html:c.html[i]}); }+ }+ if(!byKey){ tableBody.innerHTML=html.join(''); return; }+ const want=new Array(keys.length); let fresh=0;+ for(let i=0;i<keys.length;i++){+ const w=byKey.get(keys[i]);+ if(w&&w.html===html[i]&&!tableForceKeys.has(keys[i])){ want[i]=w.el; continue; }+ const t=document.createElement('template'); t.innerHTML=html[i]; want[i]=t.content.firstElementChild; fresh++;+ }+ if(fresh>keys.length/2){ tableBody.innerHTML=html.join(''); return; }+ const keep=new Set(want);+ for(const el of [...kids])if(!keep.has(el))el.remove();+ if(placeInOrder(tableBody,want,el=>el.tagName==='TR')<0)tableBody.innerHTML=html.join('');+}+function syncTableCount(n){+ const total=items.filter(r=>!isGroup(r)).length;+ countEl.textContent = n===total ? `${total} items` : `${n} of ${total} items`;+ countEl.title = n===total ? 'Every task and milestone in the plan is listed.' : tableHiddenWhy(n,total);+} function tableRows(){ const q=(searchInp.value||'').trim().toLowerCase(); const st=statusSel.value, ks=tagFilterKeys();@@ -12448,9 +12710,19 @@ function provCell(r){ function depsOf(r){ if(!r||!r.id)return {needs:[],blocks:[]}; const needs=(r.deps||[]).map(d=>({id:d,name:depName(d)}));- const blocks=items.filter(d=>d.deps&&d.deps.includes(r.id)).map(d=>({id:d.id,name:d.name||d.id}));+ const blocks=(depIndex?(depIndex.get(r.id)||[]):items.filter(d=>d.deps&&d.deps.includes(r.id))).map(d=>({id:d.id,name:d.name||d.id})); return {needs,blocks}; }+// Who depends on whom, built once for one pass over the table (0.78.1): every row's Deps+// cell used to scan the whole plan for the rows it blocks, ~250,000 checks per render.+// Same rows, same order (the plan's) as that scan.+let depIndex=null;+function withDepIndex(fn){+ if(depIndex)return fn();+ depIndex=new Map();+ items.forEach(d=>{ if(!d.deps||!d.id)return; d.deps.forEach(x=>{ let a=depIndex.get(x); if(!a)depIndex.set(x,a=[]); if(a[a.length-1]!==d)a.push(d); }); });+ try{ return fn(); }finally{ depIndex=null; }+} function depCell(r){ const {needs,blocks}=depsOf(r); if(!needs.length&&!blocks.length)return '';@@ -12490,10 +12762,9 @@ function renderTable(){ syncSortHeaders(); syncSortLock(); applyColWidths();- 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);+ let rows, html;+ withDepIndex(()=>{ rows=tableRows(); if(rows.length)html=rows.map(tableRowHtml); });+ syncTableCount(rows.length); 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.@@ -12505,20 +12776,12 @@ function renderTable(){ :'No items match these filters.'; tableRowsCache=null; tableBody.innerHTML='<tr><td colspan="13" class="table-empty">'+why+'</td></tr>';return;}- const keys=rows.map(r=>rowKey(r)), html=rows.map(tableRowHtml);- // Keyed patch (0.75.1). When these are the same rows in the same order as the table- // already shows (every live merge that changed a field), only the rows whose markup- // changed are replaced. Rewriting all ~500 rows cost the browser about half a second of- // style and layout on every save anyone made, for one changed cell.- const kids=tableBody.children, c=tableRowsCache;- const same=c&&c.keys.length===keys.length&&kids.length===keys.length&&keys.every((k,i)=>k===c.keys[i]&&kids[i].dataset&&kids[i].dataset.key===k);- if(same){- for(let i=0;i<keys.length;i++){- if(html[i]===c.html[i]&&!tableForceKeys.has(keys[i]))continue;- const t=document.createElement('template'); t.innerHTML=html[i];- tableBody.replaceChild(t.content.firstElementChild,kids[i]);- }- } else tableBody.innerHTML=html.join('');+ const keys=rows.map(r=>rowKey(r));+ // Keyed patch (0.75.1). Only the rows whose markup changed are replaced: rewriting all+ // ~500 rows cost the browser about half a second of style and layout on every save+ // anyone made, for one changed cell. Since 0.78.1 also when rows moved, came or went+ // (a sort, a filter, a new order), which used to rewrite the whole body.+ reconcileTable(keys,html); tableForceKeys.clear(); tableRowsCache={keys,html}; markFocusedRow();
install.shpackage.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.78.0",+ "version": "0.78.1", "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.78.0",+ "version": "0.78.1", "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+65−6@@ -411,8 +411,45 @@ function jsonResp(res, code, obj) { res.end(JSON.stringify(obj)); } +// ── The plan files, kept parsed (0.78.1) ──+// Every /ops read the data file and the sidecar from disk and parsed them about five times+// over (loadData, loadState, mergedData's own two and stateRev for one number), ~1.4 MB of+// JSON per batch, three times a second while a team works. Each file is now parsed once per+// change, keyed by what stat() says about it (inode, size, modified and changed times), so+// an edit from outside (an agent, git, a hand edit) is read the next time anything asks.+// This server's own writes put their text in place as they land. A key taken within a few+// milliseconds of the file's last change cannot tell two writes in the same clock tick+// apart, so such an entry is confirmed against the file's contents once more on the next+// look, as git does for its index. Every caller still gets a copy of its own, so nothing a+// request changes can reach the next one; a copy costs about a quarter of a parse.+const planCache = new Map(); // file -> { key, at, text, obj }+let mergedCache = null; // { de, se, merged }: see mergedData+const RACY_MS = 20;+function statKey(st) { return st.ino + ':' + st.size + ':' + st.mtimeMs + ':' + st.ctimeMs; }+function planEntry(file) {+ let st;+ try { st = fs.statSync(file); } catch (e) { planCache.delete(file); return null; }+ const key = statKey(st), c = planCache.get(file);+ if (c && c.key === key && c.at - st.mtimeMs >= RACY_MS) return c;+ const text = fs.readFileSync(file, 'utf8');+ if (c && c.text === text) { c.key = key; c.at = Date.now(); return c; }+ const e = { key, at: Date.now(), text, obj: undefined };+ planCache.set(file, e);+ return e;+}+function planObj(e) { if (e.obj === undefined) e.obj = JSON.parse(e.text); return e.obj; }+function forgetPlanFiles() { planCache.clear(); mergedCache = null; }+// A plain copy of parsed JSON: objects, arrays and values, nothing else.+function cloneJson(v) {+ if (Array.isArray(v)) { const a = new Array(v.length); for (let i = 0; i < v.length; i++) a[i] = cloneJson(v[i]); return a; }+ if (v && typeof v === 'object') { const o = {}; for (const k in v) o[k] = cloneJson(v[k]); return o; }+ return v;+}+ function loadData() {- return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));+ const e = planEntry(DATA_FILE);+ if (!e) return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); // throws the missing-file error, as it always did+ return cloneJson(planObj(e)); } // A half-written plan file is worse than an unwritten one, and this server now@@ -424,6 +461,13 @@ function writeAtomic(file, text) { const tmp = file + '.tmp' + process.pid; fs.writeFileSync(tmp, text, 'utf8'); fs.renameSync(tmp, file);+ // On disk first, then the parsed copy follows what was written (0.78.1). The text is this+ // server's own, renamed into place as a new file, so it is taken as settled rather than read+ // back to confirm it; anybody else's change to it after this still shows in stat().+ if (file === DATA_FILE || file === STATE_FILE) {+ mergedCache = null;+ try { const st = fs.statSync(file); planCache.set(file, { key: statKey(st), at: st.mtimeMs + RACY_MS, text, obj: undefined }); } catch (e) { planCache.delete(file); }+ } } function writeDataFile(data) { writeAtomic(DATA_FILE, JSON.stringify(data, null, 2) + '\n'); } // ── Backups (0.68.0) ──@@ -457,10 +501,8 @@ function backupOnce(file, suffix) { } function loadState() {- if (fs.existsSync(STATE_FILE)) {- return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));- }- return null;+ const e = planEntry(STATE_FILE);+ return e ? cloneJson(planObj(e)) : null; } // ── Changelog ──@@ -485,7 +527,7 @@ function readLog() { // A counter on the sidecar, bumped by every successful save. It answers exactly one // question: has this file changed since the copy I am holding? function stateRev() {- try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')).stateRev || 0; }+ try { const e = planEntry(STATE_FILE); return e ? planObj(e).stateRev || 0 : 0; } catch (e) { return 0; } } // ── Live channel (0.66.0) ──@@ -2222,7 +2264,19 @@ function applyOverride(r, sv, phaseCount) { if (Number.isInteger(sv.phase) && sv.phase >= 0 && sv.phase < phaseCount) r.phase = sv.phase; return r; }+// The merged plan, worked out once per change to either file (0.78.1): it is a function of+// the two and nothing else, and every /ops, export and page load asked for it afresh. Each+// caller gets its own copy. function mergedData() {+ const de = planEntry(DATA_FILE), se = planEntry(STATE_FILE);+ if (!de || !mergedCache || mergedCache.de !== de || mergedCache.se !== se) {+ const merged = mergedDataFresh();+ mergedCache = de ? { de, se, merged } : null;+ return de ? cloneJson(merged) : merged;+ }+ return cloneJson(mergedCache.merged);+}+function mergedDataFresh() { const data = tidyPlanTags(loadData()); const state = tidyStateTags(upgradeState(loadState(), data)); if (!state) return finaliseMerged(data);@@ -2462,6 +2516,7 @@ function importJSON(body) { fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); // Clear state file since data structure changed if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ forgetPlanFiles(); return { ok: true, items: data.items.length, phases: data.phases.length, uids }; } @@ -2578,6 +2633,7 @@ function importCSV(body, queryString) { backupPlan('import'); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ forgetPlanFiles(); return { ok: true, items: items.length, phases: phases.length, uids }; } @@ -2697,6 +2753,7 @@ function importMSProjectXML(body) { backupPlan('import'); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ forgetPlanFiles(); return { ok: true, items: tasks.length, uids }; } @@ -2843,6 +2900,7 @@ function importJiraCSV(body) { backupPlan('import'); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ forgetPlanFiles(); return { ok: true, items: validItems.length, uids }; } @@ -3609,6 +3667,7 @@ const server = http.createServer(async (req, res) => { backupPlan('compact'); fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ forgetPlanFiles(); logCompact(info, auth.agent ? AGENT_ACTOR : null); jsonResp(res, 200, Object.assign({ ok: true, file: path.basename(DATA_FILE) }, info)); } catch (e) {
uninstall.sh