← Commit history

0.12.0: drag to reorder, lane drags, and dd-Mmm-yyyy dates

Drew Owens ·8913ce6a25 · ·parent 2a97bb2
Arming a row drag is a 350ms click-and-hold, which is what let every existing click survive untouched: opening a cell editor, hitting delete, sorting a header and dragging a bar edge are all short clicks. Once armed the row lifts, a drop indicator shows where it lands, the pane auto-scrolls near either edge, and Esc abandons it. Dragging is offered only in plan order. Under a column sort the rows on screen are not the rows in the plan, so an indicator between two of them would describe a position that does not exist. Rather than reordering the plan to match a gesture made against a sorted view, or flipping the sort off on mousedown (which moves the row out from under the pointer before the drag has started), the toolbar says why and offers the way back. Headers are three-state now: ascending, descending, plan order. A cross-lane drop sets phase and position together as one undo and one changelog line. Lane drags are a chart gesture, since group headers only exist in the chart label column; dragging one takes its whole block, one move, one undo, one entry. phases[] is not reordered: a phase index is written into every item and the sidecar, so renumbering lanes would silently re-home the plan. Only row order changes, so the legend can list lanes in a different order than the chart draws them, which is noted in the UI copy. One bug surfaced: the guard stopping a drag from also counting as a click was a sticky boolean, and a drag ending outside the table never produces a click there, so it stayed armed and ate the next real click. It is a timestamp now. Dates read 17-Aug-2026 everywhere a person looks, through one formatter. Machine formats stay ISO on purpose: data file, sidecar, changelog payloads and CSV, the last because it is interchange. Sorting still sorts the underlying value, not the formatted string.
7 files changed +353−18
README.md+2
@@ -10,6 +10,8 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  **Four statuses.** Not started, in progress, blocked, complete. Plans written against the older "live" and "planned" still read correctly without being changed. +**Drag to reorder.** Click and hold a row for a moment to lift it, then drop it where it belongs. Dropping into another lane moves it there, position and phase in one step and one undo. Drag a lane header on the chart to move the whole lane. Dates read `17-Aug-2026` throughout; stored data stays ISO.+ **Edit the plan from the table.** Names, phase, owner, dates, status, notes and provenance are all editable in place, and a row can be deleted with one click and an undo toast. Moving a row to another phase re-homes it under that lane's group header on the chart, not just in the data. Deletions are recorded explicitly in the sidecar, so restoring a saved plan can never confuse "removed on purpose" with "row lost".  **Provenance.** Every item can say where it came from: `"provenance": {"label": "POR", "url": "https://..."}`, or just `"Drew added this"`. The table shows it as a chip, linked when the url is http or https and as plain text otherwise, so a hostile url can never become a link.
SKILL.md+5−1
@@ -228,7 +228,11 @@ named in its own column. | Notes | `description`, truncated | same | | Provenance | `provenance` as a chip, linked when the url is http(s) | same | -**Sorting.** Click any header. Default is deadline ascending. Blank values sort last+**Reordering.** Click and hold a row (about 350ms) to lift it, drag, drop. Only available in plan order, since a sorted view is a lens and not the plan. Dropping into another lane sets phase and position as one undo. On the chart, dragging a group header moves that whole lane.++**Dates** display as `17-Aug-2026`. Stored values (data file, sidecar, changelog payloads, CSV) stay ISO `yyyy-mm-dd`.++**Sorting.** Click any header. Three states: ascending, descending, then back to plan order. Default is deadline ascending. Blank values sort last in both directions, so an unassigned row never displaces what you are looking for.  **Filtering.** The owner dropdown is built from the distinct `owner` values in the
docs/BUILD-NOTES.md+50
@@ -3,6 +3,56 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.12.0)++**Drag to reorder.** Arming is a 350ms click-and-hold, which is what let every+existing click survive untouched: opening a cell editor, hitting delete, sorting a+header and dragging a bar edge are all short clicks and none had to change. Once+armed the row lifts, a drop indicator shows where it will land, the pane+auto-scrolls within 44px of either edge, and Esc abandons the whole thing.++Dragging is offered only in plan order. Under a column sort the rows on screen are+not the rows in the plan, so an indicator between two of them would describe a+position that does not exist. Rather than reordering the plan to match a gesture+made against a sorted view, or flipping the sort off on mousedown (which moves the+row out from under the pointer before the drag has started), the toolbar says why+and offers the way back. Column headers became three-state for this: ascending,+descending, then plan order, so the route back is the control already in reach.++A cross-lane drop sets phase and position together as one undo and one changelog+line, because the reorder event is already suppressed when a phase event explains+it. A same-lane drop reports as a reorder naming the row that moved, via a hint the+drag leaves for the differ to pick up.++**Lane drags** are a chart gesture, because group headers only exist in the chart+label column: the table is a flat list with the lane in a column. Dragging a header+takes its whole block with it, one move, one undo, one entry. `phases[]` itself is+NOT reordered: a phase index is written into every item and into the sidecar, so+renumbering lanes would silently re-home every row in the plan. Only row order+changes, which means the legend can list lanes in a different order than the chart+draws them. That is the honest trade, and it is noted in the UI copy.++**One bug this surfaced.** The guard stopping a drag from also counting as a click+was a sticky boolean set on mouseup. A drag ending outside the table never produces+a click there, so the flag stayed armed and ate the next legitimate click. It is a+timestamp now, which cannot go stale.++**Dates read 17-Aug-2026 everywhere a person looks.** One formatter, used by the+table cells, every tooltip, the milestone tags, the header start button and the+changelog markdown. A year-month value has no day so it stays `Aug 2026`, which is+what the month and quarter axis bands already showed; the day-grain axis keeps bare+day numbers under a month band, since a full date cannot fit an 18px cell and the+band above already names the month.++Machine formats are deliberately untouched and stay ISO: the data file, the+sidecar, the changelog event payloads and CSV. CSV in particular is interchange,+and a display-format CSV would break the round trip. The event timestamp in the+markdown log also stays ISO-8601, because it is a machine timestamp rather than a+plan date; that is the only ISO string left in the rendered log.++Sorting still sorts on the underlying value rather than the formatted string, which+is the classic bug this change invites. Verified chronological both directions.+ ## Build notes, 2026-08-07 (0.11.1)  **The rev chip left the canvas.** It was a fixed-position chip in the chart's
gantt.html+273−8
@@ -411,6 +411,26 @@ table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; mi .data-table .cell-edit { position: absolute; inset: 1px 3px; z-index: 6; display: flex; align-items: stretch; } .data-table .cell-input { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; text-overflow: ellipsis; } .data-table select.cell-input { font-family: 'Satoshi', sans-serif; }+/* ── Drag to reorder ──+   Arming is a click-and-hold, so every existing click (open a cell editor, hit+   the delete button, sort a header) still lands untouched. Once armed the row+   lifts and a drop indicator says where it will land. */+.table-scroll { position: relative; }+.data-table tbody tr.row-lifted { opacity: 0.45; }+.data-table tbody tr.row-lifted td { background: rgba(0,184,177,0.06); }+body.row-dragging { cursor: grabbing; user-select: none; }+body.row-dragging .data-table tbody tr:hover { background: none; }+.row-drop { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.5); z-index: 8; display: none; pointer-events: none; }+.row-drop.show { display: block; }+.row-drop::before { content: ''; position: absolute; left: 0; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }+/* The lane a drop would land in, called out while dragging across it. */+.data-table tbody tr.lane-target td.c-phase { box-shadow: inset 2px 0 0 var(--accent); }+/* Plan order is the only order you can drag in, so say so rather than letting+   the row silently refuse. */+.data-table tbody tr.no-drag { cursor: default; }+.sort-lock-note { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text2); margin-left: 10px; white-space: nowrap; }+.sort-lock-note button { background: none; border: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; text-decoration: underline; }+ .data-table .table-empty { padding: 22px 12px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; } /* Provenance: a short chip, because the useful part is usually one token ("POR",    a channel name) and the full text is on the tooltip. A chip with a link looks@@ -526,9 +546,11 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va       </select>       <input type="search" id="tblSearch" placeholder="Filter by text" aria-label="Filter rows by text">       <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>     </div>     <div class="table-scroll">+      <div class="row-drop" id="rowDrop"></div>       <table class="data-table">         <thead><tr id="tableHead">           <th class="sortable" data-sort="name">Task</th>@@ -814,7 +836,18 @@ function botLabel(d){   if(g==='quarter')         return 'Q'+(Math.floor(d.getMonth()/3)+1);   return String(d.getFullYear()); }-function fmt(ds){const[y,mo,d]=ds.split('-').map(Number),mn=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return d?`${mn[mo-1]} ${d}, ${y}`:`${mn[mo-1]} ${y}`;}+// The one date formatter. Every surface a person reads goes through it, so the+// format is decided in one place: 17-Aug-2026, zero-padded day, capitalised+// three-letter month. A year-month value has no day, so it renders Aug 2026.+// Machine formats are deliberately NOT this: the data file, the sidecar, the+// changelog payloads and CSV all stay ISO, because they are interchange.+const MN=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];+function fmt(ds){+  if(!ds)return '';+  const[y,mo,d]=String(ds).split('-').map(Number);+  if(!y||!mo)return String(ds);+  return d?`${String(d).padStart(2,'0')}-${MN[mo-1]}-${y}`:`${MN[mo-1]} ${y}`;+} function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Date(y,mo-1,d);dt.setDate(dt.getDate()+n);return`${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;}  // ── Header with import/export buttons ──@@ -1159,6 +1192,9 @@ function applyState(state){ const GROUP_DATE_RE = /\s*\((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[^)]*\d{4}[^)]*\)\s*$/; function groupLabel(t){ return (t||'').replace(GROUP_DATE_RE,''); } +// Set by a drag just before it saves, so the reorder event can say what actually+// moved instead of listing keys. Consumed once.+let moveHint=null; function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;} function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;}@@ -1247,9 +1283,10 @@ function diffAndLog(via){   const explained=evs.some(e=>e.action==='phase');   if(!explained&&before.length===after.length&&JSON.stringify(before)!==JSON.stringify(after)){     const moved=after.filter((v,i)=>before[i]!==v);-    evs.push({action:'reorder',order:after,moved:moved.slice(0,8)});+    evs.push({action:'reorder',order:after,moved:moved.slice(0,8),hint:moveHint||undefined});   }   lastSnap=cur;+  moveHint=null;   if(!evs.length)return;   if(SNAPSHOT)return;   // a snapshot has no server to record against   fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},@@ -2441,8 +2478,14 @@ const tableBody=$('tableBody'), tableHead=$('tableHead'), ownerListEl=$('ownerLi const ownerSel=$('tblOwner'), statusSel=$('tblStatus'), searchInp=$('tblSearch'), countEl=$('tblCount'); const STATUS_OPTS=STATUS_ORDER; const UNASSIGNED='__unassigned__';   // sentinel, so a real owner can never collide with it+// null means plan order: the order the rows are actually in, which is the only+// order it makes sense to drag rows around in. A column sort is a lens over that. let sortKey='deadline', sortDir=1, tblSearchTimer=null;-if(prefs.tableSort&&prefs.tableSort.key){sortKey=prefs.tableSort.key;sortDir=prefs.tableSort.dir===-1?-1:1;}+if(prefs.tableSort){+  sortKey=prefs.tableSort.key||null;+  sortDir=prefs.tableSort.dir===-1?-1:1;+}+function manualOrder(){ return !sortKey; }  function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');} function isGroup(r){return r.type==='group';}@@ -2527,11 +2570,12 @@ function tableRows(){     if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return;     if(st&&statusOf(r)!==st)return;     if(q){-      const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${provLabel(r)}`.toLowerCase();+      const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();       if(hay.indexOf(q)<0)return;     }     rows.push(r);   });+  if(manualOrder())return rows;      // already in plan order   const val=r=>{     if(sortKey==='name')return (r.name||'').toLowerCase();     if(sortKey==='phase')return (phaseNameOf(r)||'').toLowerCase();@@ -2559,7 +2603,9 @@ function syncSortHeaders(){     const on=th.dataset.sort===sortKey;     th.classList.toggle('sorted',on);     const old=th.querySelector('.sort-caret'); if(old)old.remove();-    if(on){const c=document.createElement('span');c.className='sort-caret';c.textContent=sortDir===1?'▲':'▼';th.appendChild(c);}+    if(on){const c=document.createElement('span');c.className='sort-caret';c.textContent=sortDir===1?'▲':'▼';th.appendChild(c);+      th.title='Sorted '+(sortDir===1?'ascending':'descending')+'. Click again for plan order, which is the order you can drag rows in.';}+    else th.title='Sort by this column';   }); } @@ -2578,6 +2624,7 @@ function renderTable(){   if(!tableBody)return;   syncOwnerControls();   syncSortHeaders();+  syncSortLock();   const rows=tableRows();   const total=items.filter(r=>!isGroup(r)).length;   countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;@@ -2594,8 +2641,8 @@ function renderTable(){       +`<td class="c-phase${ed}" data-field="phase">${dot}${esc(phaseNameOf(r))}</td>`       +`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'unassigned'}</td>`       +(ms?'<td class="c-date empty"></td>'-          :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(sd):'not set'}</td>`)-      +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(dl):'not set'}</td>`+          :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(fmt(sd)):'not set'}</td>`)+      +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(fmt(dl)):'not set'}</td>`       +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`       +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`       +`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>`@@ -2604,6 +2651,219 @@ function renderTable(){   }).join(''); } +// ── Drag a lane ──+//+// Group headers only exist in the chart's label column (the table is a flat list+// of tasks and milestones, with the lane in a column instead), so moving a whole+// lane is a chart gesture. Dragging one takes its entire block of rows with it as+// one move, one undo, one changelog entry.+//+// phases[] itself is NOT reordered: a phase's index is written into every item's+// `phase` field and into the sidecar, so renumbering lanes would silently+// re-home every row in the plan. Only the row order changes, which means the+// legend can list lanes in a different order than the chart draws them.+let laneDrag=null;+function laneBlock(g){+  const gi=items.indexOf(g);+  if(gi<0)return null;+  let end=gi+1;+  while(end<items.length&&items[end].type!=='group')end++;+  return {gi,end,rows:items.slice(gi,end)};+}+function endLaneDrag(commit){+  if(!laneDrag)return;+  clearTimeout(laneDrag.timer);+  const d=laneDrag; laneDrag=null;+  document.body.classList.remove('row-dragging');+  labelDropInd.classList.remove('show');barDropInd.classList.remove('show');+  (d.block?d.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.remove('dragging');if(r._bDiv)r._bDiv.classList.remove('dragging');});+  if(!commit||!d.armed||d.targetGroup==null||d.targetGroup===d.g)return;+  const blk=laneBlock(d.g); if(!blk)return;+  snapshotOrder();                                   // one undo for the whole lane+  const rows=blk.rows;+  const rest=items.filter(r=>!rows.includes(r));+  const tb=laneBlock(d.targetGroup);+  let at=rest.indexOf(d.targetGroup);+  if(at<0)at=rest.length;+  else if(!d.before){ // after the target lane's whole block+    const tRows=tb?tb.rows:[d.targetGroup];+    at=rest.indexOf(tRows[tRows.length-1])+1;+  }+  rest.splice(at,0,...rows);+  items.length=0;items.push(...rest);+  reflowRows();applyFilter();refreshChartGeometry();renderTable();+  moveHint='the '+groupLabel(d.g.label)+' lane';+  scheduleSave('drag-lane');+}+labelsEl.addEventListener('mousedown',e=>{+  if(e.button!==0)return;+  const row=e.target.closest('.label-row.group');+  if(!row)return;+  const g=items.find(r=>r._lDiv===row);+  if(!g)return;+  laneDrag={g,row,x:e.clientX,y:e.clientY,armed:false,targetGroup:null};+  laneDrag.timer=setTimeout(()=>{+    if(!laneDrag)return;+    laneDrag.armed=true;+    laneDrag.block=laneBlock(g);+    (laneDrag.block?laneDrag.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.add('dragging');if(r._bDiv)r._bDiv.classList.add('dragging');});+    document.body.classList.add('row-dragging');+    hideTip();+  },350);+});+document.addEventListener('mousemove',e=>{+  if(!laneDrag)return;+  if(!laneDrag.armed){+    if(Math.abs(e.clientX-laneDrag.x)>4||Math.abs(e.clientY-laneDrag.y)>4){clearTimeout(laneDrag.timer);laneDrag=null;}+    return;+  }+  e.preventDefault();+  const lr=labelsEl.getBoundingClientRect();+  const y=e.clientY-lr.top+labelsEl.scrollTop;+  const groups=items.filter(r=>r.type==='group'&&r!==laneDrag.g);+  let target=null,before=true;+  for(const g of groups){+    const blk=laneBlock(g); if(!blk)continue;+    const top=g._lDiv.offsetTop;+    const last=blk.rows[blk.rows.length-1];+    const bottom=last._lDiv.offsetTop+last._lDiv.offsetHeight;+    if(y<(top+bottom)/2){target=g;before=true;break;}+    target=g;before=false;+  }+  laneDrag.targetGroup=target;laneDrag.before=before;+  if(!target){labelDropInd.classList.remove('show');barDropInd.classList.remove('show');return;}+  const blk=laneBlock(target);+  const anchor=before?target:blk.rows[blk.rows.length-1];+  const top=before?anchor._lDiv.offsetTop:anchor._lDiv.offsetTop+anchor._lDiv.offsetHeight;+  labelDropInd.style.top=top+'px';labelDropInd.classList.add('show');+  const btop=before?anchor._bDiv.offsetTop:anchor._bDiv.offsetTop+anchor._bDiv.offsetHeight;+  barDropInd.style.top=btop+'px';barDropInd.classList.add('show');+});+document.addEventListener('mouseup',()=>endLaneDrag(true));+document.addEventListener('keydown',e=>{if(e.key==='Escape'&&laneDrag)endLaneDrag(false);},true);++// ── Drag a row to reorder it ──+//+// Arming is a 350ms click-and-hold. Everything the table already does on click+// (open a cell editor, delete a row, sort a header) is a short click, so none of+// it had to change and none of it can be swallowed by a drag that never armed.+//+// Dragging is only offered in plan order. Under a column sort the rows on screen+// are not the rows in the plan, so a drop indicator between two of them would be+// describing a position that does not exist. Rather than silently reordering the+// underlying plan to match a gesture made against a sorted view, or quietly+// flipping the sort off when the user presses the mouse down, the row says why+// and offers the way back.+const rowDrop=$('rowDrop'), sortLockNote=$('sortLockNote');+let rowDrag=null, autoScroll=null;++function syncSortLock(){+  if(!sortLockNote)return;+  if(manualOrder()){sortLockNote.style.display='none';sortLockNote.textContent='';return;}+  sortLockNote.style.display='';+  sortLockNote.innerHTML='Sorted, so rows cannot be dragged. <button type="button" id="toPlanOrder">Back to plan order</button>';+  const b=$('toPlanOrder');+  if(b)b.addEventListener('click',()=>{sortKey=null;prefs.tableSort={key:null,dir:1};savePrefs();renderTable();});+}++function endRowDrag(commit){+  if(!rowDrag)return;+  clearTimeout(rowDrag.timer);+  clearInterval(autoScroll); autoScroll=null;+  const d=rowDrag; rowDrag=null;+  document.body.classList.remove('row-dragging');+  rowDrop.classList.remove('show');+  if(d.tr)d.tr.classList.remove('row-lifted');+  document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));+  if(!commit||!d.armed||d.targetIdx==null)return;+  const r=d.r, target=d.targetItem;+  if(!target||target===r)return;+  const fromPhase=r.phase;+  const toPhase=target.phase;+  // One undo for the whole gesture, position and lane together.+  undoStack.push({type:'phase',r,phase:fromPhase,order:items.map(rowOrderKey)});+  if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+  const cur=items.indexOf(r);+  if(cur<0)return;+  items.splice(cur,1);+  let at=items.indexOf(target);+  if(at<0)at=items.length; else if(!d.before)at+=1;+  items.splice(at,0,r);+  if(toPhase!==undefined&&toPhase!==fromPhase){+    r.phase=toPhase;+    if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(toPhase);+    if(r._msLine)r._msLine.style.color=phaseColor(toPhase);+  }+  reflowRows();applyFilter();refreshChartGeometry();renderTable();+  // A cross-lane drop already reports itself as a phase move, which reads better+  // than "reordered rows"; a same-lane drop is a reorder and says which row moved.+  moveHint='"'+(r.name||'a row')+'"';+  scheduleSave('drag-row');+}++function rowDragMove(e){+  if(!rowDrag)return;+  if(!rowDrag.armed){+    if(Math.abs(e.clientX-rowDrag.x)>4||Math.abs(e.clientY-rowDrag.y)>4){clearTimeout(rowDrag.timer);rowDrag=null;}+    return;+  }+  e.preventDefault();+  const scroller=document.querySelector('.table-scroll');+  const sr=scroller.getBoundingClientRect();+  // auto-scroll when the pointer sits near either edge of the pane+  const EDGE=44;+  let dir=0;+  if(e.clientY<sr.top+EDGE)dir=-1; else if(e.clientY>sr.bottom-EDGE)dir=1;+  if(dir&&!autoScroll){autoScroll=setInterval(()=>{scroller.scrollTop+=dir*14;},16);}+  else if(!dir&&autoScroll){clearInterval(autoScroll);autoScroll=null;}+  else if(dir&&autoScroll){clearInterval(autoScroll);autoScroll=setInterval(()=>{scroller.scrollTop+=dir*14;},16);}++  const rows=[...tableBody.querySelectorAll('tr[data-key]')].filter(t=>t!==rowDrag.tr);+  let target=null, before=true;+  for(const t of rows){+    const b=t.getBoundingClientRect();+    if(e.clientY<b.top+b.height/2){target=t;before=true;break;}+    target=t;before=false;+  }+  document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));+  if(!target){rowDrop.classList.remove('show');rowDrag.targetIdx=null;return;}+  const tb=target.getBoundingClientRect();+  rowDrop.style.top=((before?tb.top:tb.bottom)-sr.top+scroller.scrollTop)+'px';+  rowDrop.classList.add('show');+  rowDrag.targetIdx=1;+  rowDrag.targetItem=rowItem(target.dataset.key);+  rowDrag.before=before;+  if(rowDrag.targetItem&&rowDrag.targetItem.phase!==rowDrag.r.phase)target.classList.add('lane-target');+}++tableBody.addEventListener('mousedown',e=>{+  if(e.button!==0)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+  const r=rowItem(tr.dataset.key);if(!r)return;+  rowDrag={tr,r,x:e.clientX,y:e.clientY,armed:false,targetIdx:null};+  rowDrag.timer=setTimeout(()=>{+    if(!rowDrag)return;+    rowDrag.armed=true;+    tr.classList.add('row-lifted');+    document.body.classList.add('row-dragging');+    hideTip();+  },350);+});+document.addEventListener('mousemove',rowDragMove);+document.addEventListener('mouseup',()=>{const wasArmed=rowDrag&&rowDrag.armed;endRowDrag(true);+  if(wasArmed)dragEndedAt=Date.now();});+// Esc abandons the drag and leaves the plan exactly as it was.+document.addEventListener('keydown',e=>{if(e.key==='Escape'&&rowDrag)endRowDrag(false);},true);+// A drag must not also count as a click on the cell it finished over. Timestamped+// rather than a sticky flag: a drag that ends outside the table never produces a+// click here, and a flag left armed would then eat the next real one.+let dragEndedAt=0;+tableBody.addEventListener('click',e=>{+  if(Date.now()-dragEndedAt<150){e.stopPropagation();e.preventDefault();}+},true);+ // ── Inline cell editing ── let tblEditing=null; function startCellEdit(td){@@ -2717,10 +2977,15 @@ function commitCell(r,field,v){ }  // ── Table wiring ──+// Three states per column: ascending, descending, then back to plan order. That+// last click is the way back to a draggable table, so it is the same control the+// user already reached for rather than a separate "clear sort" affordance. tableHead.addEventListener('click',e=>{   const th=e.target.closest('th[data-sort]');if(!th)return;   const k=th.dataset.sort;-  if(sortKey===k)sortDir=-sortDir;else{sortKey=k;sortDir=1;}+  if(sortKey!==k){sortKey=k;sortDir=1;}+  else if(sortDir===1)sortDir=-1;+  else sortKey=null;   prefs.tableSort={key:sortKey,dir:sortDir};savePrefs();   renderTable(); });
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.11.1",+  "version": "0.12.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.11.1",+  "version": "0.12.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+}
server.js+20−6
@@ -182,17 +182,17 @@ function describeEvent(e) {   if (action === 'dates') {     const ds = num('shift', 'start'), de = num('shift', 'end');     const move = (ds === de && ds !== 0) ? `moved ${sgn(ds)}d` : `start ${sgn(ds)}d, end ${sgn(de)}d`;-    return `${who} ${move} on ${name}: ${fld('from', 'start', '?')}..${fld('from', 'end', '?')} -> ${fld('to', 'start', '?')}..${fld('to', 'end', '?')}${via}`;+    return `${who} ${move} on ${name}: ${fmtDate(fld('from', 'start', ''))}..${fmtDate(fld('from', 'end', ''))} -> ${fmtDate(fld('to', 'start', ''))}..${fmtDate(fld('to', 'end', ''))}${via}`;   }   if (action === 'plan.shift') {-    return `${who} moved the whole plan ${sgn(num('shift', 'start'))}d (${e.count || 0} items): starts ${fld('from', 'start', '?')} -> ${fld('to', 'start', '?')}${via}`;+    return `${who} moved the whole plan ${sgn(num('shift', 'start'))}d (${e.count || 0} items): starts ${fmtDate(fld('from', 'start', ''))} -> ${fmtDate(fld('to', 'start', ''))}${via}`;   }   if (action === 'timeline.start') {-    const f = fld('from', 'start', ''), t = fld('to', 'start', '?');-    return `${who} set the timeline to start at ${t}${f ? ` (was ${f})` : ''}${via}`;+    const f = fld('from', 'start', ''), t = fld('to', 'start', '');+    return `${who} set the timeline to start at ${fmtDate(t)}${f ? ` (was ${fmtDate(f)})` : ''}${via}`;   }   if (action === 'milestone') {-    return `${who} moved milestone ${name} ${sgn(num('shift', 'days'))}d: ${fld('from', 'date', '?')} -> ${fld('to', 'date', '?')}${via}`;+    return `${who} moved milestone ${name} ${sgn(num('shift', 'days'))}d: ${fmtDate(fld('from', 'date', ''))} -> ${fmtDate(fld('to', 'date', ''))}${via}`;   }   if (action === 'milestone.rename') {     return `${who} renamed milestone "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`;@@ -241,7 +241,10 @@ function describeEvent(e) {   }   if (action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep || '?'}"${via}`;   if (action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep || '?'}"${via}`;-  if (action === 'reorder') return `${who} reordered rows: ${(Array.isArray(e.moved) ? e.moved : []).join(', ') || 'row order changed'}${via}`;+  if (action === 'reorder') {+    if (e.hint) return `${who} moved ${e.hint} to a different position in the plan${via}`;+    return `${who} reordered rows: ${(Array.isArray(e.moved) ? e.moved : []).join(', ') || 'row order changed'}${via}`;+  }   if (action === 'import') {     return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`;   }@@ -297,6 +300,17 @@ function statusKey(v) { } function statusText(v) { const k = statusKey(v); return k ? k.replace(/-/g, ' ') : ''; } +// Dates a person reads render 17-Aug-2026. Dates a machine reads (event+// payloads, CSV, the data file, the sidecar) stay ISO: this only touches the+// rendering, never the stored value.+const MN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];+function fmtDate(ds) {+  if (!ds) return '?';+  const [y, mo, d] = String(ds).split('-').map(Number);+  if (!y || !mo) return String(ds);+  return d ? `${String(d).padStart(2, '0')}-${MN[mo - 1]}-${y}` : `${MN[mo - 1]} ${y}`;+}+ // An absent field in the sidecar means "the sidecar has nothing to say", so the // data file's own value stands. An empty one means the user cleared it. function setOpt(r, k, v) {