← Commit history

0.24.0: the table can select rows too

Drew Owens ·f721a2e90c · ·parent 2b3ffb0
The blue swath was the absence of a feature, not a bug in one. The table had no row multi-select, so a ctrl or shift click fell through to the browser's own anchor-and-extend text machinery and painted across the cells. Building the feature removes the cause; suppressing the paint is the other half. One selection model, two lenses: the table reuses the chart's selected set and its anchor rather than growing a second one, so rows picked in one lens are selected in the other. Milestones still cannot be multi-selected, for the same reason as always. Ctrl toggles, shift takes the range from the anchor, and the range walks the rows the table is SHOWING, so a sort or a filter changes what the range means. Verified under plan order, an owner sort, and a text filter. Two marks, because they answer two questions: a selected row carries the chart's faint teal wash, the focused row keeps its accent bar, and they stack. Escape lost a wasted press. With a set selected the focused row is one OF the set, so clearing it alone looked like nothing happened while two rows stayed lit; that rung is skipped while a set is up and the last rung takes both. Editors still go first. The group move generalised for free, which is what 0.23.0 was betting on: plan order, simulate-first, no-op at the edges, gaps preserved. Under an active sort a GROUP move is refused rather than guessed at, because composing the single-row equal-key-run bounding across a set, where each row has its own run and the runs move as the rows do, has no answer that stays truthful to the sort. Dragging is already refused under a sort for the same reason. Delete takes the whole selection in one keystroke and one Ctrl+Z, toast naming the count. That needed a compound snapshot type: a list applied as one, whose inverse is the inverses in reverse. Removed bottom-up so each recorded index stays valid, restored top-down. preventDefault on mousedown only when a modifier is held, so unmodified click-drag still selects and copies cell text, and the open editor's input is exempt from the user-select guard. Asserted getSelection().toString() empty after every gesture. The chart was not broken; checked because the report said so. Table-only.
5 files changed +178−7
SKILL.md+1
@@ -201,6 +201,7 @@ rather than a category. - **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart - **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, at which point that tab stops autosaving and offers a reload rather than clobbering the other person - **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. The pair resets and the mode stays on, so a run of dependencies is one sitting. Refuses duplicates and refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. Esc clears a half-made pick, Esc again leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder. Ctrl+Z removes the last one without leaving the mode+- **Row multi-select in the table**: ctrl-click toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo - **Deps column** in the table: a narrow read-only indicator, needs and blocks counts with the names on hover, blank when there are none. Clicking it opens the editor where dependencies are changed. Sortable by total count - **Ctrl+Z** to undo (100-level stack) - **Ctrl+Shift+Z** (or **Ctrl+Y**) to redo. Redo only ever replays what undo just took back, so any new edit clears it. It covers every undoable action symmetrically: field edits, snapped drags, row and lane moves, deletes, creates (the row comes back with the same id and stable key), milestone edits and the whole-plan shift. Inside a text field the field's own undo and redo win, exactly as Ctrl+Z already does. Redo is logged to the changelog as its own event, tagged `redo`
docs/BUILD-NOTES.md+56
@@ -3,6 +3,62 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-12 (0.24.0, the table can select rows too)++**The blue swath was the absence of a feature, not a bug in one.** The table had no+row multi-select, so a ctrl or shift click fell through to the browser's own+anchor-and-extend text machinery and painted a selection across the cells. Building+the feature removes the cause; suppressing the paint is the other half.++**One selection model, two lenses.** The table reuses the chart's `selected` set and+its `selAnchor`, rather than growing a second one: select rows in the table, switch+to the chart, and they are still selected, because they are the same rows. That also+means milestones cannot be multi-selected in either lens, for the same reason as+always, they have no id, and a click on one says so.++Ctrl toggles, shift takes the range from the anchor, and the range walks the rows the+table is SHOWING, so a sort or a filter changes what "everything between these two"+means and the gesture follows. Measured under plan order, under an owner sort, and+with a text filter down to 23 rows.++**Two marks, because they answer two questions.** A selected row carries the same+faint teal wash the chart's label rows use; the focused row keeps its accent bar.+"Is this row in the set" and "which row do the keys act on" are different facts and+they stack without fighting.++**Escape, and one press that used to be wasted.** With a set selected, the focused+row is one OF the set, so clearing it on its own looked like the key had done+nothing while two rows stayed lit. That rung is skipped while a set is up, and the+final rung takes the set and the focus together. Editors still go first: a name+editor opened with Enter closes on the first Escape with the selection intact, and+the second clears it.++**The group move generalised for free**, which is what 0.23.0 was betting on. In the+table it moves in plan order with the same simulate-first, no-op-at-the-edges,+gaps-preserved rules. Under an active column sort a GROUP move is refused rather+than guessed at: a single row under a sort is bounded to the run of rows the sort+considers tied with it, and composing that across a set, where every row has its own+run and the runs move as the rows do, has no answer that stays truthful to the sort.+Dragging is already refused under a sort for exactly this reason, so this is the+established behaviour rather than a new rule.++**Delete takes the whole selection in one keystroke and one Ctrl+Z**, the way a file+manager behaves, with the toast naming the count. That needed a compound snapshot+type: a list of snapshots applied as one, whose inverse is the inverses in reverse.+Rows are removed bottom-up so each recorded index is still valid when the next one+goes, and restored top-down. Three rows deleted, one undo, one redo.++**Text selection, kept where it belongs.** `preventDefault` on mousedown only when a+modifier is held, so unmodified click-drag still selects and copies a cell's text,+and the open editor's input is exempt from the `user-select` guard because selecting+the text you are editing is the whole point. Asserted: `getSelection().toString()` is+empty after every ctrl and shift gesture, and an editor input still reports a live+selection range.++**The chart was not broken.** Checked as part of this round because the report said+so: ctrl-click still builds a set of two with the count chip, the group move still+moves them, and Escape still clears. This was table-only.+ ## Build notes, 2026-08-11 (0.23.0, moving a selection, and the sticky board)  **Alt+Up and Alt+Down move a whole chart selection as a group.** Each selected row
gantt.html+118−4
@@ -453,6 +453,17 @@ 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. */+/* 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+   act on") so they are different marks and they stack without fighting. */+.data-table tbody tr.multi-sel > td { background: rgba(0,184,177,0.10); }+.data-table tbody tr.multi-sel:hover > td { background: rgba(0,184,177,0.14); }+/* No text selection can start from a modified click anywhere in the grid. The open+   editor's own input is exempt, because selecting the text you are editing is the+   point of it. */+body.tbl-picking, body.tbl-picking .data-table { user-select: none; }+body.tbl-picking .cell-input { user-select: text; } .data-table tbody tr.row-focus > td:first-child { box-shadow: inset 3px 0 0 var(--accent); } .data-table tbody tr.row-focus > td { background: rgba(0,184,177,0.045); } /* Plan order is the only order you can drag in, so say so rather than letting@@ -1371,8 +1382,20 @@ function moveSelectedRows(dir){ } function moveFocusedRow(dir){   const table=prefs.view==='table';-  // More than one row selected on the chart moves the lot, as a group.-  if(!table&&selected.size>1&&moveSelectedRows(dir))return;+  // More than one row selected moves the lot, as a group, in either lens: it is one+  // selection and one machinery. Under an active column sort a GROUP move is refused+  // rather than guessed at. A single row under a sort is bounded to the run of rows+  // the sort considers tied with it (0.17.0), and composing that rule across a set,+  // where each row has its own run and the runs move as the rows do, has no answer+  // that stays truthful to the sort. Dragging is refused under a sort for the same+  // reason, so this is the behaviour already established rather than a new rule.+  if(selected.size>1){+    if(table&&!manualOrder()){+      flash(saveInd,'sorted, so a group cannot be moved. Back to plan order to reorder',3200);+      return;+    }+    if(moveSelectedRows(dir))return;+  }   const r=table?rowItem(focusedKey):soleSelectedRow();   if(!r){     flash(saveInd, table@@ -1424,6 +1447,13 @@ function applySnapshot(sn){     if(sn.phases&&sn.phases.length){applyCollapse();applyFilter();refreshChartGeometry();}     return inv;   }+  // A list of snapshots applied as one. The inverse is the inverses in reverse, so+  // undoing a batch and redoing it both land exactly where they started.+  if(sn.type==='multi'){+    const inv=[];+    (sn.steps||[]).forEach(st=>{ const i=applySnapshot(st); if(i)inv.unshift(i); });+    return {type:'multi',steps:inv};+  }   if(sn.type==='delete'){     restoreRow(sn.r,sn.idx,sn.droppedDeps);     // Putting the row back is undone by taking it out again, and it is the SAME@@ -1569,6 +1599,29 @@ document.addEventListener('keydown',e=>{     if(!tableNavReady())return;     const rows=visibleRowEls();     const i=rows.findIndex(t=>t.dataset.key===focusedKey);+    // A multi-selection goes in one keystroke and comes back in one Ctrl+Z, the way+    // a file manager behaves. Deleted bottom-up so each recorded index is still+    // valid when the next one is removed.+    if(selected.size>1){+      const victims=items.filter(x=>x.id&&selected.has(x.id));+      if(!victims.length)return;+      e.preventDefault();+      const steps=[];+      victims.slice().reverse().forEach(v=>{+        const info=removeRow(v,true);+        if(info)steps.unshift({type:'delete',r:v,idx:info.idx,droppedDeps:info.droppedDeps});+      });+      pushUndo({type:'multi',steps});+      clearSelection();+      keepTailLast();applyFilter();refreshChartGeometry();renderTable();+      clearTimeout(undoTimer);+      undoToast.innerHTML='Deleted '+steps.length+' rows &middot; <kbd>Ctrl+Z</kbd>';+      undoToast.classList.add('show');+      undoTimer=setTimeout(()=>{undoToast.classList.remove('show');undoToast.innerHTML=UNDO_TOAST_DEFAULT;},3200);+      scheduleSave('table-delete');+      focusRowAt(Math.min(i<0?0:i,visibleRowEls().length-1));+      return;+    }     if(i<0)return;                       // nothing selected, nothing to delete     const r=rowItem(focusedKey);     if(!r)return;@@ -1652,8 +1705,11 @@ document.addEventListener('keydown',e=>{   // Only while the table is the lens looking at it. A row focus left over from an   // earlier table visit is invisible in the chart, and it used to swallow the Escape   // that was meant for the chart's selection.-  if(focusedKey&&prefs.view==='table'){ setFocusedRow(null); return; }-  clearSelection(); closeMenus();+  // Skipped while a set is selected: the focused row is one OF the selection, so+  // dropping it on its own would look like the key did nothing while two rows stayed+  // lit. With a set up, Escape takes the set and the focus together.+  if(focusedKey&&prefs.view==='table'&&selected.size<2){ setFocusedRow(null); return; }+  clearSelection(); setFocusedRow(null); closeMenus(); }); } @@ -2333,6 +2389,7 @@ function refreshSelVisuals(){   document.querySelectorAll('.bar.selected').forEach(b=>b.classList.remove('selected'));   document.querySelectorAll('.label-row.multi-sel').forEach(l=>l.classList.remove('multi-sel'));   selected.forEach(sid=>{if(barEls[sid]){barEls[sid].bar.classList.add('selected');const r=items.find(d=>d.id===sid);if(r)r._lDiv.classList.add('multi-sel');}});+  markSelectedRows(); } function doSelect(id, e){   const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;@@ -4164,6 +4221,16 @@ function rowDragMove(e){ function wireTableRowDrag(){ tableBody.addEventListener('mousedown',e=>{   if(e.button!==0)return;+  // A modified click is a selection gesture, so the browser must not treat it as a+  // text-selection anchor. This is the blue swath: with no row multi-select, ctrl+  // and shift clicks fell through to the native anchor-and-extend machinery and+  // painted across the cells. Unmodified click-drag is left alone, so selecting and+  // copying a cell's text still works.+  if((e.ctrlKey||e.metaKey||e.shiftKey)&&!e.target.closest('.cell-edit')){+    e.preventDefault();+    document.body.classList.add('tbl-picking');+    return;+  }   if(e.target.closest('.row-del')||e.target.closest('.cell-edit'))return;   const tr=e.target.closest('tr[data-key]');if(!tr)return;   if(!manualOrder())return;                 // sorted: the note explains why@@ -4201,8 +4268,18 @@ tableBody.addEventListener('click',e=>{ // the app which row you mean. Esc gives it back, innermost first: the field, then // the row. let focusedKey=null;+function markSelectedRows(){+  if(!tableBody)return;+  tableBody.querySelectorAll('tr.multi-sel').forEach(t=>t.classList.remove('multi-sel'));+  if(!selected.size)return;+  tableBody.querySelectorAll('tr[data-key]').forEach(t=>{+    const k=t.dataset.key;+    if(k.charAt(0)==='t'&&selected.has(k.slice(2)))t.classList.add('multi-sel');+  });+} function markFocusedRow(){   if(!tableBody)return;+  markSelectedRows();   tableBody.querySelectorAll('tr.row-focus').forEach(t=>t.classList.remove('row-focus'));   if(!focusedKey)return;   const tr=[...tableBody.querySelectorAll('tr[data-key]')].find(t=>t.dataset.key===focusedKey);@@ -4254,6 +4331,30 @@ function tableNavReady(){ return prefs.view==='table' && appIsIdle(); } // walk is over the rendered cells, so it follows the visible order under a sort, // skips the milestone start cell and the delete button because neither carries // `editable`, and wraps around the ends rather than dead-ending.+// The table's half of the one selection model. Ctrl toggles, shift takes the range+// from the anchor, and the range walks the rows the table is SHOWING, so a sort or a+// filter changes what "everything between these two" means and the gesture follows.+// The anchor is the chart's `selAnchor`, deliberately: selecting rows in one lens and+// finding them selected in the other is the point of sharing the model.+function tableSelect(id,e){+  const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;+  const vis=[...tableBody.querySelectorAll('tr[data-key]')]+    .map(t=>t.dataset.key).filter(k=>k.charAt(0)==='t').map(k=>k.slice(2));+  if(shift&&selAnchor&&vis.includes(selAnchor)){+    const ai=vis.indexOf(selAnchor), ci=vis.indexOf(id);+    if(ai>=0&&ci>=0){+      if(!ctrl)selected.clear();+      for(let i=Math.min(ai,ci);i<=Math.max(ai,ci);i++)selected.add(vis[i]);+    }+  } else if(ctrl){+    if(selected.has(id))selected.delete(id); else selected.add(id);+    selAnchor=id;+  } else {+    selected.clear(); selected.add(id); selAnchor=id;+  }+  setFocusedRow('t:'+id);+  refreshSelVisuals(); updateSelCount();+} function tabToCell(key,field,dir){   const rows=[...tableBody.querySelectorAll('tr[data-key]')];   if(!rows.length)return;@@ -4503,6 +4604,18 @@ tableBody.addEventListener('click',e=>{     if(r)deleteRow(r);     return;   }+  // Modified clicks pick rows and never open an editor.+  const mod=e.ctrlKey||e.metaKey||e.shiftKey;+  if(mod){+    e.preventDefault();e.stopPropagation();+    const tr=e.target.closest('tr[data-key]');+    document.body.classList.remove('tbl-picking');+    if(!tr)return;+    const k=tr.dataset.key;+    if(k.charAt(0)!=='t'){ flash(saveInd,'milestones cannot be multi-selected',2400); return; }+    tableSelect(k.slice(2), e);+    return;+  }   const dc=e.target.closest('td.c-deps');   if(dc){     // The document-level "click outside closes the editor" handler would otherwise@@ -4514,6 +4627,7 @@ tableBody.addEventListener('click',e=>{     else if(r) flash(saveInd,'only tasks carry dependencies',2400);     return;   }+  if(selected.size)clearSelection();     // a plain click collapses the set, as on the chart   const td=e.target.closest('td.editable');if(td)startCellEdit(td); }); }
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.23.0",+  "version": "0.24.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.",   "main": "server.js",   "bin": {
page.json+2−2
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.23.0",+  "version": "0.24.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.",   "main": "server.js",   "bin": {@@ -63,4 +63,4 @@     "name": "Drew Owens",     "email": "[email protected]"   }-}\ No newline at end of file+}