← Commit history

0.25.0: copy the ids

Drew Owens ·a3d17d44b8 · ·parent bdb5cb3
Select rows, copy their ids, paste into an AI chat and ask for a batch edit. The ids are the point: /state, /export/json and the changelog all key on them, so an id is the one string that lands an edit on the right row. Format is one row per line, id then a tab then the name. The id is what the AI needs; the name rides along so the paste stays readable by the human who made it, and a tab means it drops into a spreadsheet as two columns. A single selection uses the same shape. Placement per lens. The table gets a Copy IDs button in the tools row, hidden until something is selected and last in an already right-aligned row so it cannot push anything. The chart's selection chip becomes the control, reading 'N selected, Copy IDs', and now shows for ANY non-empty selection rather than only two or more, which is the one visible behaviour change outside the feature. The batch panel gets its own button. The chip's count sits in a fixed-width span so it cannot resize. Never fail a copy silently: clipboard.writeText inside the click, falling back to textarea and execCommand, and if both refuse the ids go on screen in a selectable box that Escape or an outside click dismisses. Ctrl+C is guarded four ways and gives up on any of them: nothing selected, a cell editor open, focus in anything typeable, or a live text selection anywhere. Verified that with text selected in an editor and with a real page selection the app does not touch the event and defaultPrevented stays false. Milestones cannot enter the selection in either lens so the #row fallback is unreachable today; the resolver uses rowOrderKey anyway so it stays correct if that ever changes.
5 files changed +131−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+- **Copy IDs**: with rows selected, the table's tools row and the chart's selection chip (and the batch panel) copy the selected items' stable ids, one per line as `id<TAB>name`, for pasting into an AI chat to request batch edits. Ctrl+C does the same when nothing is focused and no text is selected. If the clipboard is refused the ids appear in a selectable box rather than failing silently - **Row multi-select in the table**: ctrl-click toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo - **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)
docs/BUILD-NOTES.md+36
@@ -3,6 +3,42 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-12 (0.25.0, copy the ids)++Select rows, copy their ids, paste them into an AI chat and ask for a batch edit. The+ids are the point: `/state`, `/export/json` and the changelog all key on them, so an+id is the one string that lands an edit on the right row and nothing else is.++**Format, one row per line, `id` then a tab then the name.** The id is what the AI+needs; the name rides on the same line so the paste is still readable by the person+who made it, and a tab means it drops into a spreadsheet as two columns for free. A+single selection uses the same format, so there is one shape to learn.++**Placement per lens, because the two lenses have different furniture.** The table+gets a Copy IDs button in the tools row, hidden until something is selected and last+in a row that is already right-aligned, so its appearing cannot push anything. The+chart's selection chip becomes the control: it now reads "N selected, Copy IDs" and+is clickable, and it shows for ANY non-empty selection rather than only for two or+more, which is the one visible behaviour change outside the feature. The batch editor+panel gets its own button in its header, since that is where a person doing bulk work+already is. The chip's count sits in a fixed-width span so the chip cannot resize as+the number changes.++**Never fail a copy silently.** `navigator.clipboard.writeText` inside the click,+falling back to the old textarea and `execCommand`, and if BOTH refuse the ids go on+screen in a small selectable box that Escape or a click outside dismisses. A copy+button that does nothing visible is worse than one that hands you the text.++**Ctrl+C is guarded four ways** and gives up on any of them: nothing selected, a cell+editor open, focus in anything a person could type into, or a live text selection+anywhere on the page. Verified that with text selected inside an open editor and with+a real page text selection, the app does not touch the event and `defaultPrevented`+stays false, so the browser's own copy is untouched.++Milestones cannot enter the selection in either lens, so the `#row` key fallback the+brief asked about is unreachable today. The resolver is written to use `rowOrderKey`+anyway, so it stays correct if milestones ever become selectable.+ ## Build notes, 2026-08-12 (0.24.1, toasts you can actually catch)  **Checked the durations before changing them, and the complaint was half right.** The
gantt.html+91−4
@@ -359,8 +359,12 @@ body.col-resizing { cursor: col-resize; user-select: none; } .save-indicator.show { opacity: 1; transform: translateY(0); pointer-events: auto; } /* The version is chrome: present for orientation, never competing with the plan. */ header .subject-meta .app-ver { color: var(--text3); }-.sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); z-index: 99998; opacity: 0; transition: opacity 0.15s; }-.sel-count.show { opacity: 1; }+.sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42); z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; cursor: pointer; }+.sel-count.show { opacity: 1; transform: translateY(0); pointer-events: auto; }+.sel-count:hover { border-color: var(--accent); color: var(--accent-bright); }+/* Fixed width for the count so the chip cannot change size as the number does. */+.sel-count .n { display: inline-block; min-width: 2.2em; text-align: right; }+.id-box { position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%); z-index: 99999; width: min(560px,86vw); height: 130px; background: var(--elevated); color: var(--text); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 9px 11px; font-family: 'JetBrains Mono', monospace; font-size: 11px; box-shadow: 0 10px 30px rgba(0,0,0,0.55); }  /* ── View toggle: the same plan as a chart or as a table ──    The primary control in the header, so it is bigger and heavier than the action@@ -662,6 +666,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va       <button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button>       <span class="sort-lock-note" id="sortLockNote" style="display:none"></span>       <span class="table-count" id="tblCount"></span>+      <button class="tt-btn" id="btnCopyIds" style="display:none" title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>     </div>     <div class="table-scroll">       <div class="row-drop" id="rowDrop"></div>@@ -1683,6 +1688,18 @@ document.addEventListener('keydown',e=>{     e.preventDefault();closeMenus();openCreateEditor();     return;   }+  // Ctrl+C copies the selected ids, 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.+  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;+    let live=''; try{ live=String(window.getSelection?window.getSelection().toString():''); }catch(_){}+    if(live)return;+    e.preventDefault(); copySelectedIds();+    return;+  }   if(e.key!=='Escape')return;   // Innermost first. A native select or datalist popup swallows its own Escape   // before this ever runs, which is exactly the first rung of the stack.@@ -2429,7 +2446,69 @@ function doSelect(id, e){   }   refreshSelVisuals();updateSelCount(); }-function updateSelCount(){if(selected.size>1){selCountEl.textContent=`${selected.size} selected`;selCountEl.classList.add('show');}else{selCountEl.classList.remove('show');}}+// What gets copied: the stable id, a tab, then the name. The id is the thing every+// other surface keys on, /state and /export/json and the changelog all speak it, so+// it is what makes an AI batch edit land on the right row; the name rides along on+// the same line so the paste is readable by the human who made it. One row per line.+//+// The `rowOrderKey` fallback is for a row with no id. Nothing can currently reach it,+// because a row with no id cannot enter the selection in either lens, but the+// resolver is written correctly so it stays right if milestones ever become+// selectable.+function selectedIdText(){+  return items.filter(r=>r.id&&selected.has(r.id))+    .map(r=>(r.id||rowOrderKey(r))+'\t'+(r.name||''))+    .join('\n');+}+function showIdFallback(txt){+  const old=document.getElementById('idBox'); if(old)old.remove();+  const ta=document.createElement('textarea');+  ta.id='idBox'; ta.className='id-box'; ta.readOnly=true; ta.value=txt;+  document.body.appendChild(ta); ta.focus(); ta.select();+  const off=e=>{ if(e.target!==ta){ ta.remove(); document.removeEventListener('mousedown',off,true);+    document.removeEventListener('keydown',esc,true); } };+  const esc=e=>{ if(e.key==='Escape'){ e.stopPropagation(); ta.remove();+    document.removeEventListener('mousedown',off,true); document.removeEventListener('keydown',esc,true); } };+  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 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(); });+      return;+    }+  }catch(e){}+  if(legacyCopy(txt))done(); else showIdFallback(txt);+}+function legacyCopy(txt){+  try{+    const ta=document.createElement('textarea');+    ta.value=txt; ta.style.cssText='position:fixed;top:-1000px;left:-1000px;opacity:0';+    document.body.appendChild(ta); ta.focus(); ta.select();+    const ok=document.execCommand&&document.execCommand('copy');+    ta.remove(); return !!ok;+  }catch(e){ return false; }+}+function syncCopyBtn(){+  const b=$('btnCopyIds');+  if(b)b.style.display=selected.size?'':'none';+}+function updateSelCount(){+  syncCopyBtn();+  if(selected.size){+    selCountEl.innerHTML=`<span class="n">${selected.size}</span> selected &middot; Copy IDs`;+    selCountEl.title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits";+    selCountEl.classList.add('show');+  } else selCountEl.classList.remove('show');+}  // ── Label click → select bar ── // A click on the label edits the NAME, right there. The bar still owns the@@ -2667,6 +2746,7 @@ function renderDepList(id){ // ── Batch editor (relative adjustments) ── function daysBetween(a,b){const[ay,am,ad]=a.split('-').map(Number),[by,bm,bd]=b.split('-').map(Number);return Math.round((new Date(by,bm-1,bd)-new Date(ay,am-1,ad))/86400000);} +function wireBatchCopy(){ const b=$('beCopyIds'); if(b)b.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();}); } function openBatchEditor(){   const ids=[...selected];   const tasks=ids.map(id=>items.find(d=>d.id===id)).filter(r=>r&&r.start);@@ -2675,7 +2755,7 @@ function openBatchEditor(){   const latest=tasks.reduce((a,r)=>r.end>a?r.end:a,tasks[0].end);    editorEl.innerHTML=`<div class="drag-bar"></div>-    <div class="batch-title">${ids.length} tasks selected</div>+    <div class="batch-title">${ids.length} tasks selected <button class="de-btn secondary" id="beCopyIds" style="float:right;padding:2px 9px;font-size:10px" title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button></div>     <div class="batch-hint">All changes are relative, each task shifts from its own dates</div>     <div style="margin-bottom:10px">       <div class="de-row">@@ -2704,6 +2784,7 @@ function openBatchEditor(){     scheduleSave('batch-editor');closeEditor();   });   $('bClose').addEventListener('click',closeEditor);+  wireBatchCopy(); }  function msByIndex(i){return items.filter(r=>r.type==='milestone')[i];}@@ -4668,6 +4749,11 @@ function deleteRow(r){   showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});   scheduleSave('table-delete'); }+function wireCopyIds(){+selCountEl.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();});+const b=$('btnCopyIds');+if(b)b.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();});+} function wireTableFilters(){ ownerSel.addEventListener('change',()=>{prefs.tableOwner=ownerSel.value;savePrefs();renderTable();}); statusSel.addEventListener('change',renderTable);@@ -4814,6 +4900,7 @@ async function init(){   wireTableRowDrag();   wireRowDragClickGuard();   wireTableHeadAndDelete();+  wireCopyIds();   wireTableFilters();   await startApp(); }
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.24.1",+  "version": "0.25.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.24.1",+  "version": "0.25.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+}