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.42.0: auto-schedule, critical path, analytics lens, and Alt+Arrow follows the row it moves
7 files changed
+1147−37
README.md+11−3@@ -4,7 +4,13 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau ## Features -**Two lenses over one plan.** A **Gantt / Table** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline.+**Auto-schedule the whole plan.** One press of **Auto-schedule** lays every task out so that it starts on the first business day AFTER its latest dependency ends, and so that nothing starts or ends on a Saturday or a Sunday. 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 BUSINESS days, so a five-day task laid across a weekend still takes five working days and finishes on the Monday rather than the Saturday. Milestones move off a weekend to the next business day and sit on or after 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.** **Critical** lights up the chain of work with no slack in it, the one that decides the finish date: the bars, the dependency arrows along it and any milestone on it glow white and breathe, in a saturated indigo on paper. Classic CPM over business days, on the dates the plan has right now, so it is independent of Auto-schedule and answers the question about the plan as it stands. Everything else stays exactly as it was, selection and dependency mode included, and the glow recomputes as you edit.++**Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (business days times eight, a forty-hour week), hours per person with shares and the unassigned bucket, status counts overall and per person, 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.++**Three lenses over one plan.** A **Gantt / Table / Analytics** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline. **Add rows.** The New button creates a task or a milestone: name, lane, owner, dates, status, notes and provenance. Created rows live in the sidecar, so the data file is never rewritten, and they appear in every export. @@ -233,7 +239,7 @@ long editing run. | Delete | Delete the selected row, undoable. Only when a row is selected and no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound | | Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. The armed first task wears the normal selection outline. Clicking a pair that already has a dependency removes it. Click an arrow to pick it, then Delete twice to remove that one. Stays on so you can chain them. Esc lets go of a picked arrow, then the pick, then leaves | | Shift+N | Open the New panel. Fires only when nothing else is going on, so a capital N typed into a field stays a capital N |-| Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane. Under a column sort the row stays inside the run of rows the sort considers tied with it |+| Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane. Under a column sort the row stays inside the run of rows the sort considers tied with it. The pane follows the row it is moving, keeping five rows visible ahead of it in the direction of travel, and both columns stay locked | | Tab / Shift+Tab | In the table, walk the selected row's fields. With no editor open it opens the first editable cell (Shift+Tab the last); with one open it commits and moves to the next. Wraps across rows and around the ends, and follows the visible order under a sort. With no row selected, Tab walks the page's own controls as usual. In the edit panels it is one keypress per field the same way, including the date fields, which Tab leaves whole instead of walking their day, month and year segments | | Esc | Cancel the open field, then clear the row focus, then clear the selection | | Ctrl+click | Toggle item in selection |@@ -254,8 +260,10 @@ table, the item editor or the batch editor are taken exactly as typed. | Gesture | Action | |---------|--------|-| Gantt / Table | Switch lens. Pinned to the left of the header so it never moves |+| 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. One undo. Disabled when the plan has no dependencies |+| Critical | Light up the chain with no slack in it. A toggle, 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+57−12@@ -5,15 +5,25 @@ description: > data-driven timeline from a JSON file with phases, tasks, milestones, and dependency arrows. Features: click-to-filter legend, drag-to-resize bars, inline date editor, dependency management, undo (Ctrl+Z), and- auto-save to a sidecar state file. 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. Trigger words:+ auto-save to a sidecar state file. One button auto-schedules the whole+ plan so every dependency lines up finish-to-start on business days with+ no weekend starts or ends, and another lights up the critical path.+ 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,+ hours per person, status counts, per-section totals, milestones,+ dependency stats and what is overdue. Trigger words: gantt, timeline, project schedule, gantt chart, project gantt, show timeline, view schedule, project plan chart, milestone chart, task- table, who owns what, what is mine, owner, deadline, task list.+ table, who owns what, what is mine, owner, deadline, task list,+ auto-schedule, align the dates, critical path, slack, float, analytics,+ hours per person, how many hours, capacity, overdue, what is late. Use when the user wants to see, build or edit a project plan on a- timeline, when they ask who owns what or what is due when, or when they- need to read what a human changed in a plan before changing it.+ timeline, when they ask who owns what or what is due when, when they+ want the dates aligned to the dependencies or want to know which chain+ is driving the finish date, when they ask how many hours the plan is or+ who is carrying them, or when they need to read what a human changed in+ a plan before changing it. --- # adom-gantt@@ -206,7 +216,9 @@ rather than a category. - **New** button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills EVERY field from the one you just made (type, phase, owner, status, start, deadline or milestone date, notes, provenance label). Only the name starts empty, so a run of tasks for the same person in the same lane in the same week is names and nothing else. A provenance left at its default is not remembered as a value, so it keeps following the signed-in identity. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Only a create that produced a row updates it: Cancel, Escape and a validation-rejected create all leave it untouched. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export - **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 toggle** pinned first in the header, left of every conditional control so its position never shifts. Two lenses over the same items: the chart answers "when", the table answers "whose, and by when". See The table view below+- **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 on the first BUSINESS day after its latest predecessor ends, and no task starts or ends on a Saturday or a Sunday. 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 if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in BUSINESS days, not calendar days, so a five-day task laid across a weekend still takes five working days and finishes on the Monday. Milestones take the same rule with no duration: off a weekend to the next business day, and on or after the first business day after 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: a toggle that lights up the chain of work with no slack in it, the one that decides the finish date. Classic CPM forward and backward over BUSINESS days, on the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. Total float of zero is critical; only rows in the dependency graph can be. 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. Disabled with the same reasoning when the plan has no dependencies - **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 - **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@@ -226,7 +238,7 @@ rather than a category. - **Up / Down** in the table move the selected row, following the visible order under any sort or filter. They never scroll the pane on their own: the browser's default is suppressed and the pane moves only enough to keep the selected row in view. The first press selects the first visible row. In the chart the arrows are left alone - **Enter** on the selected row opens its NAME editor, ready to type with the text selected. Inside a cell editor Enter commits and steps down one row, the spreadsheet convention, so a column of values is Enter, type, Enter, type; it does not open the next editor, and a second Enter does - **Shift+N** opens the New panel, gated on the app being idle: no editor, panel, menu or drag in progress, and nothing focused that a person could be typing into-- **Alt+Up / Alt+Down** moves one row one slot, or the WHOLE chart selection as a group when more than one row is selected: each selected row steps one place so the gaps between them survive, and if any of them has nowhere to go the press does nothing rather than squashing the selection against the end. One undo per press either way. The table moves its focused row (the row whose cell editor was touched last, marked with an accent bar on its left edge); the chart moves its selected task. In plan order the movement is free, and stepping over a group header re-homes the row into that lane, phase, bar colour and dashed rule together, exactly as a drag does. Under a column sort the movement is bounded to the contiguous run of rows the sort considers TIED with this one, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. A row alone in its sort key does not move and says so. Undo is per press; the changelog coalesces a burst into one line+- **Alt+Up / Alt+Down** moves one row one slot, and the pane FOLLOWS the row it is moving, keeping five rows visible ahead of it in the direction of travel and scrolling only when it has to, in both lenses and through a held-key repeat. Both chart columns stay locked, and the usable top of the pane is measured under the sticky axis and the pinned section header rather than at the pane's edge. It moves the WHOLE chart selection as a group when more than one row is selected, following the leading row: each selected row steps one place so the gaps between them survive, and if any of them has nowhere to go the press does nothing rather than squashing the selection against the end. One undo per press either way. The table moves its focused row (the row whose cell editor was touched last, marked with an accent bar on its left edge); the chart moves its selected task. In plan order the movement is free, and stepping over a group header re-homes the row into that lane, phase, bar colour and dashed rule together, exactly as a drag does. Under a column sort the movement is bounded to the contiguous run of rows the sort considers TIED with this one, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. A row alone in its sort key does not move and says so. Undo is per press; the changelog coalesces a burst into one line - **Tab / Shift+Tab** walks the selected row's fields at one keypress per column, including the select and date columns, and does the same thing field by field in every edit panel (task, milestone, item, New and batch): one Tab leaves a date field for the next field rather than stepping through its segments, in both directions, while the arrows and typing still edit the segments normally. A select cell shows its choices in the app's own list rather than the browser's popup, because that popup swallows the first Tab to close itself; the select keeps all its native keyboard behaviour. Date cells keep their native arrow keys for segments and values, and Tab leaves the field instead of walking its segments. With no editor open it opens the first editable cell (Shift+Tab the last); inside an editor it commits the field and opens the next editable cell, with the same ready-to-type conventions as a click (a select opens its list, short text is selected, notes get the caret at the end). It skips read-only cells and the delete button, follows the visible order under a sort, and wraps across rows and around both ends of the table - **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree) - **Auto-load** saved state on page refresh@@ -295,6 +307,35 @@ versions carry `ms: true` and a `key` instead of an `id`, since milestones have `project.view.mode` pins which lens a plan opens in, the same way `grain` pins the cell size. Use it when the plan is an assignment list more than a timeline. +## The analytics view++The third lens, and the one to reach for when the question is "how much" rather than+"when" or "whose". Read-only: every number on it is derived, and it re-derives from the+same `items` every time the lens is entered or an edit lands, so it can never disagree+with the other two.++**The hour rule.** A task's effort is the BUSINESS days in its span, counting both ends,+times eight. A forty-hour week, so Monday to Friday is 40h and the same task dragged+across a weekend to the Tuesday is 56h. A milestone is a date rather than work and+carries no hours; the owner table counts milestones separately as `+nM`.++Eight cards:++| Card | What it answers |+|------|-----------------|+| Project total | Total effort in hours, business days in the plan's span, dated tasks, milestones, people |+| Hours per person | Every owner, unassigned included, with hours, share of the total and item counts, sorted by hours |+| Status | The four status counts over every task and milestone, with shares |+| Status per person | The same four counts, one row per owner |+| Per section | Items, hours and the date range each section actually covers, derived from its rows |+| Milestones | Every dated milestone with its date, days from today and owner |+| Dependencies | Link count, rows in the graph, rows outside it, and a cycle warning naming the rows |+| Overdue | Every row with a deadline in the past and a status that is not complete |++It is WHOLE-PLAN by construction, which is why the legend and the filters are hidden+with it: a total that quietly excluded a filtered lane would be a wrong total presented+as a right one. It renders in both themes and prints.+ ## uid: the one handle worth quoting Every item carries a `uid`, a zero-padded number assigned once and never changed.@@ -358,10 +399,10 @@ curl -s localhost:8901/changelog # JSON: {rev, count, touch Each event records the action, the item, the before and after dates, the day shift, and `via` (which gesture made it: `editor`, `drag-move`, `drag-resize`, `drag-reorder`, `batch-editor`, `bar-note`, `table-edit`, `milestone-editor`, `start-picker`,-`start-revert`, `undo`).+`start-revert`, `auto-schedule`, `undo`). -Actions are `dates`, `plan.shift`, `milestone`, `milestone.rename`, `note`, `owner`,-`status`, `desc`, `dep.add`, `dep.remove` and `reorder`.+Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rename`,+`note`, `owner`, `status`, `desc`, `dep.add`, `dep.remove` and `reorder`. Every event must carry a string `action`, and `events` must be an array; `POST /log` returns 400 otherwise, because the log is append-only and anything accepted is@@ -372,7 +413,11 @@ An import records a single `import` event. It replaces the data file and clears sidecar, so treat everything logged above that event as history of a different plan. Moving the project start collapses into a single `plan.shift` event rather than one row-per task, so the log says what happened instead of burying it in 26 identical lines.+per task, so the log says what happened instead of burying it in 26 identical lines. An+Auto-schedule press collapses the same way, into one `auto-schedule` event carrying a+`count` and the `moved` row names, even though every task in it moved by a different+amount. A press that moved nothing writes nothing at all, so an aligned plan pressed+twice does not put a second line in the log. **The workflow.** Note the `rev` you saw. Next time the user asks you to change the plan, ask for `?since=<that rev>` first. Anything in `touched[]` is a deliberate human decision:
docs/BUILD-NOTES.md+228@@ -3,6 +3,234 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-18 (0.42.0, the plan schedules itself, shows its critical path, and adds up)++Four things from Drew. Three of them are in the client; the fourth is one branch of the+changelog's markdown renderer on the server, which is the only reason `server.js` moved+at all this round. **No live server was restarted and no live plan was opened at any+point.** A running node process already holds the code it started with, so a deployed+`server.js` lands for the next start rather than under a session in progress.++### A. Auto-schedule++Drew called it "Align Timing". It ships under the industry name, **Auto-schedule**, with+his sentence as the tooltip: "Align all task dates so dependencies line up+finish-to-start, skipping weekends". One press over the whole plan.++**The two rules are one pass.** Every task with dependencies starts on the first business+day AFTER its latest predecessor ends, and no task starts or ends on a Saturday or a+Sunday. Both directions: slack is pulled IN and a conflict is pushed OUT. A schedule that+only ever moved work later would drift the plan out one round at a time and never bring+it back, which is the thing that makes people stop pressing the button.++**A task with no predecessors is an ANCHOR and keeps its start.** That is the one thing+in a plan this cannot derive, so it is the one thing it does not touch, beyond nudging a+start that lands on a weekend to the Monday. Everything else in the plan is a consequence+of the anchors and the graph.++**Duration is preserved in BUSINESS days, which is the whole point of the weekend rule.**+A five-day task that would finish on the Saturday finishes on the Monday, and the same+task moved to a Wednesday still takes five working days. The bench proves the case that+matters: `A4 straddle` runs 5 business days from a Wednesday and lands on the following+Tuesday, not on the Saturday five calendar days would have given it. A span containing no+business day at all (Saturday to Sunday) counts as one, because zero would put its end+before its start.++**A milestone is a date, so it takes the same rule with no duration:** off a weekend to+the next business day, and on or after the first business day after its latest+predecessor. Nothing in this app can depend ON a milestone (it has no id, and the chart+draws no arrow to one), but a milestone can depend on tasks and the data model has always+allowed it, so the schedule reads the data rather than the drawn layer.++**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 for free.++**A cycle is left alone, and said so.** Kahn's algorithm gives a real topological order,+and what is left over when it stops is exactly the set of rows on or downstream of a+cycle. Those keep their dates and the toast names them. There is no honest answer for a+row that waits on itself, and moving it to a guess would be worse than leaving it where+its author put it. Determinism falls out of the same shape: the queue is seeded and+refilled in `items` order, which is the order on screen.++**One undo, one changelog line.** The undo entry is the same group snapshot a plan shift+takes, so a single Ctrl+Z puts every date in the plan back, milestones included. The+changelog was the harder half: `diffAndLog` already collapses a plan shift, but it does+that by noticing that every date moved by the SAME delta, and an auto-schedule moves+every task by a different one on purpose. So it collapses on the `via` instead, and it+runs LAST, after the milestone diff, because a milestone nudged off a Sunday is part of+the same press and its event does not exist until then. The event names the rows it+moved, up to a readable number of them, so the log still says what moved and not only how+many. `describeEvent` on the server renders it as one sentence.++**Nothing moved is nothing recorded.** A second press on an already aligned plan reports+"0 tasks moved, 18 already aligned" and writes nothing at all. A log that grows every+time someone presses a button that did nothing is a log nobody reads.++**The button is disabled, with a tooltip, when the plan has no dependencies**, and so is+the critical path button next to it, for the same reason and in its own words. Both are+statements about the dependency graph and neither means anything without one.++### B. The critical path++Classic CPM, forward and backward, in business days, over the dates the plan has RIGHT+NOW. It never reschedules anything: the two buttons are deliberately independent, because+a reader who wants to know which chain is driving the end date should be able to ask that+of the plan as it stands and not of a plan this app quietly rewrote first.++The forward pass takes each row's own start as a FLOOR rather than deriving it purely+from the network, so a row an author deliberately put later than its dependencies allow+is respected instead of compressed, and a row with no predecessors simply keeps its early+start. That is what makes the numbers on screen and the numbers in the arithmetic the+same numbers. After an auto-schedule the two readings coincide exactly, because+auto-schedule sets each start to `max(predecessor finish + 1)` and leaves the anchors, so+the critical path is then the longest chain in the plain sense.++**Only rows IN the graph can be critical.** An isolated task that happens to run past+everything else is not a chain through anything, and lighting it up would be a lie about+what is driving the finish. **An edge is critical when both its ends are AND the+successor really does follow the predecessor with no slack between them**; without that+last test, two separate zero-float chains that happen to touch would light the link+between them.++**It glows white, and it breathes.** White is the one colour nothing else on this page+uses: the accent is teal, the dependency signal is amber, danger is red, and the phase+ramp owns teal, purple and blue, so a white halo cannot be read as any of them. The+treatment is a 2.6s box-shadow and filter breath on the bar outlines, the arrows along+the path, and a milestone's diamond, its dashed rule and its cap, in all three milestone+styles. On paper white IS the page, so the light theme keeps the SHAPE of the signal and+swaps the band, to a saturated indigo that clears 4.5:1 against white and is not in the+phase ramp. Same reasoning that produced `--sig` and `--sig-edge` in 0.39.0, same pair of+tokens.++**Everything about it is additive.** The glow rides `box-shadow` and `filter`, which no+other state on these elements uses, so a bar can be critical AND selected AND armed at+once and each of the three still says what it says: the suite asserts the accent+selection outline is still exactly `2px solid` in the accent on a glowing bar with the+breath still running underneath it. The two dependency-mode treatments are declared AFTER+the glow at the same specificity, so a picked or armed arrow keeps its amber or its red:+the mode is a thing you are doing and the path is a thing you are reading, and the one you+are doing wins. Nothing touches `pointer-events`, geometry or layout, so a glowing bar is+dragged, resized and picked on exactly the pixels it always was.++**It recomputes live.** `scheduleSave` is the choke point every edit already goes through,+so the set is re-derived there while the toggle is on. The arrows are a separate question:+the layer is thrown away and rebuilt on every geometry change, so `drawDeps` puts the glow+back from state afterwards, exactly as it has put the pick and the armed state back since+0.39.0. The table lens carries the same answer on the row's left edge, from the same+stored set rather than a recompute, because the table re-renders on every filter keystroke+and the graph has not changed for any of them.++A reader who has asked the system not to animate gets the signal held at the bright end of+the same breath, rather than not getting it.++### C. The analytics page++A third lens, alongside Gantt and Table, over the same in-memory model. Read-only in v1 on+purpose: every number on it is derived, and a derived number with an editor on it invites+the reader to change the answer rather than the question.++**The hour rule, stated once and used everywhere:** a task's effort is the business days+in its span, counting both ends, times eight. A forty-hour week, so Monday to Friday is+40h and the same task dragged across a weekend to the Tuesday is 56h and not 64h. A+milestone is a date rather than work and carries no hours at all, which is why the owner+table counts them separately as `+nM` instead of pretending they are a task.++Eight cards: the plan in five numbers, hours per person with share bars and a total,+status overall, status per person, per-section totals with the range each section actually+covers, the milestones with days from today, the dependency stats with a cycle warning,+and what is overdue. No chart library: a share bar is one div with a percentage width,+which is the whole of what a share bar is, and it prints, themes and scales with the text+for free because it is made of the same tokens as everything else.++**It is whole-plan by construction, which is why the legend goes away with it.** A total+that quietly excluded a filtered lane would be a wrong total presented as a right one. The+filters belong to the two lenses that show rows.++Two things changed while the numbers were being checked against the fixture by hand.+Hours are now always hours with a thousands separator, never "23d": a column that says 80h+on one row and 23d on the next is a column the reader has to convert in their head before+they can compare two numbers in it, which is the one thing a totals table exists to save+them. And the unassigned bucket sorts after the named people it is tied with, so the order+is total and a redraw that changed nothing puts every row back where it was.++**Three lenses meant auditing every "is the table up" test in the app.** Four guards read+`prefs.view === 'table'` when what they meant was "the chart is not on screen", and with a+third lens those two stopped being the same question. They now test for the gantt lens.+`VIEWS` is the one list the toggle, the preference validation and the switch all read, so+an unknown saved value falls back to the chart rather than showing nothing.++### D. Alt+Arrow follows the row it is moving++Alt+Up and Alt+Down move a row through the plan, and until now the pane stayed where it+was, so a row moved far enough simply left the screen and the next press was a guess. The+pane now follows it, with a **five-row margin**: the row is kept five rows clear of the+edge it is travelling towards, so the reader can see what it is about to move past rather+than only where it has got to.++Only when it has to. The scroll is the difference between where the row is and the nearest+position that satisfies the margin, so a row moving about the middle of the pane does not+move the pane at all, and a held key that walks a row down thirty places scrolls exactly as+far as those thirty places needed. Instant rather than smooth, and that is the point rather+than a shortcut: a held Alt+Down repeats faster than a smooth scroll settles, so a smooth+one would fall behind the row it is following and then overshoot when the key came up.++The usable top of the chart pane is NOT the pane's top edge: the time axis is sticky there+and a section header pins under it, and both are opaque, so the band is measured from the+axis height and the pinned header's own box (0.31.1, 0.34.0). The label column is brought+along in the same tick rather than left to the scroll event, which fires after the next+keypress of a held repeat has already been handled: both panes stay locked row for row+through every step, which the suite asserts on all 28 of them. A multi-row selection+follows its LEADING row in the direction of travel, because that is the edge the reader is+about to lose sight of. The table lens gets the same margin from the same function, with+its own band under the sticky `thead`; its plain-arrow navigation is untouched and still+moves by the least it can get away with, which is a different question with a different+answer.++### Verification++Two new suites, plus one line changed in a standing one. `v42` (96 assertions) runs the+analytics hand-checks FIRST, on the pristine fixture, then the critical path, then+auto-schedule, which is the only destructive part. Every number it asserts is computed by+hand in a comment above the assertion: 75 business days at 8h is 600h, Ada 184h over five+tasks, Bo 256h over six, Cy and unassigned 80h each, status 12/3/1/2, twelve dependency+links with five rows outside the graph and three in a cycle. The CPM fixture is+hand-computed the same way, ES/EF and LS/LF written out, and it is built so that exactly+one row in the chain (`C3 short`, five days of float) must NOT glow: a fixture where+everything lights up would not be able to fail.++The schedule is asserted twice over. Each individual date is the hand-computed one, named+with the reasoning ("a1 ends Fri 09 Jan, so a2 starts Mon 12 Jan"), and then three+INVARIANTS are checked across the whole plan: nothing starts or ends on a weekend, every+non-cycle dependency is finish-to-start on the next business day, and every business-day+duration is unchanged from before the press. The suite's business-day arithmetic is a+second, independent implementation (a plain UTC day number and a walk) rather than the+app's closed-form week count, so the two cannot agree by sharing a bug.++`v42big` (33 assertions) is the 75-row bench. The scroll buffer is measured on EVERY press+rather than once at the end, because a pass that only checked the destination would not+notice a pane that jumped there and back: 28 presses down, 34 up, a 25-press burst with no+pause between them, a three-row group move, and the same again in the table lens, each+step asserting the row's box against the pane band minus the sticky chrome, the five-row+clearance, and that the two panes are still locked. It also deletes the plan's only+dependency and asserts both buttons go quiet with the right words, then puts it back with+one Ctrl+Z.++The standing battery is green: v37 203, regress 26, abc 75, v38 249, v39 175, v40 138, v41+75, v41big 23, v42 96, v42big 33, and the five legacy suites TESTOUT OK with the two+fixture-coupled ones unchanged. `mssel` and `msphase` were run A against 0.41.0 and B+against 0.42.0, each on its own port and its own COPY of the fixture, and the output is+byte identical. Zero JS errors anywhere. Screenshots of the path and of the analytics page+in both themes.++One standing assertion changed on purpose: `regress` counted the lens buttons and expected+two. There are three now.++**A note on benching two builds at once**, because it cost an hour: pointing a second+server at the SAME data file is not an A/B, it is a race. Both mint uids into it on+startup and the second to start rewrites what the first is serving, which shows up as the+app silently refusing to select anything and looks exactly like a regression in the build+under test. Each side gets its own copy.+ ## Build notes, 2026-08-18 (0.41.0, dependency arrows steer clear of the bars) One change, from Drew, in the client. `server.js` is untouched, so the running server was
gantt.html+801−13@@ -58,6 +58,15 @@ --sig is the stroke, which the page is measured against, and --sig-edge is the halo, which the fills are measured against. */ --sig: #ffb02e; --sig-edge: #05080b;+ /* The critical-path signal. Drew asked for WHITE, and on this page white is the one+ colour nothing else uses: the accent is teal, the dep signal is amber, danger is+ red and the phase ramp owns teal, purple and blue. So a white halo cannot be read+ as any of them.+ On paper white is the page, so it would be invisible, and the same reasoning that+ produced --sig / --sig-edge applies: the light theme keeps the SHAPE of the signal+ (a breathing halo around the same elements) and swaps the band, to a saturated+ indigo that clears 4.5:1 against the page and is not in the phase ramp. */+ --crit: #ffffff; --crit-glow: rgba(255,255,255,0.85); --crit-halo: rgba(255,255,255,0.35); --on-accent: #05221f; --grid-line: rgba(48,54,61,0.18); --weekend: rgba(255,255,255,0.022);@@ -89,6 +98,7 @@ 3:1 against all three light fills, and the stroke is the deep amber, which clears 5:1 against the page. */ --sig: #b45309; --sig-edge: #ffffff;+ --crit: #4c1d95; --crit-glow: rgba(76,29,149,0.95); --crit-halo: rgba(76,29,149,0.40); --on-accent: #ffffff; --grid-line: rgba(31,35,40,0.10); --weekend: rgba(31,35,40,0.04);@@ -106,6 +116,7 @@ --accent:#00857f; --today:var(--text2); --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04); --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff; --sig:#b45309; --sig-edge:#ffffff;+ --crit:#4c1d95; --crit-glow:rgba(76,29,149,0.95); --crit-halo:rgba(76,29,149,0.40); color-scheme: light; } html, body { overflow: visible !important; height: auto !important; } .labels-col, .timeline-col { overflow: visible !important; }@@ -189,7 +200,7 @@ header #btnTheme { min-width: 56px; justify-content: center; } the buttons keep their icons and their tooltips, the unit count goes (the axis already shows the span), and the "starts" label goes (its button says a date). With the labels gone the buttons are all one width, so nothing shifts. */-@media (max-width: 1460px) {+@media (max-width: 1620px) { .who-name { display: none; } .who { max-width: none; } header .subject-meta,@@ -368,6 +379,63 @@ body.col-resizing { cursor: col-resize; user-select: none; } .dep-svg path { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; } .dep-svg polygon { fill: var(--accent); opacity: 0.5; transition: opacity 0.15s; } .dep-svg.hidden path, .dep-svg.hidden polygon { opacity: 0; }+/* == The critical path ==+ The chain with no slack in it, breathing. Everything here is ADDITIVE: the glow is+ carried on box-shadow and filter, which no other state in this app uses on these+ elements, so a bar can be critical AND selected AND armed at once and each of the+ three still says what it says. The two dependency-mode treatments below deliberately+ come AFTER these rules, at the same specificity, so a picked or armed arrow keeps its+ amber or its red: the mode is a thing you are doing and the path is a thing you are+ reading, and the one you are doing wins.+ Nothing here touches pointer-events, geometry or layout, so hit-testing is exactly+ what it was: a glowing bar is dragged, resized and picked on the same pixels. */+.dep-svg path.crit { stroke: var(--crit); opacity: 1; animation: critStroke 2.6s ease-in-out infinite; }+.dep-svg polygon.crit { fill: var(--crit); opacity: 1; animation: critStroke 2.6s ease-in-out infinite; }+@keyframes critStroke {+ 0%, 100% { filter: drop-shadow(0 0 1px var(--crit-halo)); stroke-width: 1.5; }+ 50% { filter: drop-shadow(0 0 4px var(--crit-glow)) drop-shadow(0 0 8px var(--crit-halo)); stroke-width: 2.4; }+}+.bar.crit { animation: critBar 2.6s ease-in-out infinite; }+@keyframes critBar {+ 0%, 100% { box-shadow: 0 0 0 1px var(--crit), 0 0 5px 1px var(--crit-halo); }+ 50% { box-shadow: 0 0 0 1.5px var(--crit), 0 0 13px 4px var(--crit-glow); }+}+.ms-diamond.crit-ms { animation: critDiamond 2.6s ease-in-out infinite; }+@keyframes critDiamond {+ 0%, 100% { box-shadow: 0 0 0 1px var(--crit), 0 0 5px 1px var(--crit-halo); }+ 50% { box-shadow: 0 0 0 2px var(--crit), 0 0 12px 4px var(--crit-glow); }+}+/* The rule and its cap are one milestone in the other two styles, so both carry it. */+.ms-line.crit-ms::before { border-left-color: var(--crit); opacity: 1; animation: critRule 2.6s ease-in-out infinite; }+@keyframes critRule {+ 0%, 100% { filter: drop-shadow(0 0 1px var(--crit-halo)); }+ 50% { filter: drop-shadow(0 0 4px var(--crit-glow)); }+}+.ms-line.crit-ms .ms-cap { border-color: var(--crit); color: var(--crit); animation: critCap 2.6s ease-in-out infinite; }+@keyframes critCap {+ 0%, 100% { box-shadow: 0 0 4px 0 var(--crit-halo); }+ 50% { box-shadow: 0 0 11px 2px var(--crit-glow); }+}+/* The table's half of the same answer: the row that is on the path, breathing on its+ left edge, where the focused-row marker already lives so the two read as one column+ of signals rather than two competing washes. */+.data-table tbody tr.crit-row td:first-child { animation: critRow 2.6s ease-in-out infinite; }+@keyframes critRow {+ 0%, 100% { box-shadow: inset 3px 0 0 var(--crit-halo); }+ 50% { box-shadow: inset 3px 0 0 var(--crit), -1px 0 8px 0 var(--crit-glow); }+}+/* A reader who has asked the system not to animate still gets the signal, held at the+ bright end of the same breath rather than taken away. */+@media (prefers-reduced-motion: reduce) {+ .bar.crit, .ms-diamond.crit-ms, .ms-line.crit-ms::before, .ms-line.crit-ms .ms-cap,+ .dep-svg path.crit, .dep-svg polygon.crit,+ .data-table tbody tr.crit-row td:first-child { animation: none; }+ .bar.crit { box-shadow: 0 0 0 1.5px var(--crit), 0 0 10px 3px var(--crit-glow); }+ .ms-diamond.crit-ms { box-shadow: 0 0 0 2px var(--crit), 0 0 10px 3px var(--crit-glow); }+ .ms-line.crit-ms .ms-cap { box-shadow: 0 0 9px 2px var(--crit-glow); }+ .dep-svg path.crit, .dep-svg polygon.crit { filter: drop-shadow(0 0 3px var(--crit-glow)); }+ .data-table tbody tr.crit-row td:first-child { box-shadow: inset 3px 0 0 var(--crit); }+} /* A PICKED dependency, in dependency mode only. The whole layer stays pointer-events:none in every mode (see depEdgeAt: picking an arrow is a hit test against the drawn route, not a hit target laid over the plan), so nothing here can@@ -605,6 +673,85 @@ body.row-dragging .data-table tbody tr:hover { background: none; } /* The focused row: the row whose editor you touched last, and the row Alt+Up and Alt+Down move. An inset shadow rather than a border or a pseudo-element with a width, so the indicator cannot shift a single pixel of the row it marks. */++/* == Analytics ==+ A third lens over the same rows. The chart answers "when", the table answers "whose+ and by when", and this one answers "how much, and is any of it in trouble". It is+ READ-ONLY on purpose: every number here is derived, and a derived number with an+ editor on it invites the reader to change the answer rather than the question.++ It is also whole-plan by construction, which is why the legend goes away with it: a+ total that quietly excluded a filtered lane would be a wrong total presented as a+ right one. The filters belong to the two lenses that show rows.++ No chart library. The bars here are one div with a percentage width, which is the+ whole of what a share bar is, and it prints, themes and scales with the text for+ free because it is made of the same tokens as everything else. */+.analytics-view { display: none; flex: 1; min-height: 0; flex-direction: column; background: var(--bg); }+:root[data-view="analytics"] .analytics-view { display: flex; }+:root[data-view="analytics"] .chart-wrap,+:root[data-view="analytics"] .legend,+:root[data-view="analytics"] .dep-caption,+:root[data-view="analytics"] header .start-ctl,+:root[data-view="analytics"] #btnMsStyle,+:root[data-view="analytics"] #btnCrit,+:root[data-view="analytics"] #btnAuto { display: none; }+.an-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 16px 18px 40px; }+.an-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; align-items: start; max-width: 1600px; }+.an-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px 15px; min-width: 0; }+.an-card.wide { grid-column: 1 / -1; }+.an-card h3 { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--accent); margin: 0 0 3px; letter-spacing: 0.01em; }+.an-card .an-sub { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); margin: 0 0 11px; }+.an-hero { display: flex; flex-wrap: wrap; gap: 22px; align-items: flex-end; }+.an-stat { min-width: 0; }+.an-stat .v { font-family: 'JetBrains Mono', monospace; font-size: 25px; font-weight: 700; color: var(--text); line-height: 1.05; white-space: nowrap; }+.an-stat .v small { font-size: 12px; font-weight: 400; color: var(--text2); margin-left: 3px; }+.an-stat .k { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); margin-top: 3px; }+table.an-table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); table-layout: fixed; }+table.an-table th { font-family: 'Familjen Grotesk', sans-serif; font-size: 10px; font-weight: 600; color: var(--text2); text-align: left; padding: 0 8px 5px 0; white-space: nowrap; border-bottom: 1px solid var(--border); }+/* The gap on the LEFT is what keeps two adjacent number columns from touching when a+ header ("not started") is wider than the numbers under it. */+table.an-table th.n, table.an-table td.n { text-align: right; padding: 0 8px 5px 14px; font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }+table.an-table td.n { padding: 4px 8px 4px 14px; }+table.an-table td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(48,54,61,0.15); vertical-align: middle; }+table.an-table tr:last-child td { border-bottom: none; }+table.an-table td.name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+table.an-table td.muted, table.an-table .muted { color: var(--text2); }+table.an-table tr.total td { border-top: 1px solid var(--border); border-bottom: none; padding-top: 6px; font-weight: 600; color: var(--text); }+/* A full-width card gives its first column the slack, so a long task name has room to+ be read instead of the number columns drifting apart across a metre of empty card. */+.an-card.wide table.an-table th:first-child { width: 34%; }+/* Four narrow counts do not need a metre of card, so this one keeps its own width and+ stays left-aligned in it. */+table.an-table.tight { width: 760px; max-width: 100%; }+/* The share bar: the row's own cell, filled to its fraction. Painted UNDER the text+ rather than beside it, so a long name never steals the bar's width. */+.an-bar { position: relative; height: 6px; border-radius: 3px; background: var(--border-muted); overflow: hidden; min-width: 40px; }+.an-bar > i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); border-radius: 3px; display: block; }+.an-chip { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; vertical-align: -1px; }+.an-empty { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); padding: 3px 0; }+.an-warn { color: var(--warn); }+.an-late { color: #f85149; }+:root[data-theme="light"] .an-late { color: #b91c1c; }+.an-foot { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text3); margin-top: 9px; }+@media print {+ .an-scroll { overflow: visible !important; padding: 0; }+ .an-card { break-inside: avoid; border-color: #d0d7de; }+ .an-grid { grid-template-columns: 1fr 1fr; }+}++/* == The plan-level controls ==+ Auto-schedule and the critical path are statements about the WHOLE plan rather than+ about a row, so they sit with Fit, next to the lens switch, and not in the actions+ group at the far end with import and export. They are their own bordered group for+ the same reason the start control is: a reader can tell at a glance which buttons+ change the plan and which change the view. */+.plan-ctl { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }+:root[data-view="analytics"] .plan-ctl { display: none; }+.plan-ctl .act-btn[disabled] { opacity: 0.4; cursor: default; }+.plan-ctl .act-btn[disabled]:hover { background: none; border-color: var(--border); color: var(--text2); }+.plan-ctl .act-btn.on { background: var(--elevated); color: var(--crit); border-color: var(--crit); }+:root[data-theme="light"] .plan-ctl .act-btn.on { color: var(--crit); } /* One selection model, two lenses. A selected row carries the same faint teal wash the chart's label rows use; the focused row keeps its accent bar on the left edge. They are different questions ("is this row in the set" and "which row do the keys@@ -893,6 +1040,9 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va </table> </div> </div>+ <div class="analytics-view" id="analyticsView">+ <div class="an-scroll" id="anScroll"></div>+ </div> </div> <datalist id="ownerList"></datalist> <div class="tip" id="tip"></div>@@ -1302,6 +1452,45 @@ function fmt(ds){ } function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Date(y,mo-1,d);dt.setDate(dt.getDate()+n);return`${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;} +// ── Business days ──+//+// Every date in this app is a `yyyy-mm-dd` STRING, and it means that calendar day+// in the reader's plan and not an instant. So the arithmetic below never builds a+// local Date and asks it what day it is: a local Date is an instant, it lands at+// midnight in whatever zone the browser happens to be in, and one DST boundary or+// one container running UTC-behind is all it takes for a Friday to report itself+// as a Saturday. `dayNum` goes through Date.UTC, which is pure arithmetic on the+// three numbers, and every other function here is built on that one.+//+// Day 0 of the UTC epoch is 1970-01-01, a Thursday, so (dayNum + 4) % 7 is the+// day of the week with 0 for Sunday, the same convention getDay() uses.+function dayNum(ds){const[y,m,d]=String(ds).split('-').map(Number);return Math.round(Date.UTC(y,m-1,d)/86400000);}+function dowOf(ds){return ((dayNum(ds)%7)+7+4)%7;}+function isWeekend(ds){const w=dowOf(ds);return w===0||w===6;}+// The next business day ON or after this one, and its mirror. Both are identity+// on a weekday, so calling them on a date that is already fine costs one modulo.+function nextBiz(ds){let s=ds,g=0;while(isWeekend(s)&&g++<7)s=addDays(s,1);return s;}+function prevBiz(ds){let s=ds,g=0;while(isWeekend(s)&&g++<7)s=addDays(s,-1);return s;}+// Business days in [a,b], counting both ends, which is how a person reads a task+// that starts and ends on the same Monday: one day, not zero. Whole weeks are+// five days each and only the remainder is walked, so a ten-year span costs the+// same six comparisons a ten-day one does.+function bizBetween(a,b){+ const A=dayNum(a),B=dayNum(b);+ if(B<A)return 0;+ const total=B-A+1,weeks=Math.floor(total/7);+ let c=weeks*5;+ for(let i=weeks*7;i<total;i++){const w=((A+i)%7+7+4)%7;if(w!==0&&w!==6)c++;}+ return c;+}+// n business days forward from a business day, counting `ds` as the first, so+// addBiz(d,1) === d and addBiz(Friday,2) === the following Monday.+function addBiz(ds,n){+ let s=nextBiz(ds),k=Math.max(1,Math.round(n));+ while(k>1){s=addDays(s,1);if(!isWeekend(s))k--;}+ return s;+}+ // ── Header with import/export buttons ── function buildHeader(){ $('hdr').innerHTML = `@@ -1312,11 +1501,13 @@ $('hdr').innerHTML = ` to the brand block, which never changes width. It used to live inside .actions, which is right-aligned: every chart-only button that hid in table view made that group narrower and shoved the toggle sideways between clicks. -->- <div class="view-toggle" id="viewToggle" role="tablist" title="Two lenses over the same plan: the chart, or the owner and deadline table">+ <div class="view-toggle" id="viewToggle" role="tablist" title="Three lenses over the same plan: the chart, the owner and deadline table, or the totals"> <button class="vt-btn" data-view="gantt" role="tab"> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="5" width="13" height="3" rx="1.5"/><rect x="7" y="10.5" width="14" height="3" rx="1.5"/><rect x="5" y="16" width="10" height="3" rx="1.5"/></svg>Gantt</button> <button class="vt-btn" data-view="table" role="tab"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9.5h18M3 15h18M10 9.5V20"/></svg>Table</button>+ <button class="vt-btn" data-view="analytics" role="tab">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>Analytics</button> </div> <div class="vsep"></div> <!-- One title, one subtitle. The brand used to repeat the project as an@@ -1335,6 +1526,12 @@ $('hdr').innerHTML = ` <button class="act-btn" id="btnDeps" title="Add dependencies · Shift+D. Click the task that comes first, then the one that waits on it. Esc clears the pick, Esc again leaves the mode"> <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">+ <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 chain of work with no slack in it, the one that decides the finish date" 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">Critical</span></button>+ </div> <div class="actions"> <button class="act-btn" id="btnMsStyle" title="Milestone style, cycled: dashed lines with tags, diamonds on their own row, or both at once"> <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button>@@ -1541,7 +1738,7 @@ function recolorByPhase(){ }); } function refreshChartGeometry(){- if(prefs.view==='table')return;+ if(prefs.view!=='gantt')return; layoutMsCaps(); sizeTailroom(); drawDeps(); } // The saved order lists the plan's ROWS. Headers are derived, so they are not in@@ -1757,10 +1954,89 @@ function moveSelectedRows(dir){ }); applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable(); refreshSelVisuals(); // the selection survives, and still looks it+ followMovedRows(rows,dir); moveHint=rows.length+' rows'; scheduleKeyMoveSave(); return true; }+// ── Following a moved row ───────────────────────────────────────────────────+//+// Alt+Up and Alt+Down move a row through the plan, and until now the pane stayed+// where it was, so a row moved far enough simply left the screen and the next press+// was a guess. The pane now follows it, and it follows with a MARGIN: the row is+// kept five rows clear of the edge it is travelling towards, so the reader can see+// what it is about to move past rather than only where it has got to.+//+// Only when it has to. The scroll is the difference between where the row is and+// the nearest position that satisfies the margin, so a row moving about the middle+// of the pane does not move the pane at all, and a held key that walks a row down+// thirty places scrolls exactly as far as those thirty places needed.+//+// Instant, not smooth, and that is the point rather than a shortcut: a held Alt+Down+// repeats faster than a smooth scroll settles, so a smooth one would fall behind the+// row it is supposed to be following and then overshoot when the key came up.+const ROW_BUFFER=5;+// The usable top of the chart pane is NOT the pane's top edge: the time axis is+// sticky there, and a section header pins under it. Both are opaque, so a row level+// with either of them is a row the reader cannot see.+function chartViewBand(){+ const pane=timelineEl.getBoundingClientRect();+ let top=pane.top+46*(prefs.fs||1);+ if(pinnedGroup&&pinnedGroup._bDiv){+ const g=pinnedGroup._bDiv.getBoundingClientRect();+ if(g.bottom>top&&g.top<pane.bottom)top=g.bottom;+ }+ return {top,bottom:pane.bottom};+}+// How far to scroll so `box` sits clear of the edge it is heading for by `pad`,+// without ever pushing the row itself out of the band on the other side. Positive+// means scroll down. One function for both lenses, because the question is the same+// one in each and only the band and the scroller differ.+function followDelta(box,band,pad,dir){+ let d=0;+ if(dir>0){+ if(box.bottom+pad>band.bottom)d=box.bottom+pad-band.bottom;+ d=Math.min(d,box.top-band.top);+ }else{+ if(box.top-pad<band.top)d=box.top-pad-band.top;+ d=Math.max(d,box.bottom-band.bottom);+ }+ return d;+}+function followChartRow(r,dir){+ if(prefs.view!=='gantt'||!r||!r._bDiv)return 0;+ const el=r._bDiv;+ if(el.classList.contains('row-collapsed')||!el.offsetParent)return 0;+ const d=followDelta(el.getBoundingClientRect(),chartViewBand(),ROW_BUFFER*(el.offsetHeight||34),dir);+ if(!d)return 0;+ timelineEl.scrollTop+=d;+ // The two panes are one scroll (0.34.0), so the label column is brought along in+ // the same tick rather than left to the scroll event, which fires after the next+ // keypress of a held repeat has already been handled.+ syncLabelScroll();+ updatePinnedHeader();+ return d;+}+function followTableRow(tr,dir){+ const sc=document.querySelector('.table-scroll');+ if(!sc||!tr)return 0;+ const pane=sc.getBoundingClientRect();+ const band={top:Math.max(pane.top,tableHead.getBoundingClientRect().bottom),bottom:pane.bottom};+ const d=followDelta(tr.getBoundingClientRect(),band,ROW_BUFFER*(tr.offsetHeight||26),dir);+ if(d)sc.scrollTop+=d;+ return d;+}+// One entry point, so the two move paths cannot follow the row differently. `rows`+// is the moving selection; the one that matters is the LEADING row in the direction+// of travel, because that is the edge the reader is about to lose sight of.+function followMovedRows(rows,dir){+ const live=rows.filter(Boolean);+ if(!live.length)return 0;+ const lead=live.reduce((a,b)=>((items.indexOf(b)>items.indexOf(a))===(dir>0)?b:a));+ if(prefs.view==='gantt')return followChartRow(lead,dir);+ const tr=visibleRowEls().find(t=>t.dataset.key===rowKey(lead));+ return followTableRow(tr||focusedRowEl(),dir);+} function moveFocusedRow(dir){ const table=prefs.view==='table'; // More than one row selected moves the lot, as a group, in either lens: it is one@@ -1804,6 +2080,7 @@ function moveFocusedRow(dir){ // A milestone's row key is its position among the milestones, so it can change // under the row that carries it. Re-read it rather than keep a stale one. if(table)setFocusedRow(rowKey(r));+ followMovedRows([r],dir); moveHint='"'+(r.name||'a row')+'"'; scheduleKeyMoveSave(); }@@ -2387,6 +2664,22 @@ function diffAndLog(via){ const moved=after.filter((v,i)=>before[i]!==v); evs.push({action:'reorder',order:after,moved:moved.slice(0,8),hint:moveHint||undefined}); }+ // An auto-schedule pass moves every task by a DIFFERENT amount, on purpose, so the+ // plan-shift collapse cannot see it. It is still one press of one button and one+ // Ctrl+Z, and thirty date lines would bury that, so it collapses on the `via`+ // instead of on the shape of the deltas. It runs LAST, after the milestone diff,+ // because a milestone moved off a weekend is part of the same press and its event+ // is not produced until then. The rows it moved are named, up to a readable number+ // of them, so the log still says WHAT moved and not only how many.+ if(via==='auto-schedule'){+ const moved=evs.filter(e=>e.action==='dates'||e.action==='milestone');+ if(moved.length){+ const kept=evs.filter(e=>e.action!=='dates'&&e.action!=='milestone');+ evs.length=0;+ evs.push({action:'auto-schedule',count:moved.length,+ moved:moved.slice(0,12).map(e=>e.name||e.id||e.key)},...kept);+ }+ } lastSnap=cur; moveHint=null; if(!evs.length)return;@@ -2407,7 +2700,7 @@ function diffAndLog(via){ body:JSON.stringify({actor:'user',events:evs.map(stamp)})}) .catch(()=>{}); }-function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}+function scheduleSave(via){diffAndLog(via||'edit');if(critOn)refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);} // The changelog entry goes out immediately but the sidecar write waits 800ms, so // a tab closed inside that window left the log claiming an edit the sidecar never // received. Anything still pending goes out synchronously when the page is being@@ -3076,7 +3369,7 @@ let rlTimer=null, lastPaneW=0, lastPaneH=0; function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{ // A hidden chart measures zero, which would collapse every column and the // milestone cap layout. Switching back to the chart relays out explicitly.- if(prefs.view==='table')return;+ if(prefs.view!=='gantt')return; const w=timelineEl.clientWidth, h=timelineEl.clientHeight; if(w===lastPaneW && h===lastPaneH) return; // guard: ignore observer echo from our own writes lastPaneW=w; lastPaneH=h;@@ -3096,7 +3389,7 @@ window.addEventListener('resize',scheduleRelayout); if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl); // Web-font load changes tag widths and heights after the first measure, so re-run the // milestone cap collision layout once fonts settle, or tags can overlap on a cold load.-if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(msShowsLine()&&prefs.view!=='table')layoutMsCaps();});+if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(msShowsLine()&&prefs.view==='gantt')layoutMsCaps();}); } @@ -3364,9 +3657,10 @@ function drawDeps(){ a.setAttribute('data-from',depId);a.setAttribute('data-to',r.id);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 and the armed state are put back- // from the state that outlives the DOM.+ // 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. paintDepSel();+ paintCritDeps(); } function wireDepRedraw(){ setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);@@ -4815,7 +5109,7 @@ function armDepDelete(sel){ // this one acts on the arrow, not on the rows. function depDeleteReady(){ if(!depMode||!depSel)return false;- if(prefs.view==='table')return false;+ if(prefs.view!=='gantt')return false; if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false; if(datePop&&datePop.classList.contains('show'))return false; if(isTypingTarget(document.activeElement))return false;@@ -5116,6 +5410,271 @@ function shiftPlanTo(newStartISO, via){ return true; } +// ── The dependency graph ─────────────────────────────────────────────────────+//+// One reading of `deps` for the two features that need it, so auto-scheduling and+// the critical path can never disagree about what the plan's edges are. A row is a+// NODE if it is dated: a task with a start and an end, or a milestone with a date.+// An edge is a `deps` entry that resolves to a dated task; a milestone carries no+// id, so nothing can depend on one, but a milestone may itself depend on tasks and+// the chart draws no arrow for that, which is why this reads the data rather than+// the drawn layer.+//+// 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();+ 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;+ n.preds=[];n.succs=[];+ nodes.push(n);byRow.set(r,n);+ if(n.id&&!byId.has(n.id))byId.set(n.id,n);+ });+ nodes.forEach(n=>{+ const d=n.r.deps;+ if(!Array.isArray(d))return;+ // Deduplicated, and a self-edge dropped: either would make a row wait on itself.+ const seen=new Set();+ d.forEach(id=>{+ if(!id||id===n.id||seen.has(id))return;+ const p=byId.get(id);+ if(!p)return;+ seen.add(id);+ n.preds.push(p);p.succs.push(n);+ });+ });+ return {nodes,byId,byRow,edges:nodes.reduce((a,n)=>a+n.preds.length,0)};+}+// 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.+function topoOrder(g){+ const indeg=new Map(),out=[];+ g.nodes.forEach(n=>indeg.set(n,n.preds.length));+ let q=g.nodes.filter(n=>indeg.get(n)===0);+ while(q.length){+ const next=[];+ q.forEach(n=>{+ out.push(n);+ n.succs.forEach(s=>{const k=indeg.get(s)-1;indeg.set(s,k);if(k===0)next.push(s);});+ });+ // Refilled in items order rather than in the order the decrements happened, so+ // the pass does not depend on how the edges were listed in the data file.+ q=next.sort((a,b)=>g.nodes.indexOf(a)-g.nodes.indexOf(b));+ }+ const stuck=g.nodes.filter(n=>!out.includes(n));+ return {order:out,stuck};+}+const planHasDeps=()=>depGraph().edges>0;++// ── Auto-schedule ────────────────────────────────────────────────────────────+//+// One pass over the whole plan: every task with dependencies starts on the first+// business day AFTER its latest predecessor ends, 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.+//+// A task with no 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.+//+// 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+// finishes on the Monday instead, and the same task moved to a Wednesday still+// takes five working days. A task whose current span contains no business day at+// 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 {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;+ if(n.preds.length){+ // The first business day after the LATEST predecessor end. A predecessor left+ // unscheduled because it is in a cycle contributes the date it already has.+ 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(addDays(latest,1));+ }else{+ s=nextBiz(n.start);+ }+ plan.set(n,{start:s,end:n.ms?s:addBiz(s,dur.get(n))});+ });+ // A cycle member is left exactly as it is, and so is anything downstream of one:+ // there is no honest answer for a row that waits on itself, and moving it to a+ // guess would be worse than leaving it where the author put it.+ const moves=[];+ plan.forEach((v,n)=>{+ 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};+}+// 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.+function runAutoSchedule(){+ const res=autoScheduleCompute();+ 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);+ return res;+ }+ snapshotGroup(items.filter(r=>r.id).map(r=>r.id));+ res.moves.forEach(m=>{+ if(m.n.ms)m.n.r.date=m.date;+ else{m.n.r.start=m.start;m.n.r.end=m.end;}+ });+ rebuildTimeline();+ Object.keys(barEls).forEach(id=>updateBar(id));+ items.filter(r=>r.type==='milestone').forEach(r=>updateMilestone(r));+ refreshChartGeometry();+ 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';+ if(res.stuck.length)msg+='. '+res.stuck.length+' left alone: '+cycleNames(res.stuck);+ undoToast.innerHTML=esc(msg)+' · <kbd>Ctrl+Z</kbd>';+ showToast(undoToast,res.stuck.length?7000:4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});+ return res;+}+function cycleNames(stuck){+ const nm=stuck.map(n=>n.r.name||n.id||'a row');+ return nm.slice(0,4).join(', ')+(nm.length>4?', and '+(nm.length-4)+' more':'')+' (dependency cycle)';+}++// ── Critical path ────────────────────────────────────────────────────────────+//+// Classic CPM, forward and backward, in BUSINESS days, over the dates the plan has+// right now. It never reschedules anything: the two buttons are independent, and a+// reader who wants to know which chain is driving the end date should be able to+// ask that of the plan as it stands and not of a plan this app rewrote first.+//+// The forward pass takes each node's own start as a floor, so a row the author put+// later than its dependencies allow is respected rather than compressed. A row with+// no predecessors therefore simply keeps its own early start, which is what makes+// the numbers below match what is drawn.+//+// Only rows that are IN the dependency graph can be critical: an isolated task that+// happens to run past everything else is not a chain through anything. Durations+// are inclusive business days, so a chain's length is the sum of its rows' working+// days and a milestone contributes nothing.+function criticalSet(){+ const g=depGraph();+ const inGraph=g.nodes.filter(n=>n.preds.length||n.succs.length);+ if(!inGraph.length)return {crit:new Set(),edges:new Set(),cycle:[],float:new Map()};+ const {order,stuck}=topoOrder(g);+ const live=order.filter(n=>n.preds.length||n.succs.length);+ const dur=new Map(),ES=new Map(),EF=new Map(),LS=new Map(),LF=new Map();+ live.forEach(n=>dur.set(n,n.ms?0:Math.max(1,bizBetween(n.start,n.end))));+ // Business-day INDEX, so every comparison below is an integer count of working+ // days and the weekend never enters the arithmetic. Anchored at the earliest date+ // in the plan; only differences are ever used, so the anchor itself is arbitrary.+ const base=live.reduce((m,n)=>(m===null||dayNum(n.start)<dayNum(m))?n.start:m,null);+ const idx=ds=>bizBetween(base,nextBiz(ds))-1;+ live.forEach(n=>{+ let es=idx(n.start);+ n.preds.forEach(p=>{ if(EF.has(p))es=Math.max(es,EF.get(p)+1); });+ ES.set(n,es);+ EF.set(n,es+Math.max(dur.get(n),1)-1);+ });+ const finish=live.reduce((m,n)=>Math.max(m,EF.get(n)),-Infinity);+ for(let i=live.length-1;i>=0;i--){+ const n=live[i];+ let lf=finish;+ n.succs.forEach(s=>{ if(LS.has(s))lf=Math.min(lf,LS.get(s)-1); });+ LF.set(n,lf);+ LS.set(n,lf-Math.max(dur.get(n),1)+1);+ }+ const flt=new Map(),crit=new Set();+ live.forEach(n=>{ const f=LS.get(n)-ES.get(n); flt.set(n,f); if(f===0)crit.add(n); });+ // An EDGE is critical when both its ends are and the successor really does follow+ // the predecessor with no slack between them. Without that last test two separate+ // zero-float chains that happen to touch would light up the link between them.+ const edges=new Set();+ crit.forEach(n=>n.preds.forEach(p=>{+ if(crit.has(p)&&EF.get(p)+1===ES.get(n)&&p.id&&n.id)edges.add(p.id+'>'+n.id);+ }));+ return {crit,edges,cycle:stuck,float:flt,finish,graph:g};+}+let critOn=false;+// The glow is a CLASS on the elements that are already there, added and removed by+// this one function, so there is exactly one place that decides what is lit. It is+// idempotent: everything is cleared first, which is what makes "recompute" and+// "turn off" the same code path with a different flag.+function refreshCritical(){+ document.querySelectorAll('.bar.crit').forEach(el=>el.classList.remove('crit'));+ document.querySelectorAll('.crit-ms').forEach(el=>el.classList.remove('crit-ms'));+ 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();+ critInfo=info;+ info.crit.forEach(n=>{+ if(n.id&&barEls[n.id])barEls[n.id].bar.classList.add('crit');+ if(n.ms){+ if(n.r._msEl)n.r._msEl.classList.add('crit-ms');+ if(n.r._msLine)n.r._msLine.classList.add('crit-ms');+ }+ });+ paintCritDeps();+ paintCritTable();+ return info;+}+// The arrows are thrown away and rebuilt on every geometry change, so their half of+// the glow goes back on from state rather than being recomputed, exactly as the pick+// and the armed state are.+function paintCritDeps(){+ if(!depSvg)return;+ depSvg.querySelectorAll('.crit').forEach(el=>el.classList.remove('crit'));+ if(!critOn||!critInfo)return;+ depSvg.querySelectorAll('[data-from]').forEach(el=>{+ if(critInfo.edges.has(el.getAttribute('data-from')+'>'+el.getAttribute('data-to')))el.classList.add('crit');+ });+}+let critInfo=null;+// The table's half of the same answer, so switching lens does not lose it. It+// reads the set the last compute produced rather than recomputing, because the+// table is re-rendered on every filter keystroke and the graph has not changed for+// any of them. The arrows have the same relationship with drawDeps: the layer is+// rebuilt wholesale and the classes are put back from state afterwards, which is+// what paintDepSel has always done.+function paintCritTable(){+ if(!tableBody)return;+ tableBody.querySelectorAll('tr.crit-row').forEach(el=>el.classList.remove('crit-row'));+ if(!critOn||!critInfo)return;+ const keys=new Set();+ critInfo.crit.forEach(n=>keys.add(rowKey(n.r)));+ tableBody.querySelectorAll('tr[data-key]').forEach(tr=>{+ if(keys.has(tr.dataset.key))tr.classList.add('crit-row');+ });+}+function setCritical(on){+ critOn=!!on;+ document.documentElement.setAttribute('data-crit',critOn?'on':'off');+ const b=$('btnCrit');+ if(b)b.classList.toggle('on',critOn);+ const info=refreshCritical();+ if(critOn&&info){+ const n=info.crit.size;+ let msg=n?'Critical path: '+n+' item'+(n===1?'':'s')+', no slack':'No critical path: nothing in this plan depends on anything';+ if(info.cycle&&info.cycle.length)msg+='. '+info.cycle.length+' left out: '+cycleNames(info.cycle);+ flash(saveInd,msg,info.cycle&&info.cycle.length?6000:3200);+ }+}+ function syncThemeBtn(){ $('themeLabel').textContent = prefs.theme==='dark' ? 'Light' : 'Dark'; } function wireThemeButton(){ $('btnTheme').addEventListener('click',()=>{@@ -6016,6 +6575,7 @@ function renderTable(){ +'</tr>'; }).join(''); markFocusedRow();+ paintCritTable(); } // ── Drag a lane ──@@ -6934,7 +7494,7 @@ function armDelete(victims){ // the arrow keys use, so a Delete inside a name field, a date input or an open panel // still only edits text. function chartDeleteReady(){- if(prefs.view==='table')return false;+ if(prefs.view!=='gantt')return false; if(!selected.size)return false; if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false; if(datePop&&datePop.classList.contains('show'))return false;@@ -6988,9 +7548,197 @@ $('tblClear').addEventListener('click',()=>{ }); } ++// ── Analytics ───────────────────────────────────────────────────────────────+//+// The third lens. Every number on it is derived from `items` and `phases` at the+// moment it is drawn, so there is no second copy of the plan to fall out of step+// with the first one: switching lens re-derives, and an edit made in either of the+// other two is already in the model by the time this runs.+//+// THE HOUR RULE, stated once and used everywhere below: a task's effort is the+// BUSINESS days in its span, counting both ends, times eight. A forty-hour week, so+// a task that runs Monday to Friday is 40h and the same task dragged across the+// weekend to the following Tuesday is 56h and not 64h. A milestone is a date rather+// than work and carries no hours at all.+const HOURS_PER_DAY=8;+function taskHours(r){ return isMs(r)?0:(r.start&&r.end?bizBetween(r.start,r.end)*HOURS_PER_DAY:0); }+// Hours, always in hours. A column that says 80h on one row and 23d on the next is a+// column the reader has to convert in their head before they can compare two numbers+// in it, which is the one thing a totals table exists to save them. The separator is+// what makes a four-figure number readable instead.+function hrs(h){ return (h||0).toLocaleString()+'h'; }+function pct(a,b){ return b>0?Math.round(a*1000/b)/10:0; }+function anBar(share){ return '<div class="an-bar"><i style="width:'+Math.max(0,Math.min(100,share))+'%"></i></div>'; }+// Today as a plan date string, read at render time and not at load, so a tab left+// open overnight does not keep counting yesterday as today.+function anToday(){ return isoDay(new Date()); }+function daysFromToday(ds){ return ds?daysBetween(anToday(),ds):null; }+function relDays(n){+ if(n===null)return '';+ if(n===0)return 'today';+ return n>0?('in '+n+'d'):(Math.abs(n)+'d ago');+}+// The whole model, read once per render. Every card below takes a slice of this, so+// the totals on one card and the rows on another cannot disagree about the plan.+function analyticsModel(){+ const rows=items.filter(r=>!isGroup(r));+ const tasks=rows.filter(r=>!isMs(r)&&r.start&&r.end);+ const ms=rows.filter(r=>isMs(r)&&r.date);+ const totalHours=tasks.reduce((a,r)=>a+taskHours(r),0);+ const dates=[];+ tasks.forEach(r=>{dates.push(r.start,r.end);});+ ms.forEach(r=>dates.push(r.date));+ const first=dates.length?dates.reduce((m,d)=>dayNum(d)<dayNum(m)?d:m):'';+ const last=dates.length?dates.reduce((m,d)=>dayNum(d)>dayNum(m)?d:m):'';+ const spanBiz=first&&last?bizBetween(first,last):0;+ // People. The unassigned bucket is a real row and not a footnote: work with nobody+ // against it is the single most useful thing this page can point at.+ const byOwner=new Map();+ const bucket=o=>{+ const k=o||'';+ if(!byOwner.has(k))byOwner.set(k,{owner:k,hours:0,tasks:0,ms:0,status:{}});+ return byOwner.get(k);+ };+ rows.forEach(r=>{+ const b=bucket(ownerOf(r));+ const st=statusOf(r)||'not-started';+ b.status[st]=(b.status[st]||0)+1;+ if(isMs(r)){ if(r.date)b.ms++; return; }+ if(!r.start||!r.end)return;+ b.tasks++; b.hours+=taskHours(r);+ });+ const people=[...byOwner.values()]+ .filter(b=>b.tasks||b.ms)+ // Hours first, then task count, then the unassigned bucket after the named people+ // it is tied with, then the name. Total, so a redraw that changed nothing puts+ // every row back exactly where it was.+ .sort((a,b)=>b.hours-a.hours||b.tasks-a.tasks||(a.owner?0:1)-(b.owner?0:1)||a.owner.localeCompare(b.owner));+ const status={};+ STATUS_ORDER.forEach(k=>status[k]=0);+ rows.forEach(r=>{const k=statusOf(r)||'not-started';status[k]=(status[k]||0)+1;});+ const lanes=phases.map((ph,i)=>{+ const mine=rows.filter(r=>r.phase===i);+ const mt=mine.filter(r=>!isMs(r)&&r.start&&r.end);+ const ds=[];+ mt.forEach(r=>{ds.push(r.start,r.end);});+ mine.filter(r=>isMs(r)&&r.date).forEach(r=>ds.push(r.date));+ return {i,name:ph.name,color:phaseColor(i),items:mine.length,+ hours:mt.reduce((a,r)=>a+taskHours(r),0),+ from:ds.length?ds.reduce((m,d)=>dayNum(d)<dayNum(m)?d:m):'',+ to:ds.length?ds.reduce((m,d)=>dayNum(d)>dayNum(m)?d:m):''};+ });+ const today=anToday();+ const overdue=rows.filter(r=>{+ const dl=deadlineOf(r);+ return dl&&statusOf(r)!=='complete'&&dayNum(dl)<dayNum(today);+ }).sort((a,b)=>dayNum(deadlineOf(a))-dayNum(deadlineOf(b)));+ const g=depGraph();+ const cyc=topoOrder(g).stuck;+ const noDeps=g.nodes.filter(n=>!n.preds.length&&!n.succs.length).length;+ return {rows,tasks,ms:ms.sort((a,b)=>dayNum(a.date)-dayNum(b.date)),totalHours,+ first,last,spanBiz,people,status,lanes,overdue,+ deps:{edges:g.edges,isolated:noDeps,cycle:cyc},today};+}+function renderAnalytics(){+ const el=$('anScroll');+ if(!el)return null;+ const m=analyticsModel();+ const card=(cls,title,sub,body)=>'<div class="an-card'+(cls?' '+cls:'')+'"><h3>'+esc(title)+'</h3>'+ +(sub?'<p class="an-sub">'+esc(sub)+'</p>':'')+body+'</div>';+ const stat=(v,u,k)=>'<div class="an-stat"><div class="v">'+esc(v)+(u?'<small>'+esc(u)+'</small>':'')+'</div><div class="k">'+esc(k)+'</div></div>';+ const out=[];++ // 1. the plan in five numbers+ out.push(card('wide','Project total','Business days only, Monday to Friday, at '+HOURS_PER_DAY+'h a day, so a five day task is '+(HOURS_PER_DAY*5)+'h.',+ '<div class="an-hero">'+ +stat(m.totalHours.toLocaleString(),'h','total effort')+ +stat(String(m.spanBiz),'business days','calendar span'+(m.first?', '+fmt(m.first)+' to '+fmt(m.last):''))+ +stat(String(m.tasks.length),'','dated tasks')+ +stat(String(m.ms.length),'','milestones')+ +stat(String(m.people.length),'','people, including unassigned')+ +'</div>'));++ // 2. hours per person+ const ph=m.people.map(b=>'<tr>'+ +'<td class="name'+(b.owner?'':' muted')+'">'+esc(b.owner||'unassigned')+'</td>'+ +'<td style="width:26%">'+anBar(pct(b.hours,m.totalHours))+'</td>'+ +'<td class="n">'+esc(hrs(b.hours))+'</td>'+ +'<td class="n muted">'+pct(b.hours,m.totalHours)+'%</td>'+ +'<td class="n muted">'+b.tasks+(b.ms?' +'+b.ms+'M':'')+'</td></tr>').join('');+ out.push(card('','Hours per person','Every owner in the plan, unassigned included. Milestones are dates rather than work, so they carry no hours and are counted separately as +nM.',+ m.people.length?('<table class="an-table"><thead><tr><th>Owner</th><th>Share</th><th class="n">Hours</th><th class="n">%</th><th class="n">Items</th></tr></thead><tbody>'+ +ph+'<tr class="total"><td>Total</td><td></td><td class="n">'+esc(hrs(m.totalHours))+'</td><td class="n">100%</td><td class="n">'+m.tasks.length+(m.ms.length?' +'+m.ms.length+'M':'')+'</td></tr>'+ +'</tbody></table>'):'<div class="an-empty">Nothing in this plan carries dates yet.</div>'));++ // 3. status+ const stTotal=Object.values(m.status).reduce((a,b)=>a+b,0);+ const stRows=STATUS_ORDER.map(k=>'<tr><td class="name"><span class="tag '+statusTagFor(k)+'">'+esc(statusTextFor(k))+'</span></td>'+ +'<td style="width:36%">'+anBar(pct(m.status[k]||0,stTotal))+'</td>'+ +'<td class="n">'+(m.status[k]||0)+'</td><td class="n muted">'+pct(m.status[k]||0,stTotal)+'%</td></tr>').join('');+ out.push(card('','Status','Every task and milestone in the plan, by the status someone set on it. A row nobody has touched counts as not started.',+ '<table class="an-table"><thead><tr><th>Status</th><th>Share</th><th class="n">Items</th><th class="n">%</th></tr></thead><tbody>'+stRows+'</tbody></table>'));++ // 4. status per person+ const pph=m.people.map(b=>'<tr><td class="name'+(b.owner?'':' muted')+'">'+esc(b.owner||'unassigned')+'</td>'+ +STATUS_ORDER.map(k=>'<td class="n'+((b.status[k]||0)?'':' muted')+'">'+(b.status[k]||0)+'</td>').join('')+ +'</tr>').join('');+ out.push(card('wide','Status per person','The same four counts, one row per owner.',+ m.people.length?('<table class="an-table tight"><thead><tr><th>Owner</th>'+ +STATUS_ORDER.map(k=>'<th class="n">'+esc(statusTextFor(k))+'</th>').join('')+ +'</tr></thead><tbody>'+pph+'</tbody></table>'):'<div class="an-empty">Nobody owns anything yet.</div>'));++ // 5. phases+ const lr=m.lanes.map(l=>'<tr><td class="name"><span class="an-chip" style="background:'+esc(l.color)+'"></span>'+esc(l.name)+'</td>'+ +'<td class="n">'+l.items+'</td><td class="n">'+esc(hrs(l.hours))+'</td>'+ +'<td class="n muted">'+(l.from?esc(fmt(l.from))+' to '+esc(fmt(l.to)):'no dates')+'</td></tr>').join('');+ out.push(card('wide','Per section','Items, effort and the range the section actually covers, which is derived from its rows and not declared.',+ m.lanes.length?('<table class="an-table"><thead><tr><th>Section</th><th class="n">Items</th><th class="n">Hours</th><th class="n">Range</th></tr></thead><tbody>'+ +lr+'<tr class="total"><td>Total</td><td class="n">'+m.rows.length+'</td><td class="n">'+esc(hrs(m.totalHours))+'</td>'+ +'<td class="n">'+(m.first?esc(fmt(m.first))+' to '+esc(fmt(m.last)):'')+'</td></tr></tbody></table>'):'<div class="an-empty">This plan has no sections.</div>'));++ // 6. milestones+ const mr=m.ms.map(r=>{const d=daysFromToday(r.date);+ return '<tr><td class="name">'+esc(r.name)+'</td><td class="n">'+esc(fmt(r.date))+'</td>'+ +'<td class="n'+(d!==null&&d<0?' muted':'')+'">'+esc(relDays(d))+'</td>'+ +'<td class="n muted">'+esc(ownerOf(r)||'unassigned')+'</td></tr>';}).join('');+ out.push(card('','Milestones','Counted from today, '+fmt(m.today)+'.',+ m.ms.length?('<table class="an-table"><thead><tr><th>Milestone</th><th class="n">Date</th><th class="n">From today</th><th class="n">Owner</th></tr></thead><tbody>'+mr+'</tbody></table>')+ :'<div class="an-empty">This plan has no dated milestones.</div>'));++ // 7. dependencies+ const dep=m.deps;+ out.push(card('','Dependencies','What waits on what. The graph crosses sections freely, which is why these counts are for the whole plan.',+ '<table class="an-table"><tbody>'+ +'<tr><td class="name">Dependency links</td><td class="n">'+dep.edges+'</td></tr>'+ +'<tr><td class="name">Rows in the graph</td><td class="n">'+(m.rows.filter(r=>(!isMs(r)&&r.start&&r.end)||(isMs(r)&&r.date)).length-dep.isolated)+'</td></tr>'+ +'<tr><td class="name">Rows outside it entirely</td><td class="n">'+dep.isolated+'</td></tr>'+ +(dep.cycle.length?'<tr><td class="name an-warn">In a dependency cycle</td><td class="n an-warn">'+dep.cycle.length+'</td></tr>':'')+ +'</tbody></table>'+ +(dep.cycle.length?'<div class="an-foot an-warn">'+esc(dep.cycle.map(n=>n.r.name||n.id).slice(0,6).join(', '))+' wait on each other, directly or in a ring. Auto-schedule leaves these exactly where they are.</div>':'')));++ // 8. overdue+ const or=m.overdue.map(r=>{const dl=deadlineOf(r),d=daysFromToday(dl);+ return '<tr><td class="name">'+(isMs(r)?'<span class="muted">◆ </span>':'')+esc(r.name)+'</td>'+ +'<td class="n">'+esc(ownerOf(r)||'unassigned')+'</td>'+ +'<td class="n">'+esc(fmt(dl))+'</td><td class="n an-late">'+esc(relDays(d))+'</td>'+ +'<td class="n muted">'+esc(statusTextFor(statusOf(r))||'none')+'</td></tr>';}).join('');+ out.push(card('wide',m.overdue.length?('Overdue: '+m.overdue.length+' item'+(m.overdue.length===1?'':'s')):'Overdue',+ 'A deadline in the past with a status that is not complete, as of '+fmt(m.today)+'.',+ m.overdue.length?('<table class="an-table"><thead><tr><th>Item</th><th class="n">Owner</th><th class="n">Deadline</th><th class="n">Late by</th><th class="n">Status</th></tr></thead><tbody>'+or+'</tbody></table>')+ :'<div class="an-empty">Nothing is overdue.</div>'));++ el.innerHTML='<div class="an-grid">'+out.join('')+'</div>';+ return m;+}+ // ── View switching ──+// Three lenses now. `VIEWS` is the one list: the toggle is built from the markup,+// the preference is validated against this, and an unknown value (an old preference,+// a hand-edited file) falls back to the chart rather than showing nothing.+const VIEWS=['gantt','table','analytics']; function applyView(){- const v = (prefs.view==='table') ? 'table' : 'gantt';+ const v = VIEWS.includes(prefs.view) ? prefs.view : 'gantt'; document.documentElement.setAttribute('data-view',v); const vt=$('viewToggle'); if(vt)vt.querySelectorAll('.vt-btn').forEach(b=>{@@ -7000,13 +7748,17 @@ function applyView(){ }); } function setView(v){- if(v!=='table'&&v!=='gantt')return;+ if(!VIEWS.includes(v))return; if(prefs.view===v)return; // The marks live on chart rows. Leaving the lens leaves the question behind too, // rather than carrying an armed delete into a view that never showed it. disarmDelete();- if(v==='table'&&depMode)setDepMode(false); // the mode is a chart mode+ if(v!=='gantt'&&depMode)setDepMode(false); // the mode is a chart mode prefs.view=v;savePrefs();applyView();+ // Analytics is derived from the model, so it is rendered on the way IN and never+ // kept warm: whatever changed in another lens is already in `items`, and reading+ // it once here is cheaper and less to get wrong than keeping a second copy live.+ if(v==='analytics'){renderAnalytics();return;} if(v==='table'){renderTable();return;} // The chart measured nothing while it was hidden, so lay it out from scratch // once it is back on screen. Through the app's own debounced path with the echo@@ -7015,6 +7767,37 @@ function setView(v){ // renamed or deleted in the table came back overlapping. lastPaneW=lastPaneH=lastRelayoutW=-1; scheduleRelayout();+ // The bars and the arrows were rebuilt while the glow was off screen, so it goes+ // back on with them. Deferred behind the relayout, since the arrows do not exist+ // until it has run.+ if(critOn)setTimeout(()=>refreshCritical(),0);+}+// ── The two plan-level buttons ──+//+// 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.+function syncPlanCtl(){+ const has=planHasDeps();+ const a=$('btnAuto'), c=$('btnCrit');+ if(a){+ a.disabled=!has;+ a.title=has?'Align all task dates so dependencies line up finish-to-start, skipping weekends'+ :'Auto-schedule needs dependencies: nothing in this plan waits on anything yet';+ }+ if(c){+ c.disabled=!has;+ c.title=has?'Critical path: light up the chain of work with no slack in it, the one that decides the finish date'+ :'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);+ }+}+function wirePlanCtl(){+ const a=$('btnAuto'), c=$('btnCrit');+ if(a)a.addEventListener('click',()=>{ if(!a.disabled)runAutoSchedule(); });+ if(c)c.addEventListener('click',()=>{ if(!c.disabled)setCritical(!critOn); });+ syncPlanCtl(); } async function startApp(){ $('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);});@@ -7043,6 +7826,10 @@ drawDeps(); // "What's mine" is a standing question, so the owner filter survives a reload // (syncOwnerControls picks prefs.tableOwner up on this first render). renderTable();+// The sidecar can add or drop a dependency, so what the two plan buttons are allowed+// to do is only settled once it is in.+syncPlanCtl();+if(prefs.view==='analytics')renderAnalytics(); // Say it after the first paint, so the message sits over a chart that clearly // still works rather than over a blank page. if(UNDATED.length){@@ -7125,6 +7912,7 @@ async function init(){ wireNewButton(); wireFontScale(); wireFitButton();+ wirePlanCtl(); wireDatePicker(); wireLabelColumnResize(); wireEditorDrag();
package.json+22−4@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.41.0",- "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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.42.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 business days, and one more lights up the critical path. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for hours per person, status, 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"@@ -27,9 +27,15 @@ "export the schedule as SVG", "who owns what on this project", "show the plan as a table",- "what are my tasks and deadlines"+ "what are my tasks and deadlines",+ "auto-schedule this plan",+ "line the dependencies up finish to start",+ "what is the critical path",+ "how many hours is each person carrying",+ "show me the project analytics",+ "what is overdue on this plan" ],- "discovery_pitch": "Reach for adom-gantt when a project's phases, tasks, milestones and dependencies need to be seen and edited on a timeline rather than read as a list.",+ "discovery_pitch": "Reach for adom-gantt when a project's phases, tasks, milestones and dependencies need to be seen and edited on a timeline rather than read as a list, when the dates need aligning to the dependencies in one pass, or when someone asks how many hours the plan is and who is carrying them.", "sample_prompts": [ { "label": "View a plan",@@ -46,6 +52,18 @@ { "label": "What is mine", "prompt": "open the DC demo plan as a table and filter it to my tasks"+ },+ {+ "label": "Auto-schedule",+ "prompt": "auto-schedule the plan so every dependency lines up and nothing lands on a weekend"+ },+ {+ "label": "Critical path",+ "prompt": "show me the critical path through this plan"+ },+ {+ "label": "Hours per person",+ "prompt": "open the analytics page and tell me who is carrying the most hours" } ], "scripts": {
page.json+23−5@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.41.0",- "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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.42.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 business days, and one more lights up the critical path. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for hours per person, status, 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"@@ -27,9 +27,15 @@ "export the schedule as SVG", "who owns what on this project", "show the plan as a table",- "what are my tasks and deadlines"+ "what are my tasks and deadlines",+ "auto-schedule this plan",+ "line the dependencies up finish to start",+ "what is the critical path",+ "how many hours is each person carrying",+ "show me the project analytics",+ "what is overdue on this plan" ],- "discovery_pitch": "Reach for adom-gantt when a project's phases, tasks, milestones and dependencies need to be seen and edited on a timeline rather than read as a list.",+ "discovery_pitch": "Reach for adom-gantt when a project's phases, tasks, milestones and dependencies need to be seen and edited on a timeline rather than read as a list, when the dates need aligning to the dependencies in one pass, or when someone asks how many hours the plan is and who is carrying them.", "sample_prompts": [ { "label": "View a plan",@@ -46,6 +52,18 @@ { "label": "What is mine", "prompt": "open the DC demo plan as a table and filter it to my tasks"+ },+ {+ "label": "Auto-schedule",+ "prompt": "auto-schedule the plan so every dependency lines up and nothing lands on a weekend"+ },+ {+ "label": "Critical path",+ "prompt": "show me the critical path through this plan"+ },+ {+ "label": "Hours per person",+ "prompt": "open the analytics page and tell me who is carrying the most hours" } ], "scripts": {@@ -63,4 +81,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}
server.js+5@@ -632,6 +632,11 @@ function describeEvent(e) { if (action === 'import') { return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`; }+ 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}`;+ } if (action === 'plan.schedule') { const ov = Array.isArray(e.overrides) ? e.overrides.length : 0; const kept = e.kept_drews_newer_edits ? `, keeping ${e.kept_drews_newer_edits} edit(s) made while it was being worked out` : '';