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.56.0: copy and paste rows (Ctrl+C / Ctrl+V), with dependencies among the copied set remapped onto the copies; status vocabulary reordered to the lifecycle (not started, in progress, complete, blocked, cancelled) and the table's status filter rebuilt from STATUS_ORDER so it can no longer drift
7 files changed
+691−27
README.md+6−2@@ -16,7 +16,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **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. -**Five statuses.** Not started, in progress, blocked, complete, cancelled. Plans written against the older "live" and "planned" still read correctly without being changed, and so do the American "canceled" and a tracker's "won't do".+**Five statuses, in lifecycle order.** Not started, in progress, complete, then the two ways off that road: blocked and cancelled. Every surface takes that order from one list, so the panels, the create panel, the status filter, the inline pill list, the analytics status card, the per-person card's five columns and the table's status-column sort all read the same way, and sorting by status ranks a finished row ahead of a stuck one. Plans written against the older "live" and "planned" still read correctly without being changed, and so do the American "canceled" and a tracker's "won't do". **Cancelled is work that will not happen**, which is a different thing from work that is finished. The row stays on the plan, because deleting it would lose why it was ever there and what used to depend on it, but its HOURS leave every total: the project total, the owner's load, the section's effort and every percentage computed from them. Its own numbers are untouched, so the bar, the tooltip, the export and Ctrl+Z all still say what they said. It still counts as an item and it still has a column on the status cards, because it is a decision somebody made and worth seeing. It is never overdue. On the chart the bar is dimmed and the row is muted, well short of the fade a filtered-out row gets, and the pill carries a line through it. @@ -40,7 +40,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Click-to-edit.** Click any bar to open an inline editor with date pickers and dependency management. Drag bar edges to resize start/end dates. Drag bar body to slide dates while preserving duration. -**Status is editable from the chart.** Click a task's status pill in the label column to change it from a small on-brand dropdown (not started / in progress / blocked / complete / cancelled). It is the same edit as the table's status cell: one undo, logged to the changelog, reflected in both lenses.+**Status is editable from the chart.** Click a task's status pill in the label column to change it from a small on-brand dropdown (not started / in progress / complete / blocked / cancelled). It is the same edit as the table's status cell: one undo, logged to the changelog, reflected in both lenses. **Esc gets you out of a field.** Every text box in the app hands the keyboard back when you press Escape: the table's filter box, the panel fields, the section editors, the type-in on the owner list, the date fields. The text you typed stays exactly where it is, so a filter you built is never wiped by a stray key, and the shortcuts are live again immediately rather than after a trip to the mouse. One Escape is one action: it never blurs a field AND closes something behind it in the same press. @@ -56,6 +56,8 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Multi-select.** Ctrl+click to toggle individual rows, Shift+click for range select (Windows Explorer style), in either lens. Tasks and milestones both, since both have a permanent id to be named by; lane headers do not join. The batch editor applies relative date shifts across the selected tasks without flattening dates, and says how many milestones it is leaving alone: a milestone has one date and no duration, so shifting its end has no meaning. Its **Delete** removes the whole selection, milestones included, in one press and one Ctrl+Z: the same path the chart's armed Delete key uses, so it is one undo entry, one changelog batch, dependencies on the departed rows dropped and their ids retired either way. +**Copy and paste rows, and the chain comes with them.** Select one row or several, **Ctrl+C**, then **Ctrl+V**. With a row selected the copies land DIRECTLY BELOW it, in its lane, recoloured to match; with nothing selected they go to the end of the lane you are looking at (on the chart, the lane whose header is pinned under the axis; in the table, the lane of the topmost row fully clear of the sticky header). The part that makes it worth having is the **dependencies**: links AMONG the copied rows are remapped onto the copies, so duplicating a five-step build across another workcell gives you a five-step build and not five rows all hanging off the originals. Links pointing OUTSIDE the copied set are kept exactly as they are, still naming the original, because that is a real fact about the work. Nothing downstream of an original is touched: a task that waited on the row you copied still waits on that row and not on both. Names are KEPT, so you can rename after rather than undoing " (copy)" five times; the exception is a name that would collide inside the destination lane, which gets " 2", then " 3", counting past whatever is already there. Every copy gets a permanent id of its own, minted by the server; if that mint cannot be made the paste is refused whole and says so, rather than leaving half a chain. Milestones copy too. The pasted rows become the selection and are scrolled into view, ready for Alt+Arrow or a drag, and the whole paste is one Ctrl+Z and one line in the changelog.+ **Vertical reorder.** Drag bars or labels up/down to reorder rows. Drop indicator shows insertion point. Multi-select reorder moves all selected items together. **Dependency arrows.** Teal routes with rounded elbows and arrowheads show task relationships: out of the source bar's end sideways, down, and into the next bar's start sideways again, steering round any bar in the way rather than through it. Click any bar to see incoming ("needs") and outgoing ("blocks") dependencies. Add or remove dependencies inline.@@ -321,6 +323,8 @@ 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 **Deps** button glows in the accent for as long as the mode is on, and its tooltip is the instruction. The row under the pointer lights up across both panes, tinted in its own phase colour, so you can see which task you are about to click. 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 |+| Ctrl+C | Copy the selected rows, tasks and milestones alike, to the app's own clipboard, ready for Ctrl+V. It ALSO puts their permanent IDs and names on the system clipboard, one per line, which is what it has always done and what an AI batch edit reads. Answers only when the app is idle and there is no live text selection on the page, so a Ctrl+C aimed at text you have highlighted, or typed into any field, is the browser's copy and never this |+| Ctrl+V | Paste the copied rows as new rows. With a row selected they go DIRECTLY BELOW it (below the last of a set, in plan order) and into its lane; with nothing selected they go to the end of the lane you are looking at. Dependencies among the copied rows are remapped onto the copies, so a copied chain is a chain; dependencies pointing outside the set still name the originals, and nothing downstream of an original is changed. The whole paste is one Ctrl+Z | | 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, and a lane that is collapsed OPENS the moment the moving row lands in it, so you can see what you are moving among. 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. With several rows selected the whole set moves as a group; if the selection has gaps in it, the FIRST press closes it up into one block at the topmost selected row and every press after that moves the block | | 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 | Leave the field first: pressed with the caret in any text box it takes the focus back out of it, so the keyboard shortcuts work again on the very next keypress. It does not clear the box, and it does not do anything else in the same press. Fields that already have an Esc of their own keep it (a name editor cancels, a dropdown's type-in closes the list, a panel field closes the panel). With nothing focused it goes on as before: cancel the open field, then clear the row focus, then clear the selection |
SKILL.md+10−1@@ -168,7 +168,7 @@ items. | phase | number | yes | Index into phases[] | | owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter | | provenance | object or string | no | Where this row came from. `{"label":"POR","url":"https://..."}` or just `"Drew added this"`. Only http and https urls render as links; anything else shows as text |-| status | string | no | One of: "not-started", "in-progress", "blocked", "complete", "cancelled". The old "live" and "planned" are still read and shown as in progress / not started, and "canceled", "won't do", "abandoned" and "dropped" all read as cancelled |+| status | string | no | One of: "not-started", "in-progress", "complete", "blocked", "cancelled". That is the order the app shows them in everywhere, and since 0.56.0 it is the LIFECYCLE order: the road a row travels when nothing goes wrong, then the two ways off it. The order comes from one list (`STATUS_ORDER`), so the item and create panels, the table's status filter, the inline pill list, the analytics status card, the per-person card's five columns and the table's status-column collation cannot disagree, and sorting by status now ranks a complete row ahead of a blocked one. Membership is unchanged: the old "live" and "planned" are still read and shown as in progress / not started, and "canceled", "won't do", "abandoned" and "dropped" all read as cancelled | | description | string | no | Tooltip body text, and the Notes column in the table | | deps | string[] | no | Array of task IDs this depends on | | note | string | no | Short label drawn on the bar itself, e.g. `6m` or `Y1`. Keep it to a few characters |@@ -243,6 +243,15 @@ 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+- **Ctrl+C** copies the selected rows, tasks and milestones alike, to the app's OWN clipboard, ready for Ctrl+V. It is in-memory: no serialisation, no reading the system clipboard back, nothing that could paste one version of a plan into another. It ALSO writes the selected rows' uids and names to the SYSTEM clipboard, one row per line, tab-separated, which is what Ctrl+C has done since 0.44.0 and what an AI batch edit reads; that half is silent and its on-screen fallback box is suppressed, because a textarea grabbing the focus would eat the Ctrl+V about to follow. Toasts "Copied N tasks". Writes NOTHING to the server: no uid is minted, no sidecar, no changelog line+- **Ctrl+V** pastes the copied rows as NEW rows. Two placement rules, decided by whether anything is selected. **With a selection**, directly BELOW it, and below the LAST of a set in plan order, in that row's phase, so pasting under a row in another lane moves the copies into that lane and recolours bars, phase dots and milestone rules. **With nothing selected**, at the END of the ACTIVE lane, meaning the one most in view: on the chart that is the PINNED lane (the group header the sticky-header logic currently has stuck under the axis; scrolled above the first header, the first lane in plan order), and in the table it is the lane of the topmost row whose top edge is at or below the bottom of the sticky header, whatever the sort. Both are read at the moment of the paste, so scrolling changes the answer and nothing has to be clicked to choose a lane+- **What a pasted row carries.** Name, dates (a milestone's `date`, a task's `start` and `end`), owner, status, description and note, snapshotted at the moment of the COPY, so editing an original afterwards does not change what comes out and deleting it does not empty the clipboard. Provenance is the CREATED-row convention (`Added by <you>`), not the original's: the copy was not produced by whatever the original cites, it was made here by the person pasting. Every copy gets a fresh server-minted uid, and a task gets a fresh id slugged from its name and reserved against the plan AND against the other copies in the same paste. A cloned milestone has no id, which is what every milestone this app creates has+- **Dependencies are REMAPPED, which is the point.** A link between two rows that are both in the copied set is rewritten onto the two clones, so copying a five-step chain gives a five-step chain and not five rows all hanging off the originals. A link to anything OUTSIDE the set is kept exactly as it is, still naming the original, because that is a real fact about the work and the copy inherits it. Nothing is added in the other direction: a task that waited on a row you copied still waits on that row and on nothing else, so no successor silently acquires a second predecessor. A copied dependency that named a copied MILESTONE keeps naming the original, since an id-less clone cannot be depended on+- **Names are KEPT**, so a chain duplicated across workcells can be renamed afterwards rather than having " (copy)" undone five times first. The exception is a name that would collide INSIDE THE DESTINATION LANE, which is what pasting back into the same lane always does: those get " 2", then " 3", counting past whatever is already there and against the other copies in the same paste. A copy into a different lane keeps its name unchanged, because the lane is what tells the two apart+- **The gate on both chords.** `clipboardReady()`: no cell editor, no inline name editor, no drag or reorder, no date popup, and nothing focused that a person could be typing into. Ctrl+C additionally refuses while there is a live text selection anywhere on the page. It is `appIsIdle()` minus ONE clause, the open panel, and it has to be: a plain click on a bar selects the task AND opens its editor, so in the chart "a row is selected" and "nothing is open" never hold at once, and requiring both would make Ctrl+V unreachable in the lens the feature is for. In every case the gate refuses, the browser's own copy and paste are left completely alone+- **One paste is one action.** One undo entry removes the whole pasted set and one redo puts it back at the same indices with the same uids; an undone clone leaves by the created-row door, so it is never recorded as a deletion. One changelog event per paste, `action: "paste"`, carrying `count`, up to twelve `names` and a `hint` of "N tasks pasted", collapsed on the `via` the same way an auto-schedule pass is; the per-row `add` lines are not written. An undo and a redo are their own per-row lines, tagged `undo` and `redo`, which is the convention every batch in this app already follows. Repeated pastes make independent sets. Afterwards the pasted rows ARE the selection and the pane scrolls to them on the same five-row buffer every row-moving gesture uses, so they are ready for Alt+Arrow or a drag+- **If the mint fails, nothing is pasted.** Every uid is minted from `POST /uid` BEFORE any row is built, so a refused call (signed out, expired session, dead connection) leaves the plan exactly as it was: a toast saying so, no rows, no sidecar write, no changelog line, no half a chain. The same Ctrl+V works the moment the mint does+- **In an exported HTML SNAPSHOT** paste works too, and for free: `allocUid` falls back to the snapshot's own local counter (a single file with no server behind it is the one place a number can be made without a race), and the save and the changelog are already no-ops there. So a snapshot handed to somebody with no access to the app is still a plan they can duplicate rows in - **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. **A COLLAPSED lane OPENS the moment the moving row lands in it**, so the row never vanishes mid-press and the reader can see the rows they are now moving among; it opens on the crossing keystroke and not before, which is a different question from the five-row scroll buffer, and a lane merely stepped over (an empty one) stays shut. That is a view state, so undoing the move puts the row back and leaves the lane open. **A selection with GAPS in it GROUPS itself up on the first press**: the selected rows close up into one contiguous block at the TOPMOST selected row's slot, in the order they already had, with the unselected rows between them closing up below, and a toast says so. That press does not also step the block, because a block anchored on the topmost row and then moved one place is no longer aligned to the thing it was anchored to; the selection is contiguous afterwards, so every press after it moves the block as one. Re-scatter the selection with another Ctrl+Click and the next press groups it again. A selection that is already contiguous is untouched and moves on the first press exactly as before. 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)
docs/BUILD-NOTES.md+274@@ -3,6 +3,280 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-09-01 (0.56.0, copy and paste rows, and the status vocabulary in lifecycle order)++Two changes Drew asked for, and one drifted surface the second of them turned up on the+way. `gantt.html` carries the whole feature; `server.js` changes twice, and neither change+alters what a running server does.++### 1. Copy and paste rows++Drew, verbatim: "please add ability to copy single/multiple tasks and then paste elsewhere.+Ideally, paste would occur directly below a selected task OR appended to end of active+(i.e. in-view) phase".++**What it is for.** Drew duplicates CHAINS. The same five-step crate build, once per+workcell, renamed afterwards. Making one of those by hand is five trips through the create+panel and then four dependency links drawn by eye. The copy is the small half of this+feature; the links are the large half, and they are what the design is organised around.++**An in-app clipboard, not the OS one.** `clipRows`, a variable. Reading rows back out of+the system clipboard would mean inventing a serialisation, guarding against pasting one+version of a plan into another version of the app from a second tab, and asking for a+permission the browser only grants on a gesture it likes. None of that buys anything: the+rows never leave the page. Ctrl+C still ALSO writes the id text to the system clipboard,+exactly as it has since 0.44.0, because that is the hand-off to an AI chat and paste does+not replace it. That half is silent now, and its on-screen fallback box is suppressed:+`showIdFallback` focuses a textarea, and a textarea that grabs the focus would eat the+Ctrl+V about to follow it. `copySelectedIds` was split so the two callers can differ in+that one way (`writeIdText(txt, done, quiet)`); the Copy IDs button and the selection chip+keep the loud version and their own toast, unchanged.++**Snapshotted at copy time.** A clip holds values, not references (`clipOf`). Editing an+original after copying it does not change what comes out of the paste, and deleting the+original does not empty the clipboard. That is what "clipboard" means everywhere else, and+it is the only rule that survives the original being deleted.++**Where a paste lands, exactly.** Two rules, and which applies is decided by whether+anything is selected.++1. **Something is selected**: directly below it. With a set, below the LAST of them in+ PLAN order, which is the bottom of the block the user is looking at. The phase is that+ row's phase, so pasting under a row in another lane moves the copies into that lane:+ bar fill, phase dot and milestone rule all recolour, because `recolorByPhase` is a pure+ function of `r.phase` and `reflowRows` calls it.+2. **Nothing is selected**: the end of the ACTIVE phase, meaning the one most in view.+ Each lens answers with the thing it already computes, rather than a third notion of+ "active" being invented for this:+ * **gantt**: `pinnedGroup`, the group header the sticky-header logic currently has+ stuck under the axis. That is by construction the lane whose rows fill the top of the+ pane. Scrolled above the first header nothing is pinned, and the answer is the first+ lane in plan order.+ * **table**: the phase of the TOPMOST FULLY VISIBLE ROW, meaning the first rendered row+ whose top edge is at or below the bottom of the sticky header. A row half under the+ header is not one you are looking at. If every row is partly cut off, the first+ rendered row answers; an empty table answers phase 0. It reads the RENDERED row, so+ it keeps working under a column sort with no clause about sorting anywhere in it, and+ v56a asserts exactly that.++ Both are read at the moment of the paste, so scrolling changes the answer and nothing+ has to be clicked to choose a lane. v56a pastes the same clipboard at three chart+ scroll positions and gets three different lanes, and at two table scroll positions and+ gets two.++`insertRowAt(r, idx)` is the one new row-plumbing function, sitting next to `insertRow`.+It builds the row through `buildRowDom` exactly as every other created row is built and+then moves it to a chosen index, which is what `homeRow` already does; the two differ in+one line and not in how a row comes into existence.++**Dependencies, which is the killer part.** Two passes. The first builds every clone and+records `original id -> clone id`; the second rewrites the deps:++* A dep naming a row INSIDE the copied set is remapped to that row's clone. A copied chain+ is a chain.+* A dep naming anything OUTSIDE the set is kept exactly as it is, still naming the+ original, because that is a real fact about the work and the copy inherits it.+* Nothing is added in the other direction. The originals' successors are NOT touched:+ copying A->B->C and pasting it leaves D, which waited on C, waiting on the ORIGINAL C+ and not on both. This is the failure mode that would have made the feature useless, and+ v56b asserts it against the sidecar's stored values rather than against the absence of a+ sidecar entry (the sidecar is a full snapshot of every row, not a list of what changed).+* A cloned MILESTONE has no id, which is what every milestone this app creates has, so a+ copied dep that named a copied milestone keeps naming the original. Nothing can depend+ on an id-less row, and the original is still in the plan and still the thing the work+ waits on.++Ids are RESERVED as they are handed out, not merely looked up: `freshId` only walks+`items`, and none of the clones are in it yet, so pasting two rows that slug the same way+would otherwise hand both the same id. One growing `seen` set answers for the plan and for+the clones made so far.++**Naming.** The name is KEPT. Drew duplicates a chain across workcells and renames after,+so " (copy)" on every row would be five renames of noise to undo before the five renames+that are the actual job. The one exception is a name that would collide INSIDE THE TARGET+LANE, which is what pasting back into the same lane always does (the original is right+there): those get " 2", then " 3", counting past whatever is already in that lane AND+against the other copies in the same paste, so three copies of one row in one keystroke+come out 2, 3, 4 rather than all 2. A copy into a DIFFERENT lane keeps its name unchanged,+because the lane is what tells them apart. The bench has a "Chain one" sitting in a second+lane on purpose: pasting the three-task chain there suffixes exactly one of the three and+leaves the other two alone, which is the case that shows the rule discriminating rather+than just firing.++**Provenance** is the CREATED-row convention (`Added by <user>`, or `Added by hand`), not+the original's. These rows were not produced by whatever the original cites; they were made+here, now, by the person pasting, and that is what the field is for. It is stored+explicitly rather than left absent, unlike the create panel's default, because there is no+panel here for the user to have left alone.++**The mint.** Every uid is minted from `POST /uid` BEFORE any row is built. That is the+same call the create panel makes and the only place a uid is ever born, so a signed-in user+is served by the same door that already works and a refusal (signed out, expired session,+dead connection) leaves the plan exactly as it was: a toast, no rows, no sidecar write, no+changelog line, no half a chain. Uids already handed out when a later one fails are simply+not used, which costs nothing (the counter never goes backwards and a uid is never reused)+and is the same trade the create panel makes. v56c mocks a 401 on that call and asserts all+four kinds of nothing, then puts the door back and pastes with the same keystroke.++**One paste is one action.** `pushUndo({type:'multi', steps})`, the same shape a batch+delete uses, so one Ctrl+Z removes the whole set and one Ctrl+Shift+Z puts it back at the+same indices with the same uids. An undone clone leaves by the created-row door+(`removeRow` finds it in `addedItems`), so it is never recorded as a deletion. Undo, redo,+undo, redo lands in the same place, which v56b cycles rather than testing once. The+created-rows LIST comes back in the reverse order after a redo, because the multi's inverse+is its inverses reversed; nothing reads that order (row order is stored separately, and the+reload test proves it), so it is left alone and the assertion is about membership.++**The changelog** gets ONE line per paste. `action: "paste"`, with `count`, up to twelve+`names` and a `hint` of "N tasks pasted", collapsed on the `via` in `diffAndLog` the same+way an auto-schedule pass is and for the same reason: the shape of the events cannot tell a+paste from five separate creates, only the gesture can. It runs last, after the milestone+diff, because a pasted milestone's `add` is not produced until then and belongs in the same+count. An UNDO and a REDO are their own per-row lines, tagged `undo` and `redo`; that is+the convention every batch in this app already follows (undoing a batch delete writes N+restore lines), and tying the collapse to `via === 'paste'` is what stops it relabelling a+redo of something that was not a paste.++**After the paste** the pasted rows ARE the selection, so Alt+Arrow, a drag or a Delete all+apply to what was just made without a click, and `followMovedRows(clones, 1)` brings them+into view on the same five-row buffer every other row-moving gesture has used since 0.42.0.+A lane that happens to be collapsed opens (`openLandingLanes`, the 0.44.0 rule): a paste you+cannot see is a paste you will do again. The panel is CLOSED, because the one on screen is+the panel the copy was opened from and it is now naming a row that is no longer selected.++**The gate, which is the half that has to be invisible.** Ctrl+C and Ctrl+V are the two+most overloaded chords on the keyboard. Both answer only when `clipboardReady()` says so:+no cell editor, no inline name editor, no drag or reorder, no date popup, and nothing+focused that a person could be typing into. Ctrl+C keeps its extra clause from 0.44.0, a+live text selection anywhere on the page, which is the case where the user obviously meant+the browser's copy.++`clipboardReady()` is `appIsIdle()` MINUS ONE CLAUSE: the open panel. It has to be, and+this is the one real design decision in the round. A plain click on a bar SELECTS the task+and opens its editor, and that is the only way to select a single row in the chart, so "a+row is selected" and "nothing is open" are mutually exclusive in that lens. Gating Ctrl+V+on `appIsIdle()` would have meant it never fired in the chart at all, which is the lens the+feature is for. Ctrl+C has never asked the question either (it has answered from behind an+open panel since 0.44.0), so the two chords now share one predicate and it is written down+in one place with the reason attached. Every other clause is kept, and the one that matters+most is kept in both: a field with the caret in it owns its own copy and its own paste.+Ctrl+C's gate is slightly TIGHTER than it was (it now also refuses during a drag, an inline+rename and an open date popup); nothing asserted the old looseness and the tightening is+strictly correct.++Both chords are in the help pulldown's keyboard section and in the README table.++### 2. The status vocabulary is in lifecycle order++Drew asked for `not-started, in-progress, complete, blocked, cancelled`; complete and+blocked swap. The order is the LIFECYCLE: the first three are the road a row travels when+nothing goes wrong, so they read in the order they happen, and blocked and cancelled are+the two ways off that road and sit after it. Before this the ENDING of the happy path came+after one of its exits.++`STATUS_ORDER` in `gantt.html` is the single source of that order, and one edit moves every+surface that reads it: the item panel's select, the create panel's, the inline pill list+(chart and table), the analytics Status card's rows, the per-person card's five columns,+and the table's status-column COLLATION, since `sortValueOf` returns the INDEX into the+list. That last one is a behaviour change and not a cosmetic one: sorting by status now+ranks a complete row ahead of a blocked one, and v56d sets one row of each state and+asserts the ascending and descending runs.++MEMBERSHIP IS UNCHANGED, so nothing stored means anything different: every alias still+resolves, an unknown status still survives as itself, and every count on the analytics+cards is the same number in a different column.++**`server.js` changes, and why no restart is needed.** Two edits, and neither one is+reached by a running server in a way that matters.++The first is `STATUS_ORDER` itself. The comment above it has said since 0.52.0 that the two+lists change together or not at all, so they do. The server only ever asks that list for+MEMBERSHIP (`statusKey` uses `includes`, and nothing there indexes into it), so the reorder+changes no server behaviour at all.++The second is a `describeEvent` clause for the new `paste` action, so the markdown+changelog renders one readable sentence naming the count, the rows and the remapping rule+rather than falling through to the generic "user paste ...".++**The live 8901 process is NOT restarted.** It has run since 28 August and holds the old+`server.js` in memory. `serveApp` reads `gantt.html` AND `package.json` from disk PER+REQUEST (see the comment above `appVersion`), so deploying the new client gives that+server's next page load the whole feature and the 0.56.0 badge with nothing restarted, and+`POST /uid`, which the paste mints against, is unchanged. The only visible consequence of+the stale in-memory copy is cosmetic and confined to one surface: a `paste` event written+before that process is next restarted renders through the generic fallback in+`/changelog?format=md`. The EVENT is stored in full either way, with its `count`, `names`+and `hint`, so nothing is lost and the sentence appears the moment the server is next+started for any other reason.++### 3. The one surface that had drifted++The table's status filter, `#tblStatus`, was five hand-written `<option>` elements in the+markup. It was therefore the only status surface in the app that did not follow+`STATUS_ORDER`, and reordering the vocabulary moved everything else and left it behind.+v56d caught it on the first run, which is the whole reason for asserting every surface+rather than a representative one.++Fixed at the cause: the options are filled in from `STATUS_ORDER` by `buildStatusFilter()`+at startup, called just before `wireTableFilters`. Only the blank "All" stays in the+markup, because that is the filter's own idea and not one of the five states. The next+person to touch the vocabulary gets this dropdown for free.++**Paste works in an exported HTML snapshot, for free.** `allocUid` already had a SNAPSHOT+branch (a single file with no server behind it is the one place a number can be made+without a race, so it carries on from the counter the plan was exported with), and both the+save and the changelog are already no-ops there. So a snapshot handed to somebody with no+access to the app is still a plan they can duplicate rows in. Checked rather than assumed:+`g56/snaptest.mjs` opens a real `/export/html` snapshot over `file://`, copies a row,+pastes it, undoes it, and asserts the row count 49 -> 50 -> 49 with zero JS errors and no+network request other than the web fonts.++### The suites++Four new suites on a new bench (`g56`, port 9091, 49 rows over three lanes), because the+questions split cleanly and each one rewrites the plan it reads:++* **v56a (58)** where a paste lands and what the copies are called: below a selected row in+ the same lane and in a different one (phase reassign and recolour asserted on the table's+ phase dot AND the chart's bar fill), the in-view rule at three chart scroll positions and+ two table ones, the in-view rule under a column sort, the suffix rule counting past what+ is there and against the other copies in the same paste, and an empty clipboard changing+ nothing.+* **v56b (56)** the links, the handles, the undo and the log: internal deps remapped onto+ the clones by ID, the external dep preserved, the successor untouched, all uids fresh+ against a pre-paste set with `nextUid` advanced by exactly the count, two pastes making+ two independent sets, one Ctrl+Z taking back exactly one paste, redo cycling, one `paste`+ event per paste with the right count and names, the rendered markdown line, copy writing+ nothing at all, and the whole thing surviving a reload.+* **v56c (59)** the gate, the milestone and the mint: Ctrl+C and Ctrl+V in a planted text+ field, in a cell editor and with a live text selection, all left to the browser; the open+ panel NOT blocking either chord; a milestone copied with its date, its diamond, its cap+ and its recoloured rule; a mocked 401 on the mint proving four kinds of nothing; the pane+ FOLLOWING the pasted set (scrolled to the top of a lane whose end is below the fold, the+ paste brings the pane with it and every pasted row ends up inside it); both lenses and+ both themes; and the help pulldown carrying both chords.+* **v56d (20)** the status reorder on every surface it reaches, including the sort+ collation both ways and a pasted row carrying its status through.++**Suites changed deliberately for the reorder**, each with the reason written in beside it:+`gw/regress.mjs` (the pill list), `g42/v42.mjs` (the per-person head, the five per-owner+count vectors and the Status card rows), `g45/v45.mjs` (the per-person head, four per-owner+rows and the independent model's own status order), `g52/v52.mjs` (four dropdown option+lists and the per-person head). Every changed number is the SAME number in a different+column; the totals and the sums are untouched, which is what says the reorder is a reorder.+`w/dblist.js` picks its option by `dataset.value` and needed nothing. The `*-0.52.0.mjs`+historical copies in `g53/` are old-build A/B artifacts and were deliberately left alone.++**The whole standing battery, green on the shipping build**+(`g56/battery-0.56.0-ship.log`): v37 203, regress 26, abc 75, v38 249, v39 178, v40 138,+v41 75 + v41big 23, v42 111 + v42big 33, v43 47, v44a/b/c 54/48/19, hol-unit 66, v45 111,+v46 160, v47 116, v48 86 + big48 14, v49 99, v50 177, v51 64, v52 137, v53 117 + v53b 16,+v54 74, v55 197, and the four new ones at 58/56/59/20. Zero failures anywhere, zero page+errors, and the holiday crosscheck still identical over 2015-2040. `mssel` and `msphase`,+the two legacy suites whose FATALs are fixture-coupled and cannot be read as pass/fail, were+run against 0.55.0 and 0.56.0 on their own ports and their own copies of the fixture+(`g56/abms56.sh`): BYTE IDENTICAL, md5 6ab865f017697c83f4f3ed00ac60e28d both sides.+ ## Build notes, 2026-08-25 (0.55.0, Escape leaves a field, and the Clear button goes) Two changes Drew asked for, one repair the first of them turned up on the way. `gantt.html`
gantt.html+369−16@@ -1319,13 +1319,15 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <label for="tblOwner">owner</label> <select id="tblOwner" title="Show only one person's work"></select> <label for="tblStatus">status</label>+ <!-- The five options are FILLED IN FROM STATUS_ORDER at startup (see+ buildStatusFilter), not written out here. They were written out here until+ 0.56.0, which is how this one select came to be the only status surface in the+ app that did not follow the list: reordering the vocabulary moved the panels,+ the pill list, the sort and both analytics cards and left this dropdown behind.+ Only the blank stays in the markup, because "All" is the filter's own idea and+ not one of the five. --> <select id="tblStatus"> <option value="">All</option>- <option value="not-started">not started</option>- <option value="in-progress">in progress</option>- <option value="blocked">blocked</option>- <option value="complete">complete</option>- <option value="cancelled">cancelled</option> </select> <!-- The Clear button stood here until 0.55.0. It reset all three filters at once, which is a button for a state the row already shows you how to leave: the two@@ -1430,7 +1432,21 @@ const deletedKeys = new Set(); // its HOURS stop counting: a cancelled task is not effort anybody owes. Its own // dates and its own hours are left intact on the row, so the display, the undo and // the export are unchanged and it is only the analytics accumulators that skip it.-const STATUS_ORDER=['not-started','in-progress','blocked','complete','cancelled'];+//+// THE ORDER IS THE LIFECYCLE, and 0.56.0 put it back in that order: not started, in+// progress, complete, blocked, cancelled. The first three are the road a row travels+// when nothing goes wrong, so they read in the order they happen; blocked and+// cancelled are the two ways off that road and sit after it. Complete and blocked+// were the other way round until now, which put the ENDING of the happy path after+// one of its exits and made every dropdown, every analytics column and the status+// sort read as though blocked came before finishing.+//+// This list is the single source of that order. It drives the option order in the+// item panel and the create panel, the collation the table's status column sorts by+// (rowSortVal returns the INDEX, so a reorder here is a reorder there), the status+// rows on the analytics status card and the status columns on the per-person card.+// Membership is unchanged, so no stored value means anything different than it did.+const STATUS_ORDER=['not-started','in-progress','complete','blocked','cancelled']; const STATUS_ALIAS={ 'live':'in-progress','in progress':'in-progress','in_progress':'in-progress', 'planned':'not-started','not started':'not-started','notstarted':'not-started',@@ -2260,6 +2276,24 @@ function insertRow(r,silent){ if(!silent){applyFilter();refreshChartGeometry();} return r; }+// The same build, dropped at a CHOSEN index rather than at the end of its lane.+// Paste is the only caller: pasting below a selected row means below THAT row, not+// at the bottom of the lane it happens to be in, and homeRow can only ever say the+// bottom. The row is built at the end (buildRowDom appends, and it needs a real+// index for barEls) and then moved, which is exactly what homeRow does too, so the+// two paths differ in one line and not in how a row comes into existence.+function insertRowAt(r,idx,silent){+ if(!r.uid&&!r._key&&!r.id)r._key=freshKey();+ items.push(r);+ buildRowDom(r,items.length-1);+ if(r.type==='milestone'&&r._msLine)bars.appendChild(r._msLine);+ const cur=items.indexOf(r);+ items.splice(cur,1);+ items.splice(Math.max(0,Math.min(idx,items.length)),0,r);+ reflowRows();+ if(!silent){applyFilter();refreshChartGeometry();}+ return r;+} // Place a row at the end of its phase's block. Shared by creation and by a phase // move, so a new row lands exactly where a moved one would. function homeRow(r){@@ -2791,6 +2825,22 @@ function appIsIdle(){ if(isTypingTarget(document.activeElement))return false; return true; }+// The gate the two clipboard chords use, and appIsIdle() with ONE clause taken out:+// the open panel.+//+// It has to be. A plain click on a bar SELECTS the task and opens its editor, and that+// is the only way to select a single row in the chart, so "nothing is open" and "a row+// is selected" are mutually exclusive in that lens. Gating Ctrl+V on appIsIdle() would+// mean it never fired in the chart at all, which is the lens the feature exists for,+// and Ctrl+C has never asked the question either (it has answered from behind an open+// panel since 0.44.0). Every other clause is kept, and the one that matters most is+// kept in both: a field with the caret in it owns its own copy and its own paste.+function clipboardReady(){+ if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;+ if(datePop&&datePop.classList.contains('show'))return false;+ if(isTypingTarget(document.activeElement))return false;+ return true;+} function wireGlobalKeys(){ // An armed Delete is a question, and everything except the answer withdraws it. Both // listeners capture, so they run before whatever else the key or the click is for and@@ -2971,16 +3021,34 @@ document.addEventListener('keydown',e=>{ e.preventDefault();closeMenus();openCreateEditor(); return; }- // Ctrl+C copies the selected ids, but only when it cannot possibly be someone's+ // Ctrl+C copies the selected ROWS, but only when it cannot possibly be someone's // real copy: nothing focused that a person could be typing in, no live text // selection anywhere on the page, no cell editor open, and something selected. // If any of those is true the browser's own copy is left completely alone.+ //+ // 0.56.0 widened what it copies, and widened nothing about the gate: the same four+ // questions, in the same order. The rows go on the app's own clipboard, ready for+ // Ctrl+V, and the id text still goes on the OS clipboard exactly as before, because+ // that is the hand-off to an AI chat and paste does not replace it. if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&(e.key==='c'||e.key==='C')){ if(!selected.size)return;- if(tblEditing||isTypingTarget(document.activeElement))return;+ if(!clipboardReady())return; let live=''; try{ live=String(window.getSelection?window.getSelection().toString():''); }catch(_){} if(live)return;- e.preventDefault(); copySelectedIds();+ e.preventDefault(); copySelectedRows();+ return;+ }+ // Ctrl+V pastes them, on exactly the gate Ctrl+C answers on, which is the point: a+ // chord that can copy has to be able to paste from the same place. A Ctrl+V typed+ // into ANY field anywhere, into a cell editor, into the create panel or in the+ // middle of a drag is that field's paste and never the app's. There is no+ // text-selection clause here, because a paste does not read the selection and+ // having some text highlighted is not a reason to refuse to duplicate a task. With+ // an empty clipboard it says so rather than doing nothing, and the browser's own+ // paste is left alone in every case the gate refuses.+ if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&(e.key==='v'||e.key==='V')){+ if(!clipboardReady())return;+ e.preventDefault(); pasteRows(); return; } if(e.key!=='Escape')return;@@ -3310,6 +3378,26 @@ function diffAndLog(via){ moved:moved.slice(0,12).map(e=>e.name||e.id||e.key)},...kept); } }+ // A paste is ONE action and one Ctrl+Z, so it is one line in the log too. Five+ // "added a new task" lines in a row say nothing about the fact that they are five+ // copies of one chain made in one keystroke, and a reader trying to reconstruct+ // what a person did would have to infer it from the timestamps. Collapsed on the+ // `via` for the same reason auto-schedule is: the shape of the events cannot tell+ // a paste from five separate creates, only the gesture can.+ //+ // It runs LAST, after the milestone diff, because a pasted milestone's `add` is not+ // produced until then and it belongs in the same count. Rows are NAMED, up to a+ // readable number of them, so the log still says what was pasted.+ if(via==='paste'){+ const born=evs.filter(e=>e.action==='add');+ if(born.length){+ const kept=evs.filter(e=>e.action!=='add');+ evs.length=0;+ evs.push({action:'paste',count:born.length,+ names:born.slice(0,12).map(e=>e.name||e.id||e.key),+ hint:born.length+' task'+(born.length===1?'':'s')+' pasted'},...kept);+ }+ } lastSnap=cur; moveHint=null; if(!evs.length)return;@@ -4704,21 +4792,29 @@ function showIdFallback(txt){ document.addEventListener('mousedown',off,true); document.addEventListener('keydown',esc,true); }-function copySelectedIds(){- const n=selected.size;- if(!n){ flash(saveInd,'select some rows first'); return; }- const txt=selectedIdText();- const done=()=>flash(saveInd,'Copied '+n+' item ID'+(n===1?'':'s'));+// The OS clipboard half, on its own so the two callers can differ in the one way+// they need to. `quiet` suppresses BOTH the toast and the on-screen fallback box:+// Ctrl+C now also fills the app's own clipboard (see copySelectedRows below), and a+// fallback textarea popping up and taking the focus would eat the Ctrl+V that is+// about to follow it. The button paths keep the loud version.+function writeIdText(txt,done,quiet){+ const ok=()=>{ if(done)done(); }; // The clipboard API needs a secure context and the click it was called from. When // it is refused, the ids are put on screen in a selectable box rather than lost: // a copy that silently does nothing is worse than one that hands you the text. try{ if(navigator.clipboard&&navigator.clipboard.writeText){- navigator.clipboard.writeText(txt).then(done).catch(()=>{ if(!legacyCopy(txt))showIdFallback(txt); else done(); });+ navigator.clipboard.writeText(txt).then(ok).catch(()=>{+ if(legacyCopy(txt))ok(); else if(!quiet)showIdFallback(txt); }); return; } }catch(e){}- if(legacyCopy(txt))done(); else showIdFallback(txt);+ if(legacyCopy(txt))ok(); else if(!quiet)showIdFallback(txt);+}+function copySelectedIds(){+ const n=selected.size;+ if(!n){ flash(saveInd,'select some rows first'); return; }+ writeIdText(selectedIdText(),()=>flash(saveInd,'Copied '+n+' item ID'+(n===1?'':'s')),false); } function legacyCopy(txt){ try{@@ -4742,6 +4838,251 @@ function updateSelCount(){ } else selCountEl.classList.remove('show'); } +// ── Copy and paste rows ──+//+// Drew duplicates chains: the same five-step crate build, once per workcell, renamed+// afterwards. Making those by hand is five trips through the create panel and then+// four dependency links drawn by eye, per copy. Ctrl+C, click, Ctrl+V is the whole+// job, and the LINKS COME WITH IT, which is the part that makes it worth having.+//+// AN IN-APP CLIPBOARD, not the OS one. Reading rows back out of the system clipboard+// would mean inventing a serialisation, guarding against pasting a plan into itself+// from another tab at a different version, and asking for a permission the browser+// only grants on a gesture it likes. None of that buys anything here: the rows never+// leave the page. So the clipboard is a variable. Ctrl+C still ALSO writes the id+// text to the OS clipboard, exactly as it has since 0.44.0, because that is the+// hand-off to an AI chat and nothing about paste replaces it.+//+// SNAPSHOTTED AT COPY TIME. A clip holds values, not references: editing an original+// after copying it does not change what comes out of the paste, and deleting the+// original does not empty the clipboard. That is what "clipboard" means everywhere+// else, and it is the only rule that survives the original being deleted.+let clipRows=[];+function clipOf(r){+ return {type:r.type==='milestone'?'milestone':'task',+ name:r.name||'', srcId:r.id||'',+ start:r.start, end:r.end, date:r.date,+ owner:r.owner, status:r.status, description:r.description, note:r.note,+ deps:Array.isArray(r.deps)?r.deps.slice():[]};+}+// Ctrl+C. The selection is already the app's one selection model, so this works the+// same from either lens and with either kind of row in it.+function copySelectedRows(){+ const rows=selectedRows();+ if(!rows.length){ flash(saveInd,'select some rows first'); return; }+ clipRows=rows.map(clipOf);+ // Silently, and with the fallback box suppressed: the toast below is the feedback,+ // and a textarea that grabs the focus would break the paste that follows.+ writeIdText(selectedIdText(),null,true);+ flash(saveInd,'Copied '+rows.length+' task'+(rows.length===1?'':'s'));+}+// ── Where a paste lands ──+//+// TWO RULES, and which one applies is decided by whether anything is selected.+//+// 1. SOMETHING IS SELECTED: directly below it. With a set selected, below the LAST+// of them in plan order, which is the bottom of the block the user is looking at.+// The phase is that row's phase, so pasting under a row in another lane moves the+// copies into that lane and recolours them.+//+// 2. NOTHING IS SELECTED: the end of the ACTIVE phase, meaning the one most in view.+// Each lens answers "most in view" with the thing it already computes:+//+// * gantt: the PINNED lane. `pinnedGroup` is the group header the sticky-header+// logic currently has stuck under the axis, which is by construction the lane+// whose rows fill the top of the pane. Scrolled above the first header nothing+// is pinned, and the answer is the first lane in plan order.+// * table: the phase of the TOPMOST FULLY VISIBLE ROW, meaning the first rendered+// row whose top edge is at or below the bottom of the sticky header (a row half+// under the header is not one you are looking at). If every row is partly cut+// off, the first rendered row answers. An empty table answers phase 0.+//+// Both are read at the moment of the paste, so scrolling changes the answer and+// nothing has to be clicked to choose a lane.+function tablePhaseInView(){+ const sc=document.querySelector('.table-scroll');+ const rows=visibleRowEls();+ if(!rows.length)return null;+ let first=null;+ if(sc&&tableHead){+ const top=Math.max(sc.getBoundingClientRect().top,tableHead.getBoundingClientRect().bottom);+ first=rows.find(tr=>tr.getBoundingClientRect().top>=top-0.5)||null;+ }+ const r=rowItem((first||rows[0]).dataset.key);+ return (r&&r.phase!==undefined)?r.phase:null;+}+function phaseInView(){+ if(prefs.view==='gantt'){+ if(pinnedGroup&&pinnedGroup.phase!==undefined)return pinnedGroup.phase;+ const g=items.find(isDerivedGroup);+ return g?g.phase:0;+ }+ const p=tablePhaseInView();+ if(p!==null&&p!==undefined)return p;+ const g=items.find(isDerivedGroup);+ return g?g.phase:0;+}+// The index a row appended to the end of `ph`'s block would take. Same walk homeRow+// does, written as a position instead of as a move.+function endOfPhaseIndex(ph){+ const gi=items.findIndex(g=>g.type==='group'&&g.phase===ph);+ if(gi<0)return items.length;+ let end=gi+1;+ while(end<items.length&&items[end].type!=='group')end++;+ return end;+}+function pasteTarget(){+ const sel=selectedRows();+ if(sel.length){+ const last=sel[sel.length-1];+ return {phase:last.phase,idx:items.indexOf(last)+1,below:last};+ }+ const ph=phaseInView();+ return {phase:ph,idx:endOfPhaseIndex(ph),below:null};+}+// ── Naming a copy ──+//+// The name is KEPT. Drew duplicates a chain across workcells and renames afterwards,+// so " (copy)" on every row would be five renames of noise to undo before the five+// renames that are the actual job.+//+// The one exception is a name that would collide INSIDE THE TARGET LANE, which is+// what pasting back into the same lane always does: two rows called "Reflow profile"+// under one header cannot be told apart on the chart. Those get " 2", then " 3", and+// so on, counting past whatever is already there. A copy into a DIFFERENT lane keeps+// the name unchanged, because the lane is what tells them apart.+//+// `taken` carries the names this same paste has already placed, so pasting three+// copies of one row in one go numbers them 2, 3, 4 rather than all 2.+function copyName(base,ph,taken){+ const used=new Set(items.filter(r=>r.type!=='group'&&r.phase===ph).map(r=>r.name||''));+ taken.forEach(n=>used.add(n));+ if(!used.has(base))return base;+ let n=2;+ while(used.has(base+' '+n))n++;+ return base+' '+n;+}+// ── Ctrl+V ──+//+// Async, because a pasted row cannot exist until the server has given it a uid, and+// EVERY uid is minted before ANY row is built: a paste is one thing, so a mint that+// fails halfway must leave the plan exactly as it was rather than half a chain. The+// uids already handed out are simply not used, which costs nothing (the counter never+// goes backwards and a uid is never reused anyway) and is the same trade the create+// panel makes.+let pasting=false;+async function pasteRows(){+ if(pasting)return;+ if(!clipRows.length){ flash(saveInd,'nothing copied yet'); return; }+ const tgt=pasteTarget();+ pasting=true;+ let uids;+ try{ uids=[]; for(const c of clipRows) uids.push(await allocUid()); }+ catch(_){+ pasting=false;+ flash(saveInd,'could not allocate item IDs, nothing was pasted',4000);+ return;+ }+ pasting=false;+ // The TARGET was decided at the keystroke, which is right: it is what the user was+ // looking at and pointing to when they pressed the keys. The INDEX is re-read HERE,+ // after the mint, because the mint is a real round trip and the plan could have moved+ // under it. Both branches are re-read, not just the anchored one: an appended paste+ // wants the end of the lane as it is NOW, and the end of a lane is exactly the thing+ // another paste, a create or a drag would have moved. If the anchor row itself is gone+ // the paste falls back to the end of the lane it was in, which is the same answer the+ // no-selection rule would have given.+ const j=tgt.below?items.indexOf(tgt.below):-1;+ const idx=(j>=0)?j+1:endOfPhaseIndex(tgt.phase);+ const prov=USER_NAME?('Added by '+USER_NAME):'Added by hand';+ const taken=[];+ // Ids are RESERVED as they are handed out, not merely looked up. freshId only walks+ // `items`, and none of the clones are in it yet, so pasting two rows that slug the+ // same way would hand both of them the same id. One growing set answers for the plan+ // and for the clones made so far.+ const seen=new Set(items.map(r=>r.id).filter(Boolean));+ const reserveId=name=>{+ const base=slugify(name); let id=base,n=2;+ while(seen.has(id))id=base+'-'+(n++);+ seen.add(id); return id;+ };+ // Pass one: build every clone, and record the id it answers to so pass two can+ // remap the links onto it.+ const idMap=new Map(); // original task id -> clone's new id+ const clones=clipRows.map((c,i)=>{+ const name=copyName(c.name,tgt.phase,taken);+ taken.push(name);+ const r={type:c.type,uid:uids[i],name,phase:tgt.phase};+ // A milestone this app makes has no id, and the create panel is where that rule+ // is set; a clone follows it. Nothing can depend on an id-less row, so a copied+ // dependency that named a copied MILESTONE keeps naming the original, which is+ // still in the plan and still the thing the work waits on.+ if(c.type==='milestone'){ r.date=c.date; }+ else{+ r.id=reserveId(name);+ r.start=c.start; r.end=c.end;+ if(c.srcId)idMap.set(c.srcId,r.id);+ }+ if(c.owner)r.owner=c.owner;+ if(c.status)r.status=c.status;+ if(c.description)r.description=c.description;+ if(c.note)r.note=c.note;+ // Provenance is the CREATED-row convention, not the original's. These rows were+ // not produced by whatever the original cites; they were made here, now, by the+ // person pasting, and that is what the field is for.+ r.provenance=prov;+ return r;+ });+ // Pass two: the links.+ //+ // A dep that names a row INSIDE the copied set is remapped to that row's clone, so+ // a copied chain is a chain again and not five rows all hanging off the originals.+ // A dep that names anything OUTSIDE the set is kept exactly as it is, still pointing+ // at the original, because that is a real fact about the work and the copy inherits+ // it. Nothing is added in the other direction: the originals' successors are not+ // touched, so copying A->B->C and pasting it leaves D, which waited on C, waiting on+ // the ORIGINAL C and not on both.+ clipRows.forEach((c,i)=>{+ const deps=c.deps.map(d=>idMap.has(d)?idMap.get(d):d).filter(d=>d&&d!==clones[i].id);+ if(deps.length)clones[i].deps=deps;+ });+ // Into the plan, in copy order, each one below the last.+ const steps=[];+ clones.forEach((r,i)=>{+ addedItems.push(r);+ insertRowAt(r,idx+i,true);+ steps.push({type:'create',r});+ });+ // ONE undo entry for the whole paste, the way a batch delete is one. Undoing it+ // removes every clone and takes their uids out of circulation by the same door a+ // delete does (removeRow sees them in addedItems, so they leave without ever being+ // recorded as deletions); redo puts them back at the same indices.+ pushUndo({type:'multi',steps});+ // A lane the copies landed in that happens to be shut opens, the 0.44.0 rule: a+ // paste you cannot see is a paste you will do again.+ openLandingLanes(clones);+ applyFilter(); refreshChartGeometry();+ renderTable();+ rebuildTimeline();+ // The pasted set becomes THE selection, so Alt+Arrow, a drag or a Delete all apply+ // to what was just made without a click, and the pane follows it by the same+ // five-row buffer every other row-moving gesture uses.+ selected.clear();+ clones.forEach(r=>{ if(r.uid)selected.add(r.uid); });+ selAnchor=clones.length?clones[clones.length-1].uid:null;+ // The panel on screen, if there is one, is the one the COPY was opened from, and it+ // is now describing a row that is no longer the selection. Panels in this app track+ // the selection, so it goes rather than sits there naming the wrong task.+ closeEditor();+ refreshSelVisuals(); updateSelCount();+ if(prefs.view==='table')setFocusedRow(rowKey(clones[clones.length-1]));+ followMovedRows(clones,1);+ scheduleSave('paste');+ const n=clones.length;+ undoToast.innerHTML='Pasted '+n+' task'+(n===1?'':'s')+' into '+esc(phaseNameAt(tgt.phase))+' · <kbd>Ctrl+Z</kbd>';+ showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});+}+ // ── Label click → select bar ── // A click on the label edits the NAME, right there. The bar still owns the // schedule, so clicking it opens the date and dependency editor as before: the@@ -6478,6 +6819,8 @@ $('btnHelp').addEventListener('click',e=>{ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected row. Undoable. On the chart it takes two presses: the first says what would go and marks it, the second does it</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+D</span>Add dependencies: click the task that comes first, then the one that waits on it</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+C</span>Copy the selected rows. Also puts their IDs on the system clipboard for an AI chat</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+V</span>Paste them below the selected row, or at the end of the lane you are looking at if nothing is selected. Dependencies among the copied rows are remapped to the copies; one Ctrl+Z takes the whole paste back</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Alt+Up/Down</span>Move the row one slot. Under a sort it stays inside its tied group</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Tab</span>Commit and open the next field. Shift+Tab goes back</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Cancel the field, then the row, then the selection</div>@@ -7952,6 +8295,15 @@ function distinctOwners(){ items.forEach(r=>{if(isGroup(r))return;const o=ownerOf(r);if(o)s.add(o);}); return [...s].sort((a,b)=>a.localeCompare(b)); }+// The status filter's five options, from the one list that owns their order. Called once+// at startup: the vocabulary is fixed, so unlike the owner list there is nothing here to+// resync when the plan changes. The "All" blank stays in the markup, because it is the+// filter's own idea and not one of the five states.+function buildStatusFilter(){+ if(!statusSel)return;+ statusSel.insertAdjacentHTML('beforeend',+ STATUS_ORDER.map(k=>`<option value="${k}">${esc(statusTextFor(k))}</option>`).join(''));+} function syncOwnerControls(){ const owners=distinctOwners(); // On the first render there are no options yet, so a value set from prefs would@@ -9622,6 +9974,7 @@ async function init(){ wireRowDragClickGuard(); wireTableHeadAndDelete(); wireCopyIds();+ buildStatusFilter(); // its options exist before anything can listen for a change wireTableFilters(); await startApp(); }
package.json+6−3@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.55.0",- "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Five statuses, including cancelled, which keeps the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",+ "version": "0.56.0",+ "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": { "adom-gantt": "server.js"@@ -34,7 +34,10 @@ "what is the critical path", "how many hours is each person carrying", "show me the project analytics",- "what is overdue on this plan"+ "what is overdue on this plan",+ "duplicate this task",+ "copy these tasks and paste them into another phase",+ "duplicate the whole chain for the next workcell" ], "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": [
page.json+7−4@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.55.0",- "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Five statuses, including cancelled, which keeps the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",+ "version": "0.56.0",+ "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": { "adom-gantt": "server.js"@@ -34,7 +34,10 @@ "what is the critical path", "how many hours is each person carrying", "show me the project analytics",- "what is overdue on this plan"+ "what is overdue on this plan",+ "duplicate this task",+ "copy these tasks and paste them into another phase",+ "duplicate the whole chain for the next workcell" ], "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": [@@ -86,4 +89,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}
server.js+19−1@@ -722,6 +722,17 @@ function describeEvent(e) { // the plan needs to know what was never in the pass. return `${who} auto-scheduled the plan, moving ${e.count || 0} item(s) LATER so no dependency starts before the work it waits on finishes, on business days${names}. Nothing was moved earlier, so gaps that were already there were kept; tasks with no dependencies and cancelled tasks kept their dates${via}`; }+ // One line per paste, not one per row. The client collapses the whole set into this+ // single event on the `via`, because five "added a new task" lines cannot say that+ // they were five copies of one chain made in one keystroke. The remapping sentence is+ // the part a reader (or an agent) actually needs: the copies are wired to each other,+ // not to the rows they were copied from, and nothing downstream of the originals was+ // touched.+ if (action === 'paste') {+ const names = Array.isArray(e.names) && e.names.length+ ? `: ${e.names.join(', ')}${e.count > e.names.length ? `, and ${e.count - e.names.length} more` : ''}` : '';+ return `${who} pasted ${e.count || 0} copied item(s) into the plan${names}. Each is a new row with a permanent uid of its own; dependencies among the copied set were remapped onto the copies, dependencies pointing outside it still name the originals, and nothing that depended on an original was 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` : '';@@ -795,7 +806,14 @@ function changelogMarkdown(log, since, rev) { // list does not know survives as itself but never becomes one of ours. A fifth state // added on one side only would be half-supported in silence, which is worse than not // having it, so the two lists change together or not at all.-const STATUS_ORDER = ['not-started', 'in-progress', 'blocked', 'complete', 'cancelled'];+//+// The ORDER changed in 0.56.0 (complete and blocked swapped, so the list reads as the+// lifecycle: not started, in progress, complete, then the two ways off that road). The+// server only ever asks this list for MEMBERSHIP -- statusKey() uses includes() and+// nothing here indexes into it -- so the reorder is a source-consistency change on this+// side and changes no server behaviour at all. It is here because the two lists change+// together or not at all, and a reader comparing them must not find them disagreeing.+const STATUS_ORDER = ['not-started', 'in-progress', 'complete', 'blocked', 'cancelled']; const STATUS_ALIAS = { 'live': 'in-progress', 'in progress': 'in-progress', 'in_progress': 'in-progress', 'planned': 'not-started', 'not started': 'not-started', 'notstarted': 'not-started',