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
0.48.0: Auto-schedule says it is running, with a panning Adom-gradient outline on the button
4 files changed
+358−5
docs/BUILD-NOTES.md+188@@ -3,6 +3,194 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-20 (0.48.0, Auto-schedule says it is running)++One change from Drew, client-side. `gantt.html` only. `server.js` did not move: nothing+about the sidecar, the changelog or the save protocol changed. **No live server was+restarted and no live plan was opened at any point.** Every suite runs on synthetic+fixtures on its own port.++Pressing Auto-schedule used to look, for as long as the pass took, exactly like pressing+a button that had not responded. Now the button's OUTLINE lights up in the Adom brand+ramp and pans back and forth while the pass runs.++### It is an outline, not a fill++The button's face, its label, its icon and its own hover are untouched. What appears is a+2px band of `--ph1 -> --ph3 -> --ph2` around it: teal, blue, purple, the phase ramp,+which is the Adom brand ramp and is already defined per theme, deepened on paper. No new+colour tokens were invented and both themes come free.++A fill would have said "this button is now a different button". A ring says "this button+is busy" and leaves the thing you pressed looking like the thing you pressed. It is also+not the `.on` fill swap `.plan-ctl` gives Critical, for the same reason 0.47.0's Deps+glow is not: a ring sits on top of the button's own hover without either hiding the other.++`inset: -2px` is measured from the button's padding box, so the band lands **across the+button's own 1px border and 1px beyond it**. That is deliberate: the button's outline+lights up, rather than a second ring appearing outside an outline that is still there.+Measured at 1px of overhang on every side, and the header's gap is 6px, so the ring can+never touch a neighbour.++### Why a transform, and why the ring is a span++This is the part where the plan and the reality disagreed, and it is the most important+decision in the round.++The plan assumed the pass "likely completes in milliseconds on a 160-row plan", so a+naive start/stop would never render a frame. Half of that is right and half is not.+Measured with `PerformanceObserver({entryTypes:['longtask']})` on synthetic fixtures:++| plan | one Auto-schedule press |+|------|------------------------|+| 40 rows | a 201ms task |+| 160 rows | a single **2456ms** task |++So both cases are real. On a small plan the pass is over before a frame can be painted,+which is what the minimum duration is for. On a 160-row plan the pass holds the main+thread for nearly two and a half seconds, which is exactly the moment the user most needs+to be told the app is working and not dead, and it is also the moment when **anything+animated on the main thread is frozen**. A `background-position` pan, which is the+obvious way to do this, would have been a still picture for the entire visible run: the+feedback would work only in the case that barely needs it and fail in the case that does.++The fix is to put the movement on the compositor thread, which is not blocked by script.+A `transform` on an otherwise static layer qualifies; `background-position` does not. So+the ring is two elements rather than one pseudo-element:++- `span.busy-ring` is the window. It carries `inset:-2px`, the radius and the+ `mask-composite: exclude` pair that subtracts the content box and leaves the 2px band.+ It never moves.+- its `::before` is the strip: twice the window's width, `left:-50%` so it is centred,+ painted `linear-gradient(90deg, --ph1, --ph3, --ph2, --ph3, --ph1)`. Only this+ translates, `translateX(25%) -> translateX(-25%) -> translateX(25%)`.++The stops are a palindrome, so the pan turns round without a seam, and each end of the+travel puts one clean brand ramp in the window: `+25%` shows teal to purple left to+right, `-25%` shows the same read backwards. Every frame between them holds all three+colours.++`border-image` was not used because it cannot be animated per-stop, and `box-shadow`+because it cannot carry a gradient at all.++The proof is a compositor screencast, run against both builds on the same 160-row+fixture (`ab48.sh`). Frames delivered while the main thread was blocked by the pass:++```+0.47.0 compositor frames inside the block: 0, distinct: 0+0.48.0 compositor frames inside the block: 84, distinct: 42+```++0.47.0 is a frozen still, which is what a blocked page looks like. 0.48.0 keeps painting.++### The minimum visible duration: 1600ms++`BUSY_MIN_MS = 1600`, held from the moment the button is pressed, and the number is not a+taste call. It is the ring's own animation period, so the shortest run anyone can see is+exactly **one pan out and back**: the ring appears at the start of its travel, crosses,+returns, and is removed at rest. A shorter floor would cut the pan off mid-travel, which+reads as an interruption rather than as a finish. A longer one would be the app inventing+work it is not doing.++It is a **floor and never a ceiling**, and that is asserted rather than claimed. Nothing+in here estimates, counts or paces anything: `endBusy` waits+`max(0, 1600 - elapsed)`, so on the 40-row bench the ring is up for 1601ms and on the+160-row bench it is up for as long as the pass takes and then goes. Measured, both:++```+40 rows pass 201ms ring up 1601ms (the floor)+160 rows pass 2456ms ring up 2532ms (the pass, plus the settle)+```++That is the difference between this and a fake progress bar, which claims to know how far+along it is.++The toast is unchanged and still fires the instant the pass returns, not when the ring+settles: the result is news and should not wait on an animation.++### Yielding, so the ring exists before the pass starts++`runAutoSchedule` blocks from the moment it is called until the toast is up, so calling+it straight after adding the class would compute and paint in the same frame and the ring+would never appear. `runAutoScheduleBusy` therefore does+`requestAnimationFrame(() => requestAnimationFrame(...))` first: one rAF runs before the+next paint, the second runs before the one after it, so the frame carrying the ring has+been painted and the animation is already running by the time the pass starts. The pass+itself is not chunked and not made async: it is the same synchronous function, called+from a later frame.++### Disabled, and staying disabled++The button is `disabled` for the whole run, so a second press cannot start a second pass.+That needed one guard elsewhere. `scheduleSave` calls `syncPlanCtl`, `syncPlanCtl` sets+`btnAuto.disabled` from whether the plan has dependencies, and the pass itself saves, so+without the guard the button would have re-enabled under its own ring halfway through.+`syncPlanCtl` now skips that one write while the button wears `.btn-busy`, and `endBusy`+calls `syncPlanCtl` back once the button is its own again, so the plan's real answer is+what lands. The availability rule is not overridden, only deferred.++It is disabled but **not greyed**: `.plan-ctl` dims a disabled control to 0.4 opacity and+opacity would take the ring down with the button. `header .plan-ctl .act-btn[disabled].btn-busy`+holds it at 1. Busy and unavailable are different states and only one of them is greyed.+`aria-busy="true"` goes on for the run and comes off with the ring.++### Reduced motion++`prefers-reduced-motion: reduce` keeps the ring and drops the travel, for the same+1600ms: `animation: none` and the strip held at `translateX(25%)`, which is the Adom ramp+laid across the outline left to right, teal to purple. Still unmistakably "running", with+nothing moving. Same shape as every other live state in this app.++### No layout shift++The ring is an absolutely positioned child of a `position: relative` button, inserted on+`startBusy` and removed on `endBusy`. Out of flow, so a flex button's own layout never+sees it and the header's gap never changes. Asserted rather than reasoned: `v48.mjs`+records the boxes of `#btnAuto`, `#btnCrit`, `#btnDeps`, `#btnFit`, `#btnNew`,+`#btnTheme`, the button's own label and icon, `.legend`, `.chart-wrap` and `header`+before, during and after every press, in both themes and under reduced motion, and+requires them byte-identical.++### A pattern, not a one-off++`.btn-busy`, `startBusy(btn)` and `endBusy(handle, settle)` know nothing about+scheduling. A future long-running button adopts the whole treatment with:++```js+const h = startBusy($('btnThing'));+if (!h) return; // already running: the work cannot fire twice+requestAnimationFrame(() => requestAnimationFrame(() => {+ try { doTheThing(); } finally { endBusy(h, syncWhateverOwnsTheButton); }+}));+```++`startBusy` returns null when the button is already running something, so the+double-press guard comes with the helper rather than depending on the caller remembering+the disabled attribute. `endBusy`'s second argument is whatever owns the button's+availability afterwards, so a button with its own rule hands that rule back rather than+having it guessed at.++Only Auto-schedule is wired this round, on purpose.++### What was measured++- `v48.mjs`, 86 assertions on the 40-row bench (9001): the gradient's stops read back as+ the theme's own `--ph1/--ph3/--ph2` in both themes, the mask composites to a band, the+ band's 1px overhang, the strip's geometry, the transform animation, 40+ painted frames+ with 30+ distinct offsets sweeping both ways, the button shut for the whole run+ including across its own save, `aria-busy`, the 1600ms floor, the toast and its counts,+ the no-op press getting the same cycle and the "0 tasks moved" line, four presses+ during one run producing one changelog entry, undo, reduced motion, and no layout shift+ at every stage.+- `big48.mjs`, 14 assertions on the 160-row bench (9003): the pass is a >2s task, the+ ring outlasts it, the compositor keeps delivering differing frames while script blocks,+ and the dates, counts and changelog are what they were.+- `ab48.sh`: one press under 0.47.0 and under 0.48.0 on the same 160-row fixture, each+ build on its own port and its own copy of the data. The resulting 160-row date set is+ **byte-identical** (`5988b3290cbbb0acc2f5800e23e1845f` both sides).+- `abrows48.sh`: every row's box on the idle page, both builds, byte-identical.+- `abms48.sh`: the two fixture-coupled legacy suites, both builds, byte-identical.+ ## Build notes, 2026-08-20 (0.47.0, compact panels and a glowing Deps button) Three changes from Drew, all of them client-side. `gantt.html` only. `server.js` did not
gantt.html+167−2@@ -243,6 +243,83 @@ header .act-btn.on#btnDeps { color: var(--accent); border-color: var(--accent); @media (prefers-reduced-motion: reduce) { header .act-btn.on#btnDeps { animation: none; box-shadow: 0 0 0 1px var(--dep-glow), 0 0 10px 1px var(--dep-glow); } }+/* ── A button that is RUNNING something: .btn-busy ──+ Generic on purpose. Auto-schedule is the only button wearing it in 0.48.0, but the+ class and its two JS calls (startBusy / endBusy) know nothing about scheduling, so+ the next long-running button in this app adopts the same feedback by adding a class.++ It is an OUTLINE, not a fill. The button's face, its label, its icon and its own+ hover are all untouched; what moves is a ring drawn OUTSIDE the border, in the Adom+ teal -> blue -> purple, panning out and back. A fill would have said "this button is+ now a different button"; a ring says "this button is busy" and leaves the thing you+ pressed looking like the thing you pressed.++ The ring is an absolutely positioned span inset -2px on a relatively positioned+ button, so it is out of flow and costs the header no space: nothing beside it moves+ when the ring appears and nothing moves back when it goes. The RING shape (rather+ than a filled rounded rectangle behind the button) is the mask-composite trick:+ paint the gradient over the whole box, subtract the content box, and the 2px padding+ band is what is left. border-image cannot be animated per-stop and box-shadow cannot+ carry a gradient at all, which is why neither is used here.++ inset is measured from the button's PADDING box, so -2px lands the band across the+ button's own 1px border and 1px beyond it. That is deliberate: the button's outline+ lights up, rather than a second ring appearing outside an outline that is still+ there. Measured at 1px of overhang on every side, which is also why the ring can+ never overlap a neighbour: the header's gap is 6px.++ The colours are the phase ramp, --ph1/--ph3/--ph2, which IS the Adom brand ramp and+ is already per-theme: teal, blue, purple, deepened on paper. No new tokens, and both+ themes are covered by the ones that already switch. The strip is twice the ring's+ width and reads teal -> blue -> purple -> blue -> teal, so it is a palindrome and the+ pan turns round without a seam.++ WHY A TRANSFORM AND NOT background-position. This is the whole reason the ring is a+ span with a child rather than one pseudo-element. The work these buttons kick off is+ synchronous, and on a 160-row plan the auto-schedule pass is a single 2.4 SECOND task+ on the main thread. Anything animated on the main thread, background-position+ included, is frozen for every millisecond of that, which is precisely the moment the+ user most needs to be told the app is working and not dead. A transform on an+ otherwise static layer is run by the COMPOSITOR, which is a different thread and+ keeps painting frames while script blocks. So the ring is a clipped window (the span,+ which carries the mask and never moves) over a strip (the child, which only ever+ translates), and it keeps panning right through the pass.++ 1.6s round trip, ease-in-out, and the JS floor is the same 1.6s, so the shortest run+ anyone can see is exactly one whole pan out and back, ending where it started. */+header .act-btn.btn-busy { position: relative; }+header .act-btn.btn-busy > .busy-ring {+ position: absolute; inset: -2px; border-radius: 8px; padding: 2px;+ pointer-events: none;+ -webkit-mask: linear-gradient(#000 0 0) content-box no-repeat, linear-gradient(#000 0 0) no-repeat;+ -webkit-mask-composite: xor;+ mask: linear-gradient(#000 0 0) content-box no-repeat, linear-gradient(#000 0 0) no-repeat;+ mask-composite: exclude;+}+header .act-btn.btn-busy > .busy-ring::before {+ content: ''; position: absolute; top: 0; bottom: 0; left: -50%; width: 200%;+ background: linear-gradient(90deg, var(--ph1), var(--ph3), var(--ph2), var(--ph3), var(--ph1));+ animation: busyPan 1.6s ease-in-out infinite;+ will-change: transform;+}+/* +25% of a strip twice the ring's width puts the strip's first half in the window,+ which is the brand ramp read forwards: teal, blue, purple. -25% puts the second half+ there, which is the same ramp read back. So the pan is the gradient sweeping across+ the outline and returning, and every frame of it holds all three colours. */+@keyframes busyPan {+ 0%, 100% { transform: translateX(25%); }+ 50% { transform: translateX(-25%); }+}+/* A busy button is disabled against a second press, but it must not also be FADED:+ .plan-ctl dims a disabled control to 0.4, and opacity takes the ring down with the+ button. Busy and unavailable are different states and only one of them is greyed. */+header .plan-ctl .act-btn[disabled].btn-busy { opacity: 1; }+/* Reduced motion keeps the ring and drops the travel, for the same duration: held at+ the +25% end, which is the Adom ramp laid across the outline left to right, teal to+ purple. Still unmistakably "running", with nothing moving. */+@media (prefers-reduced-motion: reduce) {+ header .act-btn.btn-busy > .busy-ring::before { animation: none; transform: translateX(25%); }+} /* Their labels change length ("Lines"/"Diamonds", "Light"/"Dark"). .actions is right-aligned, so a width change there drags everything left of it, including the Fit button. Pin them to their widest label. */@@ -5936,6 +6013,68 @@ function topoOrder(g){ } const planHasDeps=()=>depGraph().edges>0; +// ── Busy buttons ─────────────────────────────────────────────────────────────+//+// The other half of .btn-busy. Two calls, and they know nothing about what the button+// is doing: startBusy puts the panning ring on and shuts the button against a second+// press, endBusy takes both off. Auto-schedule is the only caller in 0.48.0.+//+// The MINIMUM. A pass of work in this app is synchronous JS, and how long it takes is+// entirely a question of how big the plan is. Measured, on synthetic fixtures: one+// Auto-schedule press is a 201ms task on a 40-row plan and a single 2456ms task on a+// 160-row one. The small case is the one that needs a floor: started and stopped+// honestly it would go on and come off inside one frame, and the user would see nothing+// at all, so the button would look exactly as it did before, like a control that had+// not responded. So endBusy holds the ring until the run has been visible for+// BUSY_MIN_MS.+//+// 1600ms, and the number is not a taste call. It is the ring's own animation period, so+// the shortest run anyone can see is exactly ONE pan out and back: the ring appears at+// the start of its travel, crosses, returns, and is removed at rest. A shorter floor+// would cut the pan off somewhere in the middle, which reads as an interruption rather+// than as a finish; a longer one would be this app inventing work it is not doing.+//+// It is a FLOOR and never a ceiling. Nothing here estimates, counts or paces anything:+// if the pass genuinely runs longer than 1600ms the ring simply keeps panning until the+// pass returns, and then goes. That is the difference between this and a fake progress+// bar, which claims to know how far along it is.+const BUSY_MIN_MS=1600;+// Returns a handle, or NULL if this button is not available to start a run: there is no+// such button, or it is already running one. A caller that treats null as "do not run"+// gets the double-press guard for free, without having to remember the disabled+// attribute, and cannot fire its work twice however it was reached.+function startBusy(btn){+ if(!btn||btn.classList.contains('btn-busy'))return null;+ // The ring is a span rather than a pseudo-element because the strip inside it has to+ // be its own layer for the compositor to animate; see the stylesheet. It is inserted+ // and removed rather than left in the markup so a button opts in with one class and+ // carries nothing when it is idle. Absolutely positioned, so a flex button's own+ // layout does not see it.+ const ring=document.createElement('span');+ ring.className='busy-ring';+ ring.setAttribute('aria-hidden','true');+ btn.appendChild(ring);+ btn.classList.add('btn-busy');+ btn.setAttribute('aria-busy','true');+ btn.disabled=true;+ return {btn,ring,t0:Date.now()};+}+// `settle` is whatever the caller needs run once the button is its own again. The+// disabled state is handed BACK rather than guessed at: endBusy re-enables, and a+// caller whose button has its own availability rule (Auto-schedule is disabled on a+// plan with no dependencies) passes that rule in and it wins.+function endBusy(h,settle){+ if(!h||!h.btn){if(settle)settle();return;}+ const left=Math.max(0,BUSY_MIN_MS-(Date.now()-h.t0));+ setTimeout(()=>{+ h.btn.classList.remove('btn-busy');+ h.btn.removeAttribute('aria-busy');+ if(h.ring&&h.ring.parentNode)h.ring.parentNode.removeChild(h.ring);+ h.btn.disabled=false;+ if(settle)settle();+ },left);+}+ // ── Auto-schedule ──────────────────────────────────────────────────────────── // // One pass over the whole plan: every task with dependencies starts BACK TO BACK@@ -6031,6 +6170,28 @@ function runAutoSchedule(){ showToast(undoToast,res.stuck.length?7000:4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;}); return res; }+// The button's entry point: the same pass, with the ring around it.+//+// The two rAFs are the load-bearing part. runAutoSchedule blocks the thread from the+// moment it is called until the toast is up, so calling it straight after adding the+// class would compute and paint in the SAME frame and the ring would never exist on+// screen. The first rAF runs before the next paint, the second runs before the one+// after it, so by the time the pass starts the frame carrying the ring has been+// painted and the animation is already running.+//+// Everything the pass does is unchanged, including the toast, which still fires the+// moment the pass returns and not when the ring settles: the result is news and should+// not wait on an animation.+function runAutoScheduleBusy(){+ // Null means the button is already running a pass. The disabled attribute stops that+ // from the UI, and this stops it from anywhere else: the pass cannot be fired twice.+ const h=startBusy($('btnAuto'));+ if(!h)return;+ requestAnimationFrame(()=>requestAnimationFrame(()=>{+ try{ runAutoSchedule(); }+ finally{ endBusy(h,syncPlanCtl); }+ }));+} function cycleNames(stuck){ const nm=stuck.map(n=>n.r.name||n.id||'a row'); return nm.slice(0,4).join(', ')+(nm.length>4?', and '+(nm.length-4)+' more':'')+' (dependency cycle)';@@ -8315,7 +8476,11 @@ function syncPlanCtl(){ const has=planHasDeps(); const a=$('btnAuto'), c=$('btnCrit'); if(a){- a.disabled=!has;+ // A pass in flight owns the button. syncPlanCtl runs on every save and the pass+ // itself saves, so without this the button would re-enable under its own ring+ // halfway through and take a second click. endBusy calls back here afterwards,+ // which is where the plan's real answer lands.+ if(!a.classList.contains('btn-busy'))a.disabled=!has; a.title=has?'Align all task dates so dependencies line up finish-to-start, skipping weekends and US federal holidays' :'Auto-schedule needs dependencies: nothing in this plan waits on anything yet'; }@@ -8329,7 +8494,7 @@ function syncPlanCtl(){ } function wirePlanCtl(){ const a=$('btnAuto'), c=$('btnCrit');- if(a)a.addEventListener('click',()=>{ if(!a.disabled)runAutoSchedule(); });+ if(a)a.addEventListener('click',()=>{ if(!a.disabled)runAutoScheduleBusy(); }); if(c)c.addEventListener('click',()=>{ if(!c.disabled)setCritical(!critOn); }); syncPlanCtl(); }
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.47.0",+ "version": "0.48.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, and one more lights up the critical path. 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. 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+2−2@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.47.0",+ "version": "0.48.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, and one more lights up the critical path. 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. 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": {@@ -85,4 +85,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}