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.50.0: no resize edges in dependency mode, batch delete from the multi-select panel, Critical is a two-state toggle
6 files changed
+369−97
README.md+4−4@@ -6,9 +6,9 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **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. -**Critical path, in two modes.** **Critical** lights up the LONGEST run of work in the plan, start to end: 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 through the plan 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. Nothing is rescheduled: it reads the dates the plan has right now, independently of Auto-schedule. -One button, three states, cycled the way the milestone style is: **off**, then **Critical: plan**, the single longest path across the whole plan, then **Critical: sections**, one longest path inside EVERY section at once. In per-section mode 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 mode is remembered across reloads.+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. **Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their four status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it. @@ -46,7 +46,7 @@ One button, three states, cycled the way the milestone style is: **off**, then * **The task editor remembers where you put it.** The panel that opens on a bar is a compact 256px square, draggable by the strip at its top. It is meant to be glanced at rather than filled in: the row's name across the top, the two dates, the note, the dependency list (the one part that flexes, and the one part that scrolls) and the buttons, at desktop density, so it sits over as little of the chart it is editing as it can. The full **Edit item** panel, with the name, owner, status and provenance fields, is the one you fill in and it is unchanged. Drag it once and every panel after it opens in that same spot on screen, whatever row it belongs to and however far the chart has been scrolled, until the page is reloaded. Nothing is saved to the plan; it is a property of the sitting, and a refresh restores the automatic placement beside the bar. -**Multi-select.** Ctrl+click to toggle individual rows, Shift+click for range select (Windows Explorer style), in either lens. Tasks and milestones both, since both have a permanent id to be named by; lane headers do not join. The batch editor applies relative date shifts across the selected tasks without flattening dates, and says how many milestones it is leaving alone: a milestone has one date and no duration, so shifting its end has no meaning.+**Multi-select.** Ctrl+click to toggle individual rows, Shift+click for range select (Windows Explorer style), in either lens. Tasks and milestones both, since both have a permanent id to be named by; lane headers do not join. The batch editor applies relative date shifts across the selected tasks without flattening dates, and says how many milestones it is leaving alone: a milestone has one date and no duration, so shifting its end has no meaning. Its **Delete** removes the whole selection, milestones included, in one press and one Ctrl+Z: the same path the chart's armed Delete key uses, so it is one undo entry, one changelog batch, dependencies on the departed rows dropped and their ids retired either way. **Vertical reorder.** Drag bars or labels up/down to reorder rows. Drop indicator shows insertion point. Multi-select reorder moves all selected items together. @@ -265,7 +265,7 @@ 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. Cycles off / whole plan / one per section, and it recomputes as the plan changes |+| Critical | Light up the longest run of work inside every section. 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+3−2@@ -220,7 +220,7 @@ rather than a category. - **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, THREE states, cycled** the way the milestone style is, with the label and the tooltip naming the state you are in: off, then **Critical: plan** (one path, the longest in the whole plan), then **Critical: sections** (one longest path inside EVERY section at once, all lit together). Per section the 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, in both modes, and the mode is a saved preference beside the theme and the milestone style. 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+- **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 - **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@@ -230,7 +230,8 @@ rather than a category. - **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field - **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart - **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, at which point that tab stops autosaving and offers a reload rather than clobbering the other person-- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. **The Deps button glows** in the accent, breathing on the same 2.6s the critical path does, for exactly as long as the mode is on, and its tooltip carries the instruction in and out of the mode: "Adding dependencies. Click the task that comes first, then the one that waits on it. Click an arrow to pick it. Esc leaves the mode." There is no info bar under the legend any more, so entering and leaving the mode moves nothing on the page. **Creating a row leaves the mode first**: Shift+N and the New button both exit it (armed source dropped, wash cleared, picked arrow released, glow off) and then open the create panel, so you never end up typing a name over a chart that is still armed for a pairing. **The row under the pointer lights up**, name cell and chart band together, washed in that row's own phase colour, so a 20px bar and the name a pane away read as one row and you can see which task you are about to click. One row at a time, and it goes the instant the mode does. The armed first task wears the normal selection outline so you can see what the pair is waiting on. The pair resets and the mode stays on, so a run of dependencies is one sitting. Clicking a pair that ALREADY has a dependency REMOVES it, which is the same removal as the editor's ×: one undo entry, one changelog event. Refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. **Clicking an arrow picks it**, in this mode only, and **Delete** twice removes the dependency it stands for (the first press arms and says so, the second commits; any other key, a click or Esc withdraws the question). Outside the mode the arrows take no clicks at all. Esc lets go of a picked arrow, then a half-made pick, then leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder. Ctrl+Z undoes the last add or removal without leaving the mode+- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. **The Deps button glows** in the accent, breathing on the same 2.6s the critical path does, for exactly as long as the mode is on, and its tooltip carries the instruction in and out of the mode: "Adding dependencies. Click the task that comes first, then the one that waits on it. Click an arrow to pick it. Esc leaves the mode." There is no info bar under the legend any more, so entering and leaving the mode moves nothing on the page. **Creating a row leaves the mode first**: Shift+N and the New button both exit it (armed source dropped, wash cleared, picked arrow released, glow off) and then open the create panel, so you never end up typing a name over a chart that is still armed for a pairing. **The row under the pointer lights up**, name cell and chart band together, washed in that row's own phase colour, so a 20px bar and the name a pane away read as one row and you can see which task you are about to click. One row at a time, and it goes the instant the mode does. The armed first task wears the normal selection outline so you can see what the pair is waiting on. The pair resets and the mode stays on, so a run of dependencies is one sitting. Clicking a pair that ALREADY has a dependency REMOVES it, which is the same removal as the editor's ×: one undo entry, one changelog event. Refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. **Clicking an arrow picks it**, in this mode only, and **Delete** twice removes the dependency it stands for (the first press arms and says so, the second commits; any other key, a click or Esc withdraws the question). Outside the mode the arrows take no clicks at all. Esc lets go of a picked arrow, then a half-made pick, then leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder, and **no resize edges**. The two 8px drag handles are hidden outright in the mode, out of paint and out of hit-testing together, so the whole face of a bar is one clean pick target: on a one-day bar, which the app draws at a 6px floor, the handles covered the entire bar twice over and every aim at it returned a resize cursor instead of the bar. Leaving the mode restores them exactly, by any of the ways out. Milestones never had an edge handle, so the mode leaves their hit strip and its pointer cursor untouched. Ctrl+Z undoes the last add or removal without leaving the mode+- **The batch panel's Delete** removes the WHOLE selection, milestones included, in one press. It is the compact panels' danger button in the compact panels' place: a muted ghost, right-aligned by `margin-left:auto` so it is clear of Apply and Cancel, red on hover, last in the panel's Tab walk. It lands on the same `deleteRows` the chart's armed Delete key and the table's Delete use, so it is one `multi` undo entry restoring every row where it stood, one changelog batch, dependencies on the departed rows dropped and their uids retired, whichever hand did it; only the `via` differs (`batch-delete` against `chart-delete`). One press and no arming, unlike the key: the key acts on a selection that may be entirely off screen and asks twice, where a button inside the panel that IS the selection made visible has already been aimed. The panel's headline counts the TASKS the date fields will move and says how many milestones it is leaving alone; the Delete's tooltip names the total it will actually remove, so the two counts cannot be read as one - **Copy IDs**: with rows selected, the table's tools row and the chart's selection chip (and the batch panel) copy the selected items' stable ids, one per line as `id<TAB>name`, for pasting into an AI chat to request batch edits. Ctrl+C does the same when nothing is focused and no text is selected. If the clipboard is refused the ids appear in a selectable box rather than failing silently - **Row multi-select in the table**: ctrl-click toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo. A selection with GAPS in it groups itself up on the first Alt+Arrow press rather than moving: the selected rows close up into one contiguous block at the topmost one's slot - **Arrows steer clear of the bars.** A dependency leaves the source bar's END sideways, drops, and arrives at the next bar's START sideways again, with rounded elbows, so which end is the cause is always readable. It routes AROUND the bars in between rather than through them: the drop moves to the nearest clear channel, and a long or backward route runs along the empty lane between two rows rather than across a task that has nothing to do with it. Where a bar lies clean across the only gap, the arrow goes round the end of it. Nothing to configure; it is re-decided on every redraw, including live during a drag
docs/BUILD-NOTES.md+189@@ -3,6 +3,195 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-20 (0.50.0, no resize edges in dependency mode, batch delete, Critical is a toggle)++Three changes from Drew, all 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.++---++### 1. No resize edges while dependency mode is on++> don't show task bar resize arrows while in deps mode because it makes it difficult to+> see esp when bars are only 1-day width++Reproduced first, because the numbers turn out to be worse than the report suggests. A+bar carries two `.drag-handle` children, 8px wide each, at `left:-2px` and `right:-2px`.+A task whose start and end are the same day has zero span, so `updateBar` draws it at its+6px floor. Two 8px boxes overhanging a 6px bar by 2px at either end cover the whole face+TWICE and 2px of clear air besides, and the right-hand one is appended second so it wins+the hit test everywhere.++`elementFromPoint` at the left edge, the middle and the right edge of a one-day bar, on+0.49.0, out of the mode (`g50/ab50.mjs`, recorded against both builds):++```+{"id":"d1","l":["drag-handle right",false,"ew-resize"],+ "m":["drag-handle right",false,"ew-resize"],+ "r":["drag-handle right",false,"ew-resize"]}+```++There is no pixel of a one-day bar that IS the bar. Every aim at it returns a resize+cursor, and in a mode that has no resize the cursor is a picture of something that cannot+happen: `wireBarDrags` has returned early on `depMode` since the mode existed.++The fix is one declaration:++```css+body.dep-mode .bar .drag-handle { display: none; }+```++`display:none` rather than an opacity wash or a `pointer-events` override because it is+the one thing that takes them out of PAINT and out of HIT-TESTING together. Same three+aims, same bar, in the mode on 0.50.0:++```+at 0 of the one-day bar the pointer finds the BAR [true,"pointer"]+at 0.25 ... [true,"pointer"]+at 0.5 ... [true,"pointer"]+at 0.75 ... [true,"pointer"]+at 1 ... [true,"pointer"]+```++Counted over the whole page and over what a pointer can actually reach: 18 resize cursors+out of the mode (nine bars, two handles each), 0 in it.++The 2px of overhang stops being clickable with them. That is the pick target becoming+exactly the bar rather than slightly more than it, and it is the only behavioural+consequence beyond the affordance going away.++**The JS side.** `wireBarDrags` already refused to open a resize, a move or a reorder in+the mode, and `appIsIdle` already refuses Shift+D while any of them is live, so the rule+was enforced at both doors. It was NOT enforced at the state change itself, so a mode+entered with a drag already open would have left that drag running underneath it. Two+small extractions make it hold there too: `endBarDrag()` (banked and closed, since the+pointer has already written those dates into the model) and `cancelReorder()` (abandoned,+since nothing has moved in `items[]` until the drop), both lifted verbatim out of the+`mouseup` handler and Escape's reorder rung respectively so those paths are unchanged,+and both called from `setDepMode(true)` along with dropping any undecided `gesture`. This+is defence in depth rather than a live bug fix, and it is written down as such at the+call site: it is what stops a future third way into the mode reintroducing the hole.++**Milestones.** Nothing to do. A milestone is a `.ms-diamond` with `pointer-events:none`+and a transparent `.ms-hit` strip carrying `cursor:pointer`. There is no edge handle and+never was one, so the mode leaves them exactly as it found them. v50's B7 asserts that in+and out of the mode and finds them byte for byte identical, and asserts that a click on+one in the mode still says why it cannot be a dependency.++---++### 2. Batch delete from the multi-select panel++The batch panel could shift a selection's dates and copy its ids and could not remove it.+The chart's Delete key could, but only as a two-press armed gesture aimed at nothing in+particular, and only from the chart.++The button is the compact panels' `de-btn danger` in the compact panels' place, which is+already right-aligned by `margin-left:auto` inside `.de-actions`: Apply, Cancel, then a+muted ghost 90px clear of Cancel and 153px clear of Apply, red on hover, last in the+panel's Tab walk. It calls `deleteRows`, which is the same function the chart's armed+Delete and the table's Delete key call, so everything downstream is shared by+construction: one `multi` undo entry with a step per row, one changelog batch,+dependencies on the departed rows dropped, uids retired, and the toast that names the+count. Only `via` differs, `batch-delete` against `chart-delete`, so the log still says+which hand did it.++One press, no arming, unlike the key. The key acts on a selection that may be entirely+off screen, so a single press is too cheap for what it does; a button inside the panel+that IS the selection made visible has already been aimed, which is the same argument the+single-row panels' Delete has run on since 0.37.0. Ctrl+Z is the safety net either way.++**The one thing that needed deciding** is that the panel's headline counts TASKS, because+the date fields above it are a task-only edit, and says how many milestones it is leaving+alone. Delete is not a date edit: it takes the selection, milestones included. "3 tasks+selected, 1 milestone unchanged" over a button that removes four rows is a trap, so the+tooltip names the total out loud and says the milestones are in it:++```+Delete these 3 rows from the plan. Undoable with Ctrl+Z+Delete all 2 rows selected, the 1 milestone included. Undoable with Ctrl+Z+```++**Found while testing, not fixed, not this round's doing.** The batch panel's APPLY has+always needed one Ctrl+Z per row: `snapshotMany` pushes one `edit` entry per id where the+delete path pushes a single `multi`. The A/B against 0.49.0 on the same fixture is+identical, so it is pre-existing and out of scope here; v50's C6 records the granularity+rather than asserting it away, and the new button deliberately does not copy it.++---++### 3. Critical is a two-state toggle, per section only++0.49.0 shipped three states cycled on one button: off, one path across the whole plan,+one path per section. Drew dropped the plan-wide one. On a real plan it lights a single+section's chain and leaves every other section dark, which is a narrower reading than the+per-section answer that already contains it, and two states of a toggle is one more than+the question has.++What went:++- `CRIT_MODES` is `['off','phase']`.+- The unreachable `else` branch in `criticalSet` (the one call to `longestPath` with a+ null member set) is gone, and `criticalSet` no longer takes a mode.+- `critMessage`'s plan-wide branch is gone.+- `CRIT_BTN` becomes `CRIT_TITLE`, a plain mode-to-tooltip map, because the label no+ longer varies. `syncCritBtn` is deleted with it, along with its three call sites; the+ `#critLabel` span stays in the markup with its constant word.+- `cycleCritical` becomes `toggleCritical`.+- `header #btnCrit { min-width: 158px; justify-content: center; }` and its 36px twin+ inside the max-width:1620px block are gone. Both existed only to reserve room for the+ longest of three labels. The button shrink-wraps to its own content now (74px at 1920,+ against 158px pinned) and takes its padding, its type and its minimum from the same+ `.act-btn` rule `#btnDeps` and `#btnAuto` do, at both widths. Since the label no longer+ changes, its neighbour's left edge is the same integer before, during and after a press.++**Migration.** `clampCritModePref` maps a stored `'global'` to `'phase'` before the+ordinary clamp runs. That reader had the glow ON, so they get the glow; the state closest+to the one they chose is the surviving lit one, not off. Anything else hand-edited or+stale still lands on off, which is the clamp this has always had.++---++### Suites retargeted for the removed mode++Four suites asserted the plan-wide answer, because through 0.49.0 one press of the button+produced it. Each was re-derived against the per-section definition, section by section+against its own fixture, and every change is written down in the suite that carries it:++| Suite | Was | Now |+|---|---|---|+| `g42/v42.mjs` | B lit `c1 c2 c4` + `C5 ship` | the union of six sections' answers, `a1 a2 a3 a4 c1 c2 c4 d1` + `A6 gate` + `C5 ship`, with the cycle still left out and c3 still the dark control |+| `g43/v43.mjs` | G6 lit `c1 c2 c4` after Auto-schedule | the same union, plus an explicit assertion that Phase 4's own chain is still exactly `c1 c2 c4` |+| `g45/v45.mjs` | D4 lit `s1 s2 s3` | `c6 s1 s2 s3`: the Hours section has no dependencies at all, so its longest ROW is its answer |+| `g49/v49.mjs` | A, B, E1, F2, G1, G2, I1 in the plan-wide mode; B was the cycle, the three labels and the pin | A/E1/F2/G1/G2/I1 re-asked in the surviving mode; B retired outright and replaced by v50's A and C. A3's clause about the zero-weight gate NOT being on the path was a plan-wide fact and is now stated the other way up: per section nothing runs past the gate, so it is lit |++`g49/oldnew.mjs` is untouched and still valid: it records the real 0.48.0 build lighting+p1 and p2 and nothing else on the g49 fixture, and the per-section winners it is compared+against did not move.++---++### Verification++- **v50**, the new suite on its own fixture and its own port (9021): 171 pass, 0 fail,+ zero JS errors. The fixture carries two one-day bars, two three-month bars, five+ chained rows and two milestones, so all three changes have something to bite on.+- **The whole standing battery**, green on the source tree and on the installed build.+- **A/B against 0.49.0** on two copies of one fixture, two ports, one read-only+ recording (`g50/abrun.sh`): every bar's handle styles, every bar's hit test at both+ extreme edges and the middle, a real hover, a real resize and its undo, the+ per-section glow (bars, arrows, milestones, computed animation, status line, light+ token) and the single-row panel's controls and Tab stops. **BYTE IDENTICAL**, same+ md5. The only field that had to come out was the arrow's `stroke-width`, which+ `critStroke` animates, so a `getComputedStyle` on it samples wherever the breath+ happens to be and two runs of the SAME build disagree.+- **`mssel` + `msphase`**, the two fixture-coupled legacy suites, run against 0.49.0 and+ 0.50.0 on separate fixtures and ports (`g50/abms50.sh`): **BYTE IDENTICAL**, same md5.+- Live data untouched throughout. Nothing in this round read, wrote or served+ `/home/adom/project/dc-demo-gantt/`.+ ## Build notes, 2026-08-20 (0.49.0, the critical path is the LONGEST path) One change from Drew, client-side. `gantt.html` only. `server.js` did not move: nothing
gantt.html+168−86@@ -330,11 +330,12 @@ header .new-btn { display: flex; align-items: center; gap: 6px; background: var( header .new-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); } header .new-btn svg { width: 13px; height: 13px; } header #btnMsStyle { min-width: 96px; justify-content: center; }-/* Pinned for the same reason the milestone button is: the label names the state, the- three names are not the same width, and .actions to the right of it is right-aligned,- so an unpinned button would drag its neighbours sideways every time it was pressed.- Sized to the longest of the three. */-header #btnCrit { min-width: 158px; justify-content: center; }+/* Critical is NOT pinned. 0.49.0 pinned it to 158px for the reason the milestone button+ is pinned: it named its state in the label, the three names were different widths, and+ .actions to the right of it is right-aligned, so an unpinned button dragged its+ neighbours sideways on every press. 0.50.0 made it a two-state toggle whose label never+ changes, so there is nothing left to reserve room for and 158px was simply a hole in+ the header. It sizes off its own content like every other .act-btn now. */ header #btnTheme { min-width: 56px; justify-content: center; } /* Below this the header is out of room and the project's own name starts to truncate, which is the one thing in here that must not. Shed chrome instead:@@ -353,9 +354,6 @@ header #btnTheme { min-width: 56px; justify-content: center; } alone dragged the Fit button sideways. Theme keeps its word rather than becoming a blank button. */ header #btnMsStyle { min-width: 36px; }- /* Icon-only too, and pinned at the same width, so the plan group stops moving under- the reader once the labels are gone. */- header #btnCrit { min-width: 36px; } } /* Narrower still: an abbreviated subtitle is worse than no subtitle. Drop it and keep the title whole. The full text stays on the header's tooltip. */@@ -621,6 +619,23 @@ body.dep-mode .label-row { transition: opacity 0.15s; } the ring is above the wash by construction rather than by a z-index. */ body.dep-mode .label-row.dep-hover, body.dep-mode .bar-row.dep-hover { background: rgb(var(--dep-hl-rgb) / var(--dep-hl-a)); }+/* No resize edges in the mode. The two handles are 8px each and overhang their bar by+ 2px at either end, so on a one-day bar they ARE the bar: the ew-resize cursor and the+ hover wash sat exactly where the pick target is and hid the thing being aimed at, and+ at that width there was no middle left to aim at instead. They are an affordance for a+ gesture the mode has always refused anyway (wireBarDrags has returned on depMode since+ the mode existed), so in the mode they are a picture of something that cannot happen.++ display:none rather than a wash of opacity or a pointer-events override because it is+ the one declaration that takes them out of PAINT and out of HIT-TESTING together:+ elementFromPoint at either extreme edge of a bar returns the bar itself, there is no+ resize cursor to read, and the whole face is one uninterrupted dependency target. The+ 2px of overhang stops being clickable with them, which is the pick target becoming+ exactly the bar and not slightly more than it.++ Nothing here is stateful and nothing measures a handle, so leaving the mode restores+ the hover behaviour exactly, with no JS to run and nothing to put back. */+body.dep-mode .bar .drag-handle { display: none; } /* ── Tooltip ── */ .tip { display: none; position: fixed; z-index: 99999; background: rgba(22,27,34,0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(0,184,177,0.2); border-radius: 10px; padding: 10px 14px; max-width: 340px; box-shadow: 0 12px 40px rgba(0,0,0,0.55); pointer-events: none; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.5; color: var(--text); }@@ -1465,13 +1480,24 @@ const MS_STYLES = ['line', 'diamond', 'both']; function clampMsStylePref(){ if (MS_STYLES.indexOf(prefs.msStyle) < 0) prefs.msStyle = 'line'; }-// The three critical-path states, in the order the button cycles them, and they are a-// PREFERENCE for exactly the reason the milestone style is one: it is a way of looking-// at the plan rather than a change to it, so a reader who left the longest path lit-// should find it lit when they come back. Same storage, same clamp, same shape: a-// hand-edited or stale localStorage value lands on the first one, which is off.-const CRIT_MODES = ['off', 'global', 'phase'];+// The two critical-path states, and they are a PREFERENCE for exactly the reason the+// milestone style is one: it is a way of looking at the plan rather than a change to it,+// so a reader who left the longest path lit should find it lit when they come back.+//+// 0.49.0 shipped THREE: off, one path for the whole plan, one path per section. 0.50.0+// drops the plan-wide one. On a real plan it answers a question nobody was asking, because+// a plan is read section by section and the plan-wide winner is usually one section's chain+// with the rest of the chart dark; the per-section answer contains it and says more. With+// one live state left there is nothing for a label to name, so the button is a plain toggle+// and its .on treatment carries the whole of the state.+//+// MIGRATION. A browser that used the plan-wide mode has 'global' in its saved prefs, and+// that reader had the glow ON, so they get the glow: 'global' lands on 'phase' rather than+// on off, which is the state closest to the one they chose. Anything else hand-edited or+// stale still lands on off, the same clamp this has always had.+const CRIT_MODES = ['off', 'phase']; function clampCritModePref(){+if (prefs.critMode === 'global') prefs.critMode = 'phase'; if (CRIT_MODES.indexOf(prefs.critMode) < 0) prefs.critMode = 'off'; } // Is anything lit at all. Everything that used to test the old `critOn` flag asks THIS,@@ -1856,7 +1882,7 @@ $('hdr').innerHTML = ` <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"> <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 in the plan, start to end. Press again for one path per section." aria-pressed="false">+ <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> </div> <div class="actions">@@ -2807,9 +2833,7 @@ document.addEventListener('keydown',e=>{ // Innermost first. A native select or datalist popup swallows its own Escape // before this ever runs, which is exactly the first rung of the stack. if(reorderState&&reorderState.active){- reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);- if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});- hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;cancelledDrag=true;+ cancelReorder(); return; } if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }@@ -5051,7 +5075,7 @@ function openBatchEditor(){ <div class="batch-row"><label>shift start</label><input type="number" id="bShiftS" value="0" step="1"> <span>days</span></div> <div class="batch-row"><label>shift end</label><input type="number" id="bShiftE" value="0" step="1"> <span>days</span></div> </div>- <div class="de-actions"><button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button></div>`;+ <div class="de-actions"><button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button><button class="de-btn danger" id="bDelete" title="${esc(batchDeleteTitle(rows.length,others))}">Delete</button></div>`; showEditor(window.innerWidth/2-155,window.innerHeight/2-130,'compact'); $('bApply').addEventListener('click',()=>{@@ -5067,8 +5091,20 @@ function openBatchEditor(){ scheduleSave('batch-editor');closeEditor(); }); $('bClose').addEventListener('click',closeEditor);+ $('bDelete').addEventListener('click',()=>deleteBatchFromPanel()); wireBatchCopy(); }+// The one sentence the batch Delete has to get right. Everything ABOVE it in this panel+// is about the dates, and the dates are a task-only edit, so the headline says how many+// milestones it is leaving alone. Delete is not a date edit: it takes the SELECTION, the+// milestones with it, which is what the same selection has always meant to the chart's+// double-Delete. The two counts would otherwise read as one, so the mixed case names its+// total out loud rather than letting "3 tasks selected" be read as "3 rows will go".+function batchDeleteTitle(total,others){+ const rows=total+(total===1?' row':' rows');+ if(!others)return 'Delete these '+rows+' from the plan. Undoable with Ctrl+Z';+ return 'Delete all '+rows+' selected, the '+others+(others===1?' milestone':' milestones')+' included. Undoable with Ctrl+Z';+} function updateMilestone(r){ if(!r||!r._msEl)return;@@ -5113,6 +5149,31 @@ function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.r // ── Drag (edge-resize, body-move horizontal, reorder vertical) ── let dragState=null; +// Winding a drag up is the same few steps wherever it winds up, so both ways out share+// them. The ordinary way out is the release. The other one is dependency mode arriving+// mid-press: the mousedown guard below refuses to OPEN a resize, a move or a reorder in+// the mode, but a Shift+D pressed with the button already down used to leave whichever+// of them was open running underneath it, so the mode with no resize handles went on+// resizing. Now the mode closes what is open on its way in and the guard covers both+// halves of the same rule.+//+// A horizontal drag banks what it has done rather than throwing it away: the pointer has+// already written those dates into the model and redrawn from them, so dropping the state+// without a save would leave the page and the file disagreeing. A reorder is the opposite,+// because nothing has moved in items[] until the drop, so it is abandoned exactly the way+// Escape abandons one.+function endBarDrag(){+ if(!dragState)return;+ scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));+ dragState=null;document.body.style.cursor='';+}+function cancelReorder(){+ if(!reorderState||!reorderState.active)return;+ reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);+ if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});+ hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;cancelledDrag=true;+}+ function wireBarDrags(){ bars.addEventListener('mousedown',e=>{ if(depMode)return; // no edge resize, no move, no reorder arming@@ -5328,7 +5389,7 @@ document.addEventListener('mouseup',()=>{ gesture=null; if(!reorderState&&!laneDrag)hideDropIndicators(); - if(dragState){scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));dragState=null;document.body.style.cursor='';}+ endBarDrag(); if(reorderState&&reorderState.active){ hideDropIndicators();@@ -5476,7 +5537,25 @@ function setDepMode(on){ depMode=!!on; if(!depMode){setDepPending(null);setDepSel(null);clearDepHover();} document.body.classList.toggle('dep-mode',depMode);- if(depMode){closeEditor();closeMenus();clearSelection();}+ if(depMode){+ // Anything the mouse already had open goes with the old mode. A press still deciding+ // which way it went (gesture) would otherwise resolve into a resize, a move or a+ // reorder on the very next pointer move, INSIDE a mode whose mousedown guard exists to+ // stop exactly that, so it is dropped rather than allowed to resolve; a drag already+ // resolved is banked and closed. cancelledDrag is what makes the release that follows+ // read as the end of a drag rather than as a dependency pick on whatever it happens to+ // finish over, the same clock every other click guard in the file reads.+ //+ // Both of the ways INTO the mode already make this hard to reach: Shift+D is behind+ // appIsIdle, which is false while any of these is live, and the Deps button needs a+ // click of its own, which needs the button released first. That is why it is here and+ // not only at those two doors. The rule is "the mode has no resize, no move and no+ // reorder", and a rule enforced at the state change cannot be walked around by a+ // future third way in.+ if(dragState||gesture||(reorderState&&reorderState.active))cancelledDrag=true;+ endBarDrag();cancelReorder();gesture=null;+ closeEditor();closeMenus();clearSelection();+ } syncDepMode(); } function toggleDepMode(){ setDepMode(!depMode); }@@ -6249,18 +6328,20 @@ function cycleNames(stuck){ // exactly as before. Rows on or downstream of a cycle are left out, because there is // no topological order through them; they are reported the way they always were. //-// TWO MODES, because a plan is read at two altitudes:+// ONE PATH PER SECTION, each computed inside that section's own subgraph: the nodes are+// that section's rows and the edges are the `deps` whose two ends are BOTH in it. A+// dependency that crosses sections is deliberately not an edge here and can never light+// up, because the question being asked is "what is the longest run of work inside this+// section" and a chain that leaves the section is not an answer to it. A section whose+// rows depend on nothing inside it therefore has no edges at all, and its longest path is+// its single longest ROW. That is the same rule, not an exception to it, and it is what+// gets lit. //-// 'global' one path, the longest in the whole plan.-// 'phase' one path per SECTION, each computed inside that section's own subgraph:-// the nodes are that section's rows and the edges are the `deps` whose two-// ends are BOTH in it. A dependency that crosses sections is deliberately-// not an edge here and can never light up in this mode, because the-// question being asked is "what is the longest run of work inside this-// section" and a chain that leaves the section is not an answer to it.-// A section whose rows depend on nothing inside it therefore has no edges-// at all, and its longest path is its single longest ROW. That is the same-// rule, not an exception to it, and it is what gets lit.+// 0.49.0 also offered the plan-wide answer, one path across the whole graph, as a second+// state of the same button. 0.50.0 drops it: 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, and two states of a toggle is one more than the question has.+// The longest-path DP below is unchanged and is simply run once per section subgraph. // // AN ISOLATED ROW IS A PATH OF ONE. This is the second break with the old code, which // only ever considered rows that were in the graph. A one-node path is a real path and@@ -6331,36 +6412,31 @@ function longestPath(order,members,idx){ } return {crit,edges,days:win.days,count:win.count}; }-// One or many paths, depending on the mode, unioned into the one set the painters read.-// The per-path detail is kept alongside it for the message the button flashes.-function criticalSet(mode){+// 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 {order,stuck}=topoOrder(g); const idx=new Map(); g.nodes.forEach((n,i)=>idx.set(n,i)); const paths=[]; if(order.length){- if(mode==='phase'){- // Bucketed in ONE pass, and each bucket keeps the topological order it was cut- // from, so a section's pass walks only that section's rows and still sees every- // predecessor before the row that waits on it. Visited top to bottom, and a- // section with no dated rows simply never appears.- const byPhase=new Map();- order.forEach(n=>{- const ph=typeof n.r.phase==='number'?n.r.phase:-1;- if(!byPhase.has(ph))byPhase.set(ph,{seq:[],set:new Set()});- const bag=byPhase.get(ph);- bag.seq.push(n);bag.set.add(n);- });- [...byPhase.keys()].sort((a,b)=>a-b).forEach(ph=>{- const bag=byPhase.get(ph);- const w=longestPath(bag.seq,bag.set,idx);- if(w){w.phase=ph;paths.push(w);}- });- }else{- const w=longestPath(order,null,idx);- if(w){w.phase=null;paths.push(w);}- }+ // Bucketed in ONE pass, and each bucket keeps the topological order it was cut+ // from, so a section's pass walks only that section's rows and still sees every+ // predecessor before the row that waits on it. Visited top to bottom, and a+ // section with no dated rows simply never appears.+ const byPhase=new Map();+ order.forEach(n=>{+ const ph=typeof n.r.phase==='number'?n.r.phase:-1;+ if(!byPhase.has(ph))byPhase.set(ph,{seq:[],set:new Set()});+ const bag=byPhase.get(ph);+ bag.seq.push(n);bag.set.add(n);+ });+ [...byPhase.keys()].sort((a,b)=>a-b).forEach(ph=>{+ const bag=byPhase.get(ph);+ const w=longestPath(bag.seq,bag.set,idx);+ if(w){w.phase=ph;paths.push(w);}+ }); } const crit=new Set(),edges=new Set(); paths.forEach(w=>{w.crit.forEach(n=>crit.add(n));w.edges.forEach(e=>edges.add(e));});@@ -6376,7 +6452,7 @@ function refreshCritical(){ document.querySelectorAll('.data-table tr.crit-row').forEach(el=>el.classList.remove('crit-row')); if(depSvg)depSvg.querySelectorAll('.crit').forEach(el=>el.classList.remove('crit')); if(!critOn()){critInfo=null;return null;}- const info=criticalSet(prefs.critMode);+ const info=criticalSet(); critInfo=info; info.crit.forEach(n=>{ if(n.id&&barEls[n.id])barEls[n.id].bar.classList.add('crit');@@ -6417,32 +6493,23 @@ function paintCritTable(){ if(keys.has(tr.dataset.key))tr.classList.add('crit-row'); }); }-// One button, three states, cycled in CRIT_MODES order, and it is deliberately the-// SAME control the milestone style is: the label names the state you are in, the-// tooltip says what the next press will do, and the choice is a saved preference. The-// only thing this one adds is `.on`, because two of its three states are a treatment-// laid over the plan and the reader has to be able to see, from the button alone, that-// something on the chart is lit.-const CRIT_BTN = {- off: {label:'Critical',- title:'Critical path: light up the longest run of work in the plan, start to end. Press again for one path per section.'},- global:{label:'Critical: plan',- title:'Critical path: the longest run of work in the whole plan is lit. Press again for one path per section.'},- phase: {label:'Critical: sections',- title:'Critical path: the longest run of work INSIDE each section is lit, and a dependency that crosses sections is left out. Press again to turn it off.'}+// One button, two states, and with the plan-wide mode gone it is an ordinary toggle+// rather than the cycling control the milestone style still is. The label is therefore+// CONSTANT: there is one thing this button does and one state it can be in, so `.on` and+// aria-pressed carry all of it and a label that changed would only be restating them in a+// word. That is also what lets the button size like its neighbours again, since 0.49.0's+// pin existed solely to stop the longest of three labels dragging the group sideways.+// The tooltip is the only text that moves, and it says what the next press will do.+const CRIT_TITLE = {+ off: 'Critical path: light up the longest run of work inside each section. A dependency that crosses sections is left out.',+ phase:'Critical path: the longest run of work INSIDE each section is lit, and a dependency that crosses sections is left out. Press again to turn it off.' };-function syncCritBtn(){- const m=CRIT_BTN[prefs.critMode]||CRIT_BTN.off;- const l=$('critLabel');- if(l)l.textContent=m.label;-} // Enter a state. Idempotent, and the one place the mode is written: the attribute, the // button, the glow and the saved preference all follow from it. function setCritical(mode){ prefs.critMode=CRIT_MODES.indexOf(mode)<0?'off':mode; document.documentElement.setAttribute('data-crit',prefs.critMode); savePrefs();- syncCritBtn(); const b=$('btnCrit'); if(b){b.classList.toggle('on',critOn());b.setAttribute('aria-pressed',critOn()?'true':'false');} syncPlanCtl();@@ -6451,8 +6518,8 @@ function setCritical(mode){ flash(saveInd,critMessage(info),info.cycle&&info.cycle.length?6000:3200); } }-function cycleCritical(){- setCritical(CRIT_MODES[(CRIT_MODES.indexOf(prefs.critMode)+1)%CRIT_MODES.length]);+function toggleCritical(){+ setCritical(critOn()?'off':'phase'); } // What the status line says on the way in. It reports the LENGTH, in working days, // because that is now the whole definition and a reader who cannot see the number has@@ -6461,14 +6528,11 @@ function critMessage(info){ let msg; if(!info.paths.length){ msg='No critical path: nothing in this plan is dated';- }else if(prefs.critMode==='phase'){+ }else{ const items=info.crit.size; msg='Longest path in each section: '+info.paths.length+' section'+(info.paths.length===1?'':'s')+', ' +items+' item'+(items===1?'':'s')+', longest is ' +Math.max(...info.paths.map(w=>w.days))+' working days';- }else{- const w=info.paths[0];- msg='Longest path: '+w.count+' item'+(w.count===1?'':'s')+', '+w.days+' working day'+(w.days===1?'':'s'); } if(info.cycle&&info.cycle.length)msg+='. '+info.cycle.length+' left out: '+cycleNames(info.cycle); return msg;@@ -8350,6 +8414,26 @@ function deleteFromPanel(r){ deleteRow(r); refreshSelVisuals();updateSelCount(); }+// The SET, from the batch panel's Delete button. Straight to deleteRows, which is the+// chart's armed Delete and the table's Delete key: one `multi` undo entry restoring every+// row where it stood, one changelog batch, dependencies on the departed rows dropped and+// their uids retired. `via` is the only thing that differs, so the log still says which+// hand did it.+//+// One press, no arming, unlike the chart's Delete key. The key has no target of its own+// and acts on a selection that may be entirely off screen, so it asks twice; a button+// inside the panel that IS the selection made visible has already been aimed, the same+// argument the single-row panels' Delete has run on since 0.37.0. Ctrl+Z is the safety+// net in both cases and the toast says so.+function deleteBatchFromPanel(){+ const victims=selectedRows();+ if(!victims.length){closeEditor();return;}+ disarmDelete();+ // The panel goes first: it is a view of a selection that is about to be empty, and+ // deleteRows clears the selection itself, which would leave it open over nothing.+ closeEditor();+ deleteRows(victims,'batch-delete');+} function wireCopyIds(){ selCountEl.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();}); const b=$('btnCopyIds');@@ -8641,7 +8725,7 @@ function syncPlanCtl(){ // nothing waits on anything has no runs to be longest. The message is the one it // has always been, for the same reason. c.disabled=!has;- c.title=has?(CRIT_BTN[prefs.critMode]||CRIT_BTN.off).title+ c.title=has?(CRIT_TITLE[prefs.critMode]||CRIT_TITLE.off) :'The critical path needs dependencies: nothing in this plan waits on anything yet'; c.setAttribute('aria-pressed',critOn()?'true':'false'); c.classList.toggle('on',critOn());@@ -8650,8 +8734,7 @@ function syncPlanCtl(){ function wirePlanCtl(){ const a=$('btnAuto'), c=$('btnCrit'); if(a)a.addEventListener('click',()=>{ if(!a.disabled)runAutoScheduleBusy(); });- if(c)c.addEventListener('click',()=>{ if(!c.disabled)cycleCritical(); });- syncCritBtn();+ if(c)c.addEventListener('click',()=>{ if(!c.disabled)toggleCritical(); }); syncPlanCtl(); } async function startApp(){@@ -8697,7 +8780,6 @@ if(critOn()&&!planHasDeps()){ document.documentElement.setAttribute('data-crit','off'); syncPlanCtl(); }-syncCritBtn(); if(critOn())refreshCritical(); if(prefs.view==='analytics')renderAnalytics(); // Say it after the first paint, so the message sits over a chart that clearly
package.json+2−2@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.49.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 in the plan, either once across the whole plan or once inside every section. 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.",+ "version": "0.50.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. 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": { "adom-gantt": "server.js"
page.json+3−3@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.49.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 in the plan, either once across the whole plan or once inside every section. 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.",+ "version": "0.50.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. 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": { "adom-gantt": "server.js"@@ -85,4 +85,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}