← Commit history

0.53.0: Auto-schedule pushes later only so deliberate gaps survive, and cancelled work drops out of the scheduling graph and the critical path

Drew Owens ·0a6c56001b · ·parent b4f6a08
7 files changed +446−46
README.md+5−5
@@ -4,9 +4,9 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  ## Features -**Auto-schedule the whole plan.** One press of **Auto-schedule** lays every task out BACK TO BACK with the work it waits on, and so that nothing starts or ends on a Saturday, a Sunday or a US federal holiday. A successor starts ON its latest dependency's end, so the two bars meet on the chart with no column of white between them. Tasks move earlier as well as later: slack is pulled in and a conflict is pushed out. A task with no dependencies is an anchor and keeps its start, since that is the one thing the plan cannot derive. Durations are preserved in WORKING days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor that ends on the Friday hands off on the Friday, and the weekend sits inside the successor's span rather than between the two of them. Milestones move off a weekend or a holiday to the next working day and sit on the day their last dependency ends. The dependency graph crosses sections freely and so does the schedule. A cycle is left exactly as it is, named in the toast, because there is no honest answer for a row that waits on itself. It is one Ctrl+Z for the whole pass and one line in the changelog, and pressing it on an already aligned plan reports "0 tasks moved" and writes nothing.+**Auto-schedule the whole plan, by pushing later only.** One press of **Auto-schedule** moves every task out until it starts no earlier than the work it waits on has finished, and so that nothing starts or ends on a Saturday, a Sunday or a US federal holiday. The handoff is a MINIMUM rather than an answer. A task that starts BEFORE its latest dependency's end is pushed out to it, so the two bars meet on the chart with no column of white between them; a task that already starts on or after it is LEFT EXACTLY WHERE IT IS. That is what keeps a gap you meant: an order placed on the Monday and the parts that arrive on the Thursday keep their three days, because a lead time is real elapsed time and not a tidying-up job. Nothing is ever pulled EARLIER, so a plan cannot drift backwards over a press and a second press moves nothing at all. Pushes cascade, so a task moved out moves its own successors, measured against where they landed. A task with no dependencies is an anchor and keeps its start, since that is the one thing the plan cannot derive. Durations are preserved in WORKING days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor that ends on the Friday hands off on the Friday, and the weekend sits inside the successor's span rather than between the two of them. Milestones take the same rule with no duration. Cancelled work is out of the pass entirely: it never moves, and it constrains nobody, so a task whose only dependency is cancelled behaves like an anchor and the toast counts the excluded rows as their own number. The dependency graph crosses sections freely and so does the schedule. A cycle is left exactly as it is, named in the toast, because there is no honest answer for a row that waits on itself. It is one Ctrl+Z for the whole pass and one line in the changelog, and pressing it on an already aligned plan reports "0 tasks moved" and writes nothing. -**Critical path, one per section.** **Critical** lights up the LONGEST run of work inside every section at once: the bars, the dependency arrows along it and any milestone on it glow white and breathe, in a saturated indigo on paper. The dependency graph is read as a DAG, a task weighs its duration in WORKING days (weekends and US federal holidays excluded) and a milestone weighs nothing, and the critical path is the maximum-weight path through it. So it is the hardest run of work and not merely whatever chain happens to end last. Nothing is rescheduled: it reads the dates the plan has right now, independently of Auto-schedule.+**Critical path, one per section.** **Critical** lights up the LONGEST run of work inside every section at once: the bars, the dependency arrows along it and any milestone on it glow white and breathe, in a saturated indigo on paper. The dependency graph is read as a DAG, a task weighs its duration in WORKING days (weekends and US federal holidays excluded) and a milestone weighs nothing, and the critical path is the maximum-weight path through it. So it is the hardest run of work and not merely whatever chain happens to end last. A gap between two tasks adds no weight, because the length is the sum of the work along the path and not the calendar it is spread over, so the gaps Auto-schedule now preserves cannot promote or demote a chain. Cancelled work is not in the graph: it can never glow, it carries no weight, and a chain that ran through it is broken at that row, so the section falls back to whatever the longest run through the work that is still live turns out to be. Un-cancelling puts the row, its links and every path through it straight back, because nothing was deleted. Nothing is rescheduled: it reads the dates the plan has right now, independently of Auto-schedule.  A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependency that crosses sections is not an edge and never lights up, and a section with no dependencies among its own rows lights its longest single row. Ties are broken deterministically: more working days, then a greater calendar span, then more rows, then whichever starts earlier in the plan. Everything else stays exactly as it was, selection and dependency mode included, the glow recomputes as you edit, and the state is remembered across reloads. @@ -46,7 +46,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Rename a row in place.** Click the name text in the label column to edit it. Enter commits the new name, closes the field and leaves the row selected, so the rename hands straight over to the keyboard. Escape cancels. -**Dependency arrows are coloured by where they come FROM.** Each arrow takes the phase colour of the task at its tail, so a chain's origin is readable at a glance instead of every arrow being the same teal. A lane colour chosen to work as a wide bar can be invisible as a thin line, so the stroke is derived rather than copied: it is lifted or deepened away from the page, holding its hue, until it clears a 3:1 contrast ratio, which means a lane painted navy gets a lighter blue arrow that still reads as that lane. Picking an arrow in dependency mode, arming one for deletion and lighting the critical path all still override the colour while they are active.+**Dependency arrows are coloured by where they come FROM.** Each arrow takes the phase colour of the task at its tail, so a chain's origin is readable at a glance instead of every arrow being the same teal. A lane colour chosen to work as a wide bar can be invisible as a thin line, so the stroke is derived rather than copied: it is lifted or deepened away from the page, holding its hue, until it clears a 3:1 contrast ratio, which means a lane painted navy gets a lighter blue arrow that still reads as that lane. Picking an arrow in dependency mode, arming one for deletion and lighting the critical path all still override the colour while they are active. An arrow into or out of cancelled work is still drawn, dimmed by the same fraction the cancelled bar takes, so a link the schedule no longer walks recedes exactly as far as the work it points at.  **The full item editor is the New panel.** Editing a task or a milestone opens the same layout, in the same order, with the same labels, as creating one: type and lane, name, owner and status, the dates, notes and provenance. Type is shown but not editable on an existing row, because a task has two dates and a bar and a milestone has one date and a diamond, and dropping one of a task's dates is not a conversion. The buttons are Apply, Cancel and Delete, with Delete kept away from the other two. @@ -319,8 +319,8 @@ table, the item editor or the batch editor are taken exactly as typed. |---------|--------| | Gantt / Table / Analytics | Switch lens. Pinned to the left of the header so it never moves | | Fit | Fit the whole plan into the pane, no horizontal scrolling |-| Auto-schedule | Align every task to its dependencies, finish to start, skipping weekends and US federal holidays. One undo. Disabled when the plan has no dependencies |-| Critical | Light up the longest run of work inside every section. On/off, and it recomputes as the plan changes |+| Auto-schedule | Push every task out to its dependencies, finish to start, skipping weekends and US federal holidays. Never pulls work earlier, so intentional gaps survive. One undo. Disabled when the plan has no dependencies |+| Critical | Light up the longest run of work inside every section, cancelled work left out. On/off, and it recomputes as the plan changes | | A- / A+ | Step the text size for both views. Click the percentage to reset | | Hover "today" | The current date | | Click a table cell | Edit owner, start, deadline, status or notes in place |
SKILL.md+7−6
@@ -7,9 +7,10 @@ description: >   bars, inline date editor, dependency management, undo (Ctrl+Z), and   auto-save to a sidecar state file. One button auto-schedules the whole   plan so every dependency lines up finish-to-start on working days with-  no weekend or federal-holiday starts or ends, and another lights up the-  critical path, meaning the longest run of work inside every section at-  once.+  no weekend or federal-holiday starts or ends, pushing work LATER only so+  a lead-time gap you set on purpose is never closed, and another lights up+  the critical path, meaning the longest run of work inside every section at+  once. Cancelled work is left out of both.   A Table view is the second lens over the same data: every task and   milestone as a row with Owner, Deadline and Status, filterable per owner   and editable in place. An Analytics view is the third: total hours,@@ -219,8 +220,8 @@ rather than a category. - **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type - **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only - **Gantt / Table / Analytics toggle** pinned first in the header, left of every conditional control so its position never shifts. Three lenses over the same items: the chart answers "when", the table answers "whose, and by when", and analytics answers "how much, and is any of it in trouble". See The table view and The analytics view below-- **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: every task with dependencies starts BACK TO BACK with its latest predecessor, on that predecessor's end date, and no task starts or ends on a Saturday, a Sunday or a US federal holiday. Back to back is a statement about the CHART: a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. Tasks move EARLIER as well as later, so slack is pulled in and a conflict is pushed out. A task with no dependencies is an ANCHOR and keeps its start (shifted off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies-- **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and observed US federal holidays out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest+- **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: no task with dependencies starts before its latest predecessor's end date, and no task starts or ends on a Saturday, a Sunday or a US federal holiday. The handoff date is a MINIMUM and not an answer: it is BACK TO BACK for a task that was overlapping and is pushed onto it, and back to back is a statement about the CHART, since a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. **PUSH ONLY, since 0.53.0.** A task that already starts on or after its handoff is LEFT WHERE IT IS, however wide the gap, because a gap in a real plan is usually somebody's lead time: an order placed on the Monday and the parts that arrive on the Thursday keep their three days. Through 0.52.0 the pass went both ways and closed every one of those, and there was no way to keep one. Nothing is ever pulled EARLIER now, so the plan cannot drift backwards over a press, and the pass reaches a fixed point in ONE press: the second moves nothing. Pushes still cascade, each successor measured against where its predecessor LANDED and not against the date it shipped with. **CANCELLED WORK IS NOT IN THE PASS AT ALL,** also since 0.53.0: it is never moved, so its dates freeze exactly as set even if they violate something, and it constrains nobody, so a task whose only predecessors are cancelled behaves like an anchor and one with a mix is pushed by its LIVE predecessors alone. The toast counts those rows as their own third number ("5 tasks moved, 15 unchanged, 1 cancelled excluded") rather than folding them into the count of work that was already aligned. A task with no dependencies is an ANCHOR and keeps its start (shifted LATER off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies+- **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and observed US federal holidays out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest - **Click legend** to filter phases, milestones, dependency arrows, or the today line - **Click a bar** to open the date editor with start/end pickers and dependency management. It is a compact 256px square (`--ed-sq`, one token for both dimensions): the row's name across the top, the two dates, the note, the dependency list and the buttons, at desktop density, so it covers as little of the chart as it can. The dependency list is the only part that flexes and the only part that scrolls; the buttons are pinned to the bottom edge. The milestone panel and the batch panel take the same compact metrics; the full **Edit item** panel, which is filled in rather than glanced at, keeps the full ones - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded@@ -366,7 +367,7 @@ after when it falls on a Sunday. They are computed for whatever years the plan c read from a list, and the observance shift crosses the year boundary: New Year's Day 2028 is a Saturday, so it is observed on Friday 31 December 2027. -They count as non-working everywhere: Auto-schedule steps over them, the critical path+They count as non-working everywhere: Auto-schedule steps over them (forwards, since it only ever pushes later), the critical path weighs its nodes in working days without them, and every hours figure excludes them. On the chart at day grain they get a weekend-style band in a different colour, with the holiday's name in a tooltip on the shaded date above the chart, and a `US holiday` key in the legend.
docs/BUILD-NOTES.md+257
@@ -3,6 +3,263 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-24 (0.53.0, two scheduling semantics Drew decided)++Two changes, one round, both of them changes to what the app MEANS rather than to what it+looks like. Both files moved. `gantt.html` carries all of the behaviour; `server.js`+changed in exactly one place, the English sentence the changelog renders for an+`auto-schedule` event, which said "so every dependency lines up finish-to-start" and was+therefore describing the 0.52.0 pass to any agent reading the log of a 0.53.0 one. No wire+format, no sidecar shape, no importer and no route changed.++**The live plan was not rescheduled by this deploy.** `gantt.html` and `package.json` are+read PER REQUEST, so the running 8901 server serves 0.53.0's client and shows v0.53.0 in+its header without a restart; `server.js` is loaded once at boot, so that one sentence+starts appearing in the changelog when the live process is next restarted. Nothing on+disk under the plan's own directory was read or written by this round.++### 1. Auto-schedule is PUSH ONLY++Drew, verbatim: *"for auto-schedule, there are some items (i.e. order-receive pairs) that+have deliberate gaps. So, Let's make auto-schedules align everything by moving dependent+items Later but never Earlier in the schedule, so as to preserve gaps I have+intentionally set while aligning everything else to follow."*++Through 0.52.0 the pass went both ways, and 0.43.0's header comment said why: *"a schedule+that only ever moved work later would drift the plan out one round at a time and never+bring it back."* That reasoning was about a pass run repeatedly, and it turns out to be+wrong on its own terms, which is worth saying plainly because it is the argument the old+code was built on. Push-only does not drift, because it is a FIXED POINT after one press:+a task the pass has just put at its minimum is by definition no longer before it, so the+second press moves nothing. There is nothing to drift. What the bidirectional pass bought+was not stability, it was the closing of every gap in the plan, and that is precisely the+thing Drew does not want closed.++**The change is one comparison.** The finish-to-start handoff is now a MINIMUM rather than+an answer:++```+const cur=nextBiz(n.start);+let s=cur;+if(n.preds.length){+  ...+  const min=nextBiz(latest);+  if(dayNum(cur)<dayNum(min))s=min;+}+```++At or past the minimum, the row is left where it is and its gap survives. Before it, the+row is pushed OUT to the minimum. Everything downstream of that is unchanged: the same+`topoOrder`, so pushes still cascade and a successor is still measured against where its+predecessor LANDED; the same `addBiz(s,dur)` end derivation, so working-day durations are+preserved; the same cycle rule, so a row that waits on itself is left alone and named.++**The one date that can still come back earlier is an END, and it is not work moving+earlier.** `dur` is `bizBetween(start,end)` and the end is re-derived as+`addBiz(s,dur)`, so a Monday-to-Saturday task is five working days and is redrawn Monday+to Friday. A non-working tail was dropped, no work moved, and that arithmetic is+deliberately untouched. The off-day nudge itself was always later-only: `nextBiz` walks+forward off a Saturday or a holiday and never back.++**What a reader sees.** A pair the pass PUSHED still draws flush, exactly as 0.43.0+promised. A pair the author gapped keeps its column of white, and that column is now the+point rather than an untidiness. The tooltip says so, and it is a CONSTANT now+(`AUTO_TITLE`): the sentence used to be written out twice, once in the markup and once in+`syncPlanCtl`, which rewrites the title on every save. This round's rewrite hit the markup+copy first and the button went straight back to the old sentence on the first edit. One+string, and v53 asserts the markup against the running page so the two cannot drift again.++**The live plan.** Nothing was rescheduled by this deploy, and nothing under the plan's+own directory was read or written by this round. The dc-demo plan will only ever see+push-only behaviour on its NEXT Auto-schedule press, and when that press happens it will+move strictly less than 0.52.0 would have: every gap currently in it survives, and every+overlap is still closed.++### 2. Cancelled work is out of the scheduling graph++Drew, verbatim: *"yes, cancelled tasks should drop out of path and schedule."*++0.52.0 added cancelled as a fifth status and took its HOURS out of the analytics+accumulators, but left it fully in the dependency graph: it could be rescheduled, it could+constrain its successors, and it could light up as the critical path. Work that will not+happen was still driving the plan.++**One reading, two features, one flag.** `depGraph()` grew a `skipCancelled` argument and+`schedGraph()` is the single call Auto-schedule and the critical path both make. Dropping+the row as a NODE does the whole job in one stroke, because both of the behaviours Drew+asked for are the same statement:++* it is never moved (it is not in the pass at all, so its dates freeze as set, violating+  or not: a date on work that will not happen is a record of what was planned and not a+  thing to correct)+* it constrains nobody (its edges went with it, so a successor whose only predecessors are+  cancelled is an ANCHOR, and one with a mix is pushed by its LIVE predecessors alone)+* it can never glow and carries no weight+* a chain THROUGH it is BROKEN at that row, since its predecessors and successors are no+  longer joined by way of it. A section with a second route is REROUTED onto it; a section+  whose only route ran through it falls back to the longest single row left, which is not+  necessarily either stump's first half+* the dep-less section's longest-single-ROW fallback ignores cancelled rows for free,+  because they are not in the graph to be considered++**Analytics keeps the whole graph, deliberately.** The Dependencies card is describing what+the plan SAYS, not what it will DO, and its link count, isolated count and cycle warning+are unmoved by this round. That is why `skipCancelled` is a mode and not the only reading:+the two questions are genuinely different, and the "the two features can never disagree"+invariant the 0.49.0 comment promised is now carried by `schedGraph()` rather than by+`depGraph()` being the only call.++**`planHasDeps()` asks the LIVE graph.** A plan whose only dependencies run through+cancelled work has nothing left for either button to say, and both go quiet with the same+message a plan with no dependencies at all gets.++**Nothing is deleted.** The `deps` arrays on the rows are untouched, so un-cancelling puts+the node, its edges, its weight and every path through it straight back. v53 proves the+round trip on both a broken chain and a rerouted diamond, and on the schedule.++**The arrows.** A link into or out of cancelled work is still DRAWN, because removing it+from the picture would lose the reason the row is in the plan at all, and it is dimmed by+the same 0.42 factor `.bar.cancelled` takes: `0.5 * 0.42 = 0.21`, so the arrow recedes+exactly as far as the bar it points at and the two cannot drift apart. EITHER end being+cancelled dims the edge, since one live end does not revive a link to work nobody will do,+which is why `paintCancelledDeps()` is a full walk recomputing from both endpoints rather+than a toggle driven off the row that changed. The rule sits ABOVE the crit, pick and+armed rules in the stylesheet: a cancelled row can no longer be on the critical path at+all, but an arrow you have PICKED or ARMED is a thing you are doing and stays legible+while you do it.++**The toast counts them separately.** `"5 tasks moved, 15 unchanged, 1 cancelled+excluded"`. Folding the excluded rows into "unchanged" would have been true and useless:+they are not unchanged because the pass considered them and left them, they are unchanged+because the pass never looked. Silent when the plan has no cancelled work, which is most+plans.++### The critical path and the gaps, which do NOT interact++Worth stating because it is the obvious worry and the answer is clean. A path's LENGTH is+the sum of the NODE weights along it, so a three-week wait between an order and its+receive is nobody's working days and adds nothing. The winner is therefore the same before+and after a press, and lengthening a gap can never promote a chain. The only rule a gap can+reach is tie-break 2, the calendar span, and only to separate two chains ALREADY tied on+working days, where it says the chain occupying more of the calendar is the more critical+of the two. That is the right reading and it is the one it has always had: a deliberate gap+is real elapsed time the plan has to live through. v53's B2 asserts it on a fixture with a+five-week preserved gap in it.++That leaves one question rule 2 can actually be asked, and v53b is the bench that asks it:+two chains in one section, ten working days each, one laid back to back and one carrying a+three-week gap the author set. Tied on rule 1, so the answer has to come out of the span,+and the gapped chain wins it at 31 calendar days against 10. Run against the 0.52.0 build+on the SAME fixture, that answer FLIPS after a press: the bidirectional pass closes the+gap, the two chains come out tied on span and on count as well, and rule 4 hands it to the+flush one because its first row is earlier in plan order. So the 0.53.0 behaviour is not+only different, it is the more useful of the two: the chain that really does occupy three+more weeks of the calendar goes on being the one that is lit, instead of being quietly+made identical to its rival by the button next to it.++### What the suites had to be told++Five suites assert the bidirectional behaviour by name, and rewriting them was the largest+part of the round. Two new suites were written for the two changes themselves. v53, 117+assertions on a six-section fixture on port 9051, with a lead-time gap, a three-level+cascade through the observed Independence Day, a chain whose only route runs through the+row the suite cancels, a diamond that reroutes when it is cancelled, and a cancelled row+that is both a violator and a constraint. v53b, 16 assertions on a two-chain fixture on+port 9053, for the calendar-span tie-break described above; it is the one bench in the+family that gives a DIFFERENT answer on the two builds, so it doubles as the A/B.++Every changed assertion, and why:++**v42** (12), the hand-computed schedule on the small bench:++1. *its tooltip says exactly what it will do* -- the sentence changed. Asserted in full+   rather than by keyword, because a button describing the old rule is exactly what this+   round could have shipped.+2. *a successor with slack is pulled IN* -> *is LEFT where the author put it*. The+   behaviour it named is the behaviour that was removed. `2026-01-09/15` -> `2026-02-02/06`.+3. *a successor in conflict is pushed OUT* -> *pushed OUT to the handoff*. Same rule, new+   arithmetic: a3's predecessor a2 no longer moves, so the handoff is a different date.+4. *a five-business-day task laid across a weekend and a holiday still takes five* -> *a+   successor already past its handoff keeps its five business days where they are*. a4 is+   past its minimum and is no longer moved, so it no longer straddles Martin Luther King,+   Jr. Day. That arithmetic is still covered twice, by v45 across Labor Day and v53 across+   the observed Independence Day, and both are PUSHES; a duration check was added here in+   its place so the five days are still asserted.+5. *a milestone with a predecessor sits where its predecessor ends* -> *a milestone that+   sits before its predecessor is pushed onto its end*. Value only: the gate shipped ten+   weeks before its predecessor, so it is still a violation and is still pushed.+6. *every dependency is finish-to-start with no day between them* -> *no dependency is+   violated*. An equality became an inequality, which is the round written as an+   assertion, and a NEW assertion names the two pairs still carrying a gap so the+   weaker rule cannot pass by being weak. Two and not three: this loop asks each row+   about its LATEST predecessor, and c4's latest is c2, which it sits flush against. The+   slack c4 has over c3, the short branch of the diamond, is a per-PAIR fact and is+   asserted as one in v43, which measures every pair separately.+7. toast *6 tasks moved, 12 unchanged* -> *4 tasks moved, 14 unchanged*, plus a new+   assertion that a plan with no cancelled work says nothing about cancelled work.+8. changelog *count* 6 -> 4.+9. changelog *moved* list: a2 and a4 dropped, because they no longer move.+10. markdown *moving 6 item(s)* -> *moving 4 item(s)*, plus two new assertions on the+    server's rewritten sentence.+11. *and changes nothing* (the second-press check) takes a4's new dates.+12. *the schedule is in the sidecar* retargeted from a2, which no longer moves and so+    proves nothing about the sidecar, onto a3, which the pass rewrote. A new assertion+    checks the PRESERVED gap reached the sidecar too.++**v43** (12), the rendered gap in pixels:++1. *a dependent pair starts far apart* reworded: it must now stay apart. A new assertion+   says another pair OVERLAPS, so the suite still has something to close.+2. the hardcoded `NOTADJ = {c3>c4}` became a CLASSIFICATION derived from the dates the+   press produced, and the classification itself is asserted: six pairs pushed onto their+   handoff, three left carrying a gap. Under 0.52.0 all nine were flush, and that+   difference is now the assertion.+3. *a pair that is not adjacent is still drawn apart* now covers all three preserved+   pairs at every unfloored grain, not only c3>c4.+4. NEW: the two gaps the author set are drawn with real white at the day grain.+5. G3 retargeted wholesale from a1>a2, which the old pass pulled flush, onto a2>a3, which+   the new pass pushes flush: five business days across a weekend became two.+6. the MLK straddle half of G3 went with a4's move, replaced by the claim that the+   preserved a1>a2 gap is drawn as exactly 24 day columns of white.+7. *every successor starts ON its latest predecessor end* -> the inequality, and the same+   for its milestone twin.+8. *the bars are still flush* retargeted onto the pushed pair, plus a new assertion that+   the preserved gap is still open after the second press.+9. *and there are real pairs behind that* 7 -> 6, since six pairs are pushed onto their+   handoff where the bidirectional pass forced eight.+10. the 6px-floor clause restructured. The floor still bites, but on a3>a4 at the year+    grain, where a3's two working days come out exactly 6px wide, and that pair is+    PRESERVED now rather than pushed, so the old filter (floored AND adjacent) went+    empty and the existence guard caught it. Both promises are asserted instead of the+    one: a floored predecessor may never open a gap in a pair the pass pushed flush, and+    it may never close one the pass preserved.+11. G6, the longest path, is UNCHANGED, and a note was added saying so on purpose: the+    pass preserves every working-day duration and a path's length is the sum of the node+    weights, so the gaps left standing add nothing and the winner does not move.++**v45** (1), the federal-holiday bench:++1. *the milestone sits where its predecessor ends* -> *sits on or after its predecessor+   end, never before*. s3 ends Mon 14 Sep and the gate shipped on Tue 15 Sep, one working+   day past its handoff; 0.52.0 dragged it back. Two new assertions: the one working day+   survives, and the gate is exactly where it shipped.+2. NEW: the tooltip says it only ever pushes later, asserted in the suite that owns the+   holiday half of the same sentence.++**v48 and big48** (2 each), the busy-ring benches:++1. *every dependency lines up finish-to-start on business days* -> *every dependency is+   honoured*. This fixture spaces every step nine days apart with a three-to-five-day+   duration, so every pair already carries a gap and push-only leaves all of them.+2. *this pass moved most of it* recalibrated, 25 -> 15 of 40 and 120 -> 60 of 160. What+   moves on this fixture now is the off-day normalisation alone: 22 rows and 96 rows. The+   assertion still exists to say the ring covered real work rather than a no-op, so the+   number was recalibrated and not dropped.++Everything else in the standing battery is untouched and green, v49's and v50's+critical-path sets included: no fixture in them carries a cancelled row, and the longest+path does not read gaps.+ ## Build notes, 2026-08-21 (0.52.0, a fifth status and three things that were wrong)  Four changes, one round. Two of them are features Drew asked for, one is a readability
gantt.html+164−30
@@ -551,6 +551,17 @@ body.col-resizing { cursor: col-resize; user-select: none; } .dep-svg path { fill: none; stroke: var(--dep-stroke, var(--accent)); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; } .dep-svg polygon { fill: var(--dep-stroke, var(--accent)); opacity: 0.5; transition: opacity 0.15s; } .dep-svg.hidden path, .dep-svg.hidden polygon { opacity: 0; }+/* CANCELLED, on the arrows. 0.53.0 takes cancelled work out of the scheduling graph, so+   an arrow into or out of it is a link the schedule no longer walks. The link is still+   DRAWN, because deleting it from the picture would lose the reason the row is in the+   plan, and it is dimmed by the SAME 0.42 factor .bar.cancelled takes: 0.5 * 0.42 =+   0.21, so the arrow recedes exactly as far as the bar it points at and the two cannot+   drift apart. EITHER end being cancelled dims the edge, since one live end does not+   revive a link to work nobody will do.+   Placed here, above the crit, pick and armed rules, so all three still win: a cancelled+   row can no longer be on the critical path at all, but an arrow you have PICKED or+   ARMED is a thing you are doing and has to stay legible while you do it. */+.dep-svg path.cancelled, .dep-svg polygon.cancelled { opacity: 0.21; } /* == The critical path ==    The longest run of work in the plan, breathing. Everything here is ADDITIVE: the glow is    carried on box-shadow and filter, which no other state in this app uses on these@@ -1922,7 +1933,7 @@ $('hdr').innerHTML = `       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h6a3 3 0 0 1 3 3v7"/><path d="M14 14l2 3 2-3"/></svg><span class="btn-label">Deps</span></button>   </div>   <div class="plan-ctl">-    <button class="act-btn" id="btnAuto" title="Align all task dates so dependencies line up finish-to-start, skipping weekends and US federal holidays">+    <button class="act-btn" id="btnAuto" title="Align dependencies by pushing later tasks out so each one starts when the work it waits on finishes, skipping weekends and US federal holidays. Never pulls work earlier, so intentional lead-time gaps are preserved and cancelled work is left out">       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h7M4 12h11M4 17h6"/><path d="M17 14l3 3-3 3"/><path d="M20 17h-6"/></svg><span class="btn-label">Auto-schedule</span></button>     <button class="act-btn" id="btnCrit" title="Critical path: light up the longest run of work inside each section. A dependency that crosses sections is left out." aria-pressed="false">       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18l5-5 4 3 7-8"/><circle cx="4" cy="18" r="1.6"/><circle cx="9" cy="13" r="1.6"/><circle cx="13" cy="16" r="1.6"/><circle cx="20" cy="8" r="1.6"/></svg><span class="btn-label" id="critLabel">Critical</span></button>@@ -4281,6 +4292,20 @@ function recolorDepArrows(){     paintDepEdge(el,depStrokeFor(byId[el.getAttribute('data-from')]));   }); }+// Which arrows point at, or come out of, cancelled work. A full walk of the layer+// rather than a toggle driven from the row that changed, and for the same reason+// recolorDepArrows is: the answer is a question about BOTH ends of each edge, so+// un-cancelling one row must not brighten an arrow whose other end is still cancelled.+// One class, so the stylesheet holds the whole treatment and this holds none of it.+function paintCancelledDeps(){+  if(!depSvg)return;+  const byId={};+  items.forEach(r=>{ if(r.id)byId[r.id]=r; });+  const dead=id=>{const r=byId[id];return !!(r&&isCancelled(r));};+  depSvg.querySelectorAll('[data-from]').forEach(el=>{+    el.classList.toggle('cancelled',dead(el.getAttribute('data-from'))||dead(el.getAttribute('data-to')));+  });+} function drawDeps(){   depSvg.innerHTML='';let maxY=0;   // One measurement of the bars for the whole layer, not one per arrow.@@ -4310,10 +4335,11 @@ function drawDeps(){       a.setAttribute('data-from',depId);a.setAttribute('data-to',r.id);paintDepEdge(a,stroke);depSvg.appendChild(a);       maxY=Math.max(maxY,y1,y2);});});   depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));-  // The redraw threw away the classes, so the pick, the armed state and the critical-  // path are put back from the state that outlives the DOM.+  // 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.   paintDepSel();   paintCritDeps();+  paintCancelledDeps(); } function wireDepRedraw(){ setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);@@ -6255,14 +6281,34 @@ function shiftPlanTo(newStartISO, via){ // // The graph ignores phases entirely. A dependency crosses lanes freely and always // has; a phase's displayed range is derived from the rows in it, so it follows.-function depGraph(){-  const nodes=[],byId=new Map(),byRow=new Map();+//+// CANCELLED WORK IS NOT IN THE SCHEDULING GRAPH. 0.53.0. A cancelled row is work that+// will not happen, so nothing can sensibly wait on it and it cannot sensibly be the+// longest run of anything: it is dropped as a NODE, which drops its edges with it and+// BREAKS any chain that ran through it, because a path through work nobody will do is+// not a path. The `deps` arrays are untouched on the rows themselves, so un-cancelling+// puts the node, its edges and every chain through it straight back.+//+// It is a MODE rather than the only reading, because the two questions are different.+// Auto-schedule and the critical path are asking what the plan will DO, so they read+// the LIVE graph; the analytics Dependencies card is describing what the plan SAYS, so+// it reads the whole one and its counts are unmoved by 0.53.0. schedGraph() below is+// the single call the two scheduling features share, which is what still makes it+// impossible for them to disagree about what the plan's edges are.+//+// `cancelled` is the rows the live reading dropped that would otherwise have been+// nodes, in items order. It is what the Auto-schedule toast counts.+function depGraph(skipCancelled){+  const nodes=[],byId=new Map(),byRow=new Map(),cancelled=[];   items.forEach(r=>{     if(r.type==='group')return;     let n=null;     if(r.id&&r.start&&r.end)n={r,id:r.id,ms:false,start:r.start,end:r.end};     else if(r.type==='milestone'&&r.date)n={r,id:null,ms:true,start:r.date,end:r.date};     if(!n)return;+    // Tested AFTER the dated check, so the count is of rows that were really in the+    // graph and not of every cancelled row in the plan.+    if(skipCancelled&&isCancelled(r)){cancelled.push(r);return;}     n.preds=[];n.succs=[];     nodes.push(n);byRow.set(r,n);     if(n.id&&!byId.has(n.id))byId.set(n.id,n);@@ -6280,8 +6326,10 @@ function depGraph(){       n.preds.push(p);p.succs.push(n);     });   });-  return {nodes,byId,byRow,edges:nodes.reduce((a,n)=>a+n.preds.length,0)};+  return {nodes,byId,byRow,cancelled,edges:nodes.reduce((a,n)=>a+n.preds.length,0)}; }+// The one graph Auto-schedule and the critical path both read.+function schedGraph(){ return depGraph(true); } // Kahn, so the order is a real topological order and what is left over when it // stops is exactly the set of nodes on or downstream of a cycle. Deterministic: // the queue is seeded and refilled in `items` order, which is the order on screen.@@ -6302,7 +6350,11 @@ function topoOrder(g){   const stuck=g.nodes.filter(n=>!out.includes(n));   return {order:out,stuck}; }-const planHasDeps=()=>depGraph().edges>0;+// What makes the two plan buttons live. It asks the LIVE graph, so a plan whose only+// dependencies run through cancelled work has nothing left for either button to say+// and both of them go quiet, which is the same answer they give a plan with no+// dependencies at all.+const planHasDeps=()=>schedGraph().edges>0;  // ── Busy buttons ───────────────────────────────────────────────────────────── //@@ -6368,14 +6420,44 @@ function endBusy(h,settle){  // ── Auto-schedule ──────────────────────────────────────────────────────────── //-// One pass over the whole plan: every task with dependencies starts BACK TO BACK-// with its latest predecessor, and no task starts or ends on a Saturday or a-// Sunday. Both directions: slack is pulled in and a conflict is pushed out,-// because a schedule that only ever moved work later would drift the plan out one-// round at a time and never bring it back.+// One pass over the whole plan: no task starts before the work it waits on has+// finished, and no task starts or ends on a Saturday, a Sunday or a federal holiday.+//+// PUSH ONLY. 0.53.0, and it is the change that matters most here. Through 0.52.0 this+// went both ways: slack was pulled IN so every successor sat exactly back to back with+// its predecessor, and a conflict was pushed OUT. Drew's report is that a real plan has+// gaps somebody meant: an order is placed, and the receive that waits on it is three+// weeks later because that is the lead time, not because nobody tidied the dates. The+// old pass closed every one of those and there was no way to keep one.+//+// So the finish-to-start point is now a MINIMUM rather than an answer. For every task+// with live predecessors:+//+//   * its minimum start is the latest predecessor end, moved to a working day (the+//     0.43.0 zero-lag convention: successor.start IS predecessor.end, which is what+//     draws the two bars touching);+//   * if it already starts on or after that minimum, it is LEFT WHERE IT IS, and the+//     gap between the two, however wide, survives the press untouched;+//   * if it starts before it, it is PUSHED later to exactly that minimum, keeping its+//     working-day duration.+//+// Pushes still propagate: a pushed task's successors are measured against its NEW end,+// so a violation three links up moves the whole tail out. Nothing is ever pulled in, so+// the pass can only ever move work later and the plan cannot drift backwards over it.+// It is a fixed point after one press, because a task the pass has just put at its+// minimum is by definition no longer before it: the second press moves nothing.+//+// CANCELLED WORK IS NOT IN THIS PASS AT ALL. schedGraph() has already dropped it, which+// does both halves of what that means in one stroke: a cancelled row is never moved,+// and it imposes NO constraint on the rows that wait on it, so a successor whose only+// predecessors are cancelled is an anchor and keeps the dates it has. Its own dates+// freeze exactly as set, violating or not, because a date on work that will not happen+// is a record of what was planned and not a thing to correct. // // BACK TO BACK is a statement about the DRAWN chart, and the chart is what a reader-// is looking at when they say two tasks have a gap between them. A bar runs from+// is looking at when they say two tasks have a gap between them. It is now what a+// PUSHED pair lands on rather than what every pair is forced to; a pair the author+// spaced deliberately keeps its column of white and that column is the point. A bar runs from // toX(start) to toX(end), so its right edge is the LEADING edge of the end date's // cell: the end date is the boundary the work finishes on, not a cell the bar // paints. The successor's bar therefore meets the predecessor's exactly when the@@ -6389,9 +6471,17 @@ function endBusy(h,settle){ // is the identity there; it earns its place for a predecessor left where it was by // a cycle, whose end may be a Saturday. //-// A task with no predecessors is an ANCHOR and keeps the start it has. That is the+// A task with no LIVE predecessors is an ANCHOR and keeps the start it has. That is the // only thing in the plan this cannot derive, so it is the only thing it does not-// touch, beyond nudging a start that lands on a weekend to the Monday.+// touch, beyond nudging a start that lands on a weekend to the Monday. Since 0.53.0 a+// task whose predecessors are all cancelled is an anchor by the same rule, and since+// 0.53.0 every other task is one too whenever it already clears its minimum.+//+// THE OFF-DAY NUDGE IS LATER-ONLY as well, and always was: nextBiz walks FORWARD off a+// Saturday or a holiday and never back. The end is still derived rather than nudged,+// which is the one place a date can come out earlier than it went in: a Monday-to-+// Saturday task is five working days, so it is redrawn Monday to Friday. No work moved+// earlier there, a non-working tail was dropped, and that arithmetic is untouched. // // DURATION is preserved in BUSINESS days, not in calendar days, which is the whole // point of the weekend rule: a five-day task that would finish on the Saturday@@ -6400,25 +6490,32 @@ function endBusy(h,settle){ // all (a Saturday-to-Sunday task) is treated as one business day, since zero would // give it an end before its start. function autoScheduleCompute(){-  const g=depGraph();+  const g=schedGraph();   const {order,stuck}=topoOrder(g);   const dur=new Map();   g.nodes.forEach(n=>{ dur.set(n, n.ms?0:Math.max(1,bizBetween(n.start,n.end))); });   const plan=new Map();   order.forEach(n=>{-    let s;+    // Where the row sits now, off a non-working day if it landed on one. This is the+    // candidate the push-only rule defends, and moving it forward off a Saturday is+    // the only thing that can happen to it before the comparison.+    const cur=nextBiz(n.start);+    let s=cur;     if(n.preds.length){-      // The LATEST predecessor end, taken as the successor's start so the two bars-      // meet. A predecessor left unscheduled because it is in a cycle contributes-      // the date it already has, which is the one case that can be a weekend.+      // The LATEST predecessor end, which is the EARLIEST this row may start so the+      // two bars at worst meet. A predecessor left unscheduled because it is in a+      // cycle contributes the date it already has, which is the one case that can be+      // a weekend.       let latest=null;       n.preds.forEach(p=>{         const pe=plan.has(p)?plan.get(p).end:p.end;         if(latest===null||dayNum(pe)>dayNum(latest))latest=pe;       });-      s=nextBiz(latest);-    }else{-      s=nextBiz(n.start);+      const min=nextBiz(latest);+      // The push-only rule, in one comparison: at or past the minimum is a gap the+      // author put there and is kept; before it is an overlap and is closed by moving+      // THIS row out, never by pulling the predecessor in.+      if(dayNum(cur)<dayNum(min))s=min;     }     plan.set(n,{start:s,end:n.ms?s:addBiz(s,dur.get(n))});   });@@ -6430,8 +6527,14 @@ function autoScheduleCompute(){     if(n.ms){ if(v.start!==n.r.date)moves.push({n,date:v.start}); }     else if(v.start!==n.r.start||v.end!==n.r.end)moves.push({n,start:v.start,end:v.end});   });-  return {moves,stuck,total:g.nodes.length,scheduled:order.length};-}+  // `total` is the LIVE plan: cancelled rows were never nodes, so they are neither+  // moved nor unchanged and are reported as their own third number instead of being+  // quietly folded into the count of work that was already aligned.+  return {moves,stuck,total:g.nodes.length,scheduled:order.length,cancelled:g.cancelled.length};+}+// The tail every Auto-schedule line ends in, so the two branches cannot word it+// differently. Silent when the plan has no cancelled work, which is most plans.+function excludedNote(res){ return res.cancelled?', '+res.cancelled+' cancelled excluded':''; } // The result of the pass, applied as ONE undo entry and ONE changelog event. The // snapshot is the same group snapshot a plan shift takes, so Ctrl+Z puts every // date back in a single press; `via` is what collapses the diff into one line.@@ -6440,7 +6543,7 @@ function runAutoSchedule(){   if(!res.moves.length){     // Nothing moved, so nothing is recorded. A second click on an already aligned     // plan is a no-op, not a changelog line saying zero things happened.-    flash(saveInd,'Auto-scheduled: 0 tasks moved, '+res.total+' already aligned',4000);+    flash(saveInd,'Auto-scheduled: 0 tasks moved, '+res.total+' already aligned'+excludedNote(res),4000);     return res;   }   snapshotGroup(items.filter(r=>r.id).map(r=>r.id));@@ -6455,7 +6558,7 @@ function runAutoSchedule(){   renderTable();   scheduleSave('auto-schedule');   const n=res.moves.length,un=res.total-n;-  let msg='Auto-scheduled: '+n+' task'+(n===1?'':'s')+' moved, '+un+' unchanged';+  let msg='Auto-scheduled: '+n+' task'+(n===1?'':'s')+' moved, '+un+' unchanged'+excludedNote(res);   if(res.stuck.length)msg+='. '+res.stuck.length+' left alone: '+cycleNames(res.stuck);   undoToast.innerHTML=esc(msg)+' &middot; <kbd>Ctrl+Z</kbd>';   showToast(undoToast,res.stuck.length?7000:4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});@@ -6501,8 +6604,16 @@ function cycleNames(stuck){ // run of work in this plan. Precisely: // //   * The dependency graph is a DAG. Tasks and milestones are NODES; a `deps` entry-//     between two dated rows is an EDGE. (Same depGraph() Auto-schedule reads, so the+//     between two dated rows is an EDGE. (Same schedGraph() Auto-schedule reads, so the //     two features can still never disagree about what the plan's edges are.)+//   * CANCELLED WORK IS NOT A NODE, from 0.53.0. It can never glow, it carries no+//     weight, and a chain that ran through it is BROKEN at that row: the node is absent,+//     so its predecessors and its successors are no longer joined by way of it and the+//     DP has to find its longest path some other way, or settle for the two stumps. A+//     section whose rows depend on nothing inside it falls back to its longest single+//     ROW, and that fallback ignores cancelled rows for the same reason: they are not in+//     the graph to be considered. Un-cancelling puts the node, its edges and every path+//     through it back exactly as they were, because nothing was deleted from the data. //   * A node WEIGHS its own duration in WORKING days: inclusive business days with //     weekends and observed federal holidays taken out, the same isOffDay arithmetic //     0.45.0 put behind every other count in this app. A milestone is an instant, so@@ -6547,6 +6658,16 @@ function cycleNames(stuck){ // // When two disjoint paths are still tied after 1-3, rule 4 picks exactly one, because // Drew asked for THE critical path and two of them is not an answer.+//+// GAPS DO NOT ADD WEIGHT, and 0.53.0's push-only Auto-schedule leaves a lot more of them+// standing than 0.52.0 did, so it is worth saying plainly what that does and does not+// change here. Rule 1 is a sum of NODE weights: a three-week wait between an order and+// its receive is nobody's working days and adds nothing to either path. So the winner is+// the same before and after a press, and lengthening a gap can never promote a chain.+// Rule 2 is the only rule a gap can reach, and only to separate two chains ALREADY tied+// on working days, where it says the one that occupies more of the calendar is the more+// critical of the two. That is the right reading and it is the one it always had: a+// deliberate gap is real elapsed time the plan has to live through. function nodeDays(n){ return n.ms?0:Math.max(1,bizBetween(n.start,n.end)); } // True when path a beats path b. Both are DP records; `b` is null on the first // candidate, which a always beats.@@ -6602,7 +6723,7 @@ function longestPath(order,members,idx){ // One path per section, unioned into the one set the painters read. The per-path detail // is kept alongside it for the message the button flashes. function criticalSet(){-  const g=depGraph();+  const g=schedGraph();   const {order,stuck}=topoOrder(g);   const idx=new Map();   g.nodes.forEach((n,i)=>idx.set(n,i));@@ -7551,6 +7672,10 @@ function syncCancelMark(r){   [r._lDiv,r._bDiv,r._msEl,r._msLine].forEach(el=>{ if(el)el.classList.toggle('cancelled',on); });   const be=r.id&&barEls[r.id];   if(be&&be.bar)be.bar.classList.toggle('cancelled',on);+  // The arrows too, since 0.53.0. Nothing about a status change moves a pixel, so+  // there is no geometry event to trigger a redraw and the layer would otherwise keep+  // the old brightness until the next resize.+  paintCancelledDeps(); }  function distinctOwners(){@@ -9048,6 +9173,15 @@ function setView(v){ // Both are statements about the dependency graph, so both are meaningless without // one and both say so in the same way rather than failing quietly when pressed. The // state is re-read whenever the graph can have changed, which is every save.+//+// The live tooltip is a CONSTANT, and the markup carries the same words so the button+// reads correctly before the first sync as well as after it. It was written out twice+// through 0.52.0 and the two copies could drift; 0.53.0 rewrote the sentence and that is+// exactly the kind of edit that would have left one of them behind, so there is one+// string now and the markup is checked against it below.+const AUTO_TITLE='Align dependencies by pushing later tasks out so each one starts when '+  +'the work it waits on finishes, skipping weekends and US federal holidays. Never pulls '+  +'work earlier, so intentional lead-time gaps are preserved and cancelled work is left out'; function syncPlanCtl(){   const has=planHasDeps();   const a=$('btnAuto'), c=$('btnCrit');@@ -9057,7 +9191,7 @@ function syncPlanCtl(){     // 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'+    a.title=has?AUTO_TITLE                :'Auto-schedule needs dependencies: nothing in this plan waits on anything yet';   }   if(c){
package.json+3−2
@@ -2,8 +2,8 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.52.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, meaning the longest run of work inside every section at once. 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. Five statuses, including cancelled, which keeps 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.",+  "version": "0.53.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. Five statuses, including cancelled, which keeps 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": {     "adom-gantt": "server.js"@@ -30,6 +30,7 @@     "what are my tasks and deadlines",     "auto-schedule this plan",     "line the dependencies up finish to start",+    "align the plan without closing my lead-time gaps",     "what is the critical path",     "how many hours is each person carrying",     "show me the project analytics",
page.json+3−2
@@ -2,8 +2,8 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.52.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, meaning the longest run of work inside every section at once. 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. Five statuses, including cancelled, which keeps 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.",+  "version": "0.53.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. Five statuses, including cancelled, which keeps 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": {     "adom-gantt": "server.js"@@ -30,6 +30,7 @@     "what are my tasks and deadlines",     "auto-schedule this plan",     "line the dependencies up finish to start",+    "align the plan without closing my lead-time gaps",     "what is the critical path",     "how many hours is each person carrying",     "show me the project analytics",
server.js+7−1
@@ -714,7 +714,13 @@ function describeEvent(e) {   if (action === 'auto-schedule') {     const names = Array.isArray(e.moved) && e.moved.length       ? `: ${e.moved.join(', ')}${e.count > e.moved.length ? `, and ${e.count - e.moved.length} more` : ''}` : '';-    return `${who} auto-scheduled the plan, moving ${e.count || 0} item(s) so every dependency lines up finish-to-start on business days${names}. Tasks with no dependencies kept their start; nothing else about them changed${via}`;+    // 0.53.0 rewrote this sentence with the rule it describes. The pass pushes LATER+    // only, so it no longer forces every dependency onto its handoff date: it closes an+    // overlap and leaves a gap the author set alone. Saying "lines up finish-to-start"+    // would now be describing the 0.52.0 pass to an agent reading the log of a 0.53.0+    // one. Cancelled work is named for the same reason: a reader comparing the count to+    // the plan needs to know what was never in the pass.+    return `${who} auto-scheduled the plan, moving ${e.count || 0} item(s) LATER so no dependency starts before the work it waits on finishes, on business days${names}. Nothing was moved earlier, so gaps that were already there were kept; tasks with no dependencies and cancelled tasks kept their dates${via}`;   }   if (action === 'plan.schedule') {     const ov = Array.isArray(e.overrides) ? e.overrides.length : 0;