← Commit history

0.27.0: the plan is keyed by uid, and milestones join the selection

Drew Owens ·9f614e3031 · ·parent fd24a85
7 files changed +486−211
README.md+9−3
@@ -30,7 +30,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  **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. -**Multi-select.** Ctrl+click to toggle individual items, Shift+click for range select (Windows Explorer style). Batch editor applies relative date shifts across all selected tasks without flattening dates.+**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.  **Vertical reorder.** Drag bars or labels up/down to reorder rows. Drop indicator shows insertion point. Multi-select reorder moves all selected items together. @@ -46,7 +46,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  **Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). CSV carries the phase by name, so an export re-imported keeps its phases, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off. -**Auto-save.** Edits persist to a sidecar JSON file next to the data file, debounced while you work and flushed immediately if you close or hide the tab, so the changelog never claims an edit the sidecar did not get. Delete the sidecar to reset. The format is additive: a sidecar written by an older version still loads. Rows are keyed by a stable index rather than by their own text, so renaming a milestone keeps it in the plan, and the saved row order is applied all-or-nothing so a stale key can never drop a row.+**Auto-save.** Edits persist to a sidecar JSON file next to the data file, debounced while you work and flushed immediately if you close or hide the tab, so the changelog never claims an edit the sidecar did not get. Delete the sidecar to reset. Every row in it is keyed by its permanent `uid`, so renaming a row, reordering the plan, or inserting a row into the middle of the data file all leave every saved edit pointing exactly where it was. Sidecars written by older versions keyed rows by position; they are translated on the way in and keep working. The saved row order is applied all-or-nothing, so a key that no longer resolves can never drop a row.  **Fills the window.** Cells stretch to the width of the pane, and stop at each grain's minimum so a long plan scrolls horizontally rather than squashing into unreadability. Resizing reflows without a reload. @@ -147,7 +147,13 @@ a round trip through CSV.  Select rows and press the Copy IDs button (or Ctrl+C) to get `uid`, a tab, and the name, one row per line: paste that into an AI chat, ask for a batch of edits, and-every edit lands on the row you meant.+every edit lands on the row you meant. Tasks and milestones can both be selected;+lane headers cannot.++Row order, deletions and every saved edit are keyed by uid too, in both the data+file and the sidecar. That is what makes it safe to insert a row into the middle of+a plan: nothing below it is identified by its position any more. Sidecars written+by older versions are translated on the way in and keep working.  ## Sharing a plan with your team 
SKILL.md+26−13
@@ -326,6 +326,12 @@ The rules, all of them: The user's batch-edit workflow depends on this. They select rows, press **Copy IDs**, and paste you lines of `uid`, a tab, and the current name. Quote the uid back in whatever you write; the name is there so the paste is readable to them.+Tasks and milestones can both be selected, so both can appear in that paste.++One thing does NOT key on uid: `deps`. A dependency still names the task's `id`,+because a task id has always been stable (it is only its TEXT that goes stale) and+because deps are written by hand in data files people already have. Read and write+`deps` as task ids; use uid for everything else.  ## Read the changelog before you edit a plan @@ -402,19 +408,26 @@ milestone dates, phase ranges and row order) are saved to a sidecar JSON file ne your data file. For example, `smt-line.json` saves state to `smt-line-state.json`. This file only contains deltas. Delete it to reset to the original data file values. -The sidecar is at `v: 3`. Task entries are-`{id, s, e, deps, note, owner, status, desc}` and milestone entries are-`{i, key, name, date, owner, status, desc}`, where `key` is the milestone's stable-row key (`#row<n>`, its index in the data file). `order` is a list of those same-stable keys, so renaming a milestone no longer changes its key. Milestone entries-resolve by key first, then by a unique name, then by index, and each milestone is-claimed at most once, so two milestones sharing a name still edit independently. A-task entry with no `id` is ignored rather than matched against the first id-less row. The `owner`, `status` and `desc` fields are-additive: a `v: 2` sidecar written before the table view existed still loads, and so-does the original `v: 1` bare task array. An absent field means the sidecar has-nothing to say and the data file's value stands; an empty one means the user cleared-it. `GET /state` returns this file verbatim, so it is the fastest way for an AI to-read the current owner and deadline of every item.+The sidecar is at `v: 4` and everything in it is keyed by `uid`:++| Field | Shape |+|-------|-------|+| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase}`, one entry per changed row. Tasks carry `s`/`e`, milestones carry `date` |+| order | a list of uids, the plan's row order |+| deleted | a list of uids the user removed. Those uids are retired and never reissued |+| added | rows the user created, each carrying its own `uid` |+| phases | `{i, start, end}`, keyed by phase index, because a phase is not a row |++An absent field means the sidecar has nothing to say and the data file's value+stands; an empty one means the user cleared it.++`v: 1` (a bare task array), `v: 2` and `v: 3` are still read, forever. They keyed+rows positionally, by `#row<n>`, which is the fragility uid exists to end, so the+server translates them to uid keys on the way in: at startup it rewrites the file,+`GET /state` serves the translated view, and `POST /save` translates an older body+before storing it. **You will only ever see v4**, and you never have to write the+old shapes. `GET /state` is the fastest way to read the current owner and deadline+of every item.  Rows the user created in the app live only in this file, under `added`, and they carry their `uid` there the same way a data-file row carries it. `deleted` holds the
docs/BUILD-NOTES.md+82
@@ -3,6 +3,88 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-12 (0.27.0, the plan is keyed by uid)++0.26.0 put a uid on every row and used it for one thing, the copy button.+Everything the app stored still keyed rows the two old ways: a task by+its slug id, and a group or a milestone by `#row<n>`, its index in the data file.+This version moves the storage onto uid and deletes the positional key.++**The failure was measurable, so it was measured.** The same mid-lane insert was+run against 0.25.0 and against this build: one row spliced into the middle of the+data file and placed in the saved order, which is what restructuring a plan looks+like. On 0.25.0, **115 of 141 rows silently changed places**. Nothing failed and+nothing warned: every `#row<n>` below the insertion point named a different row,+they all resolved, the all-or-nothing order guard was satisfied because the count+and the uniqueness were both still right, and the permuted plan was applied. On+this build the same insert moves **zero** rows, every override still lands on its+own row, and the probe appears exactly where it was put. That is the whole reason+this release exists, and it is why the standing workaround (append, never insert)+can be retired.++**The sidecar is v4 and every older one is translated on the way in.** One+`items` list keyed by uid replaces the v3 task list keyed by slug id and the+separate milestone list. `order` and `deleted` are lists of uids. The four-rung+"find the milestone by its key, or by a unique name, or by its index, or by its+name again" ladder is gone from the live path, because it only ever existed to+work around a milestone having no stable key.++**The translator is one function on the server, and that is a design decision+rather than an accident.** `upgradeState()` is total and pure, it accepts v1, v2+and v3, and it is the only code left that understands a legacy key. The boot+rewrites the file through it, `GET /state` serves its result, and `POST /save`+runs an older body through it before storing. So the CLIENT is v4-only, and the+legacy understanding could be deleted from `gantt.html` entirely rather than+duplicated across both sides with a comment begging the two copies to stay in+step, which is what `resolveMs` and `resolveMilestone` had been doing.++Proven at both ends: a genuine v1 bare task array, the oldest shape this app ever+wrote, was translated to uid keys and its overrides landed on the right three+rows; the live plan's v3 sidecar upgraded with 138 overrides, 141 order entries,+40 created rows and 3 deletions, and **zero dropped**, with the merged plan+identical to what 0.25.0 produced from the same two files.++**Milestones join the selection, which is the question 0.24.0 left open.** They+were excluded for exactly one reason: they had no id, so there was nothing to put+in the set. The set holds uids now, so that reason is gone. In both lenses a+milestone can be ctrl or shift selected, it carries the same teal wash on its+label row, it copies its uid, it deletes with the set under one Ctrl+Z, and it+travels with an Alt+Arrow group move and a vertical drag.++**Where it stops is deliberate.** A milestone has one date and no duration, so a+gesture that shifts a start or an end has no honest answer for it:++| Gesture | Milestone in the set |+|---|---|+| Copy IDs, Delete, Alt+Arrow move, vertical drag | travels with the set |+| Batch date editor | not moved, and the panel says "N tasks selected, M milestones unchanged" |+| Horizontal bar drag | not moved. A bar drag is a bar operation |+| Dependency mode | still refused, as before |++Saying so in the panel title beat both alternatives: refusing a mixed selection+would make the user undo work to do work, and inventing a rule (shift the date by+the start delta?) would be a guess presented as a fact.++**Lane headers stay out.** A group has no dates and no owner, and every operation+the selection offers means something different for a lane than for the rows in it.++**Deps still name task ids, on purpose.** A task id is genuinely stable; only its+TEXT goes stale, which is a readability problem and not a correctness one, and the+uid work was about correctness. Moving deps would mean rewriting every `deps`+array in every data file people already have, plus dep mode, plus the arrow+renderer, to fix nothing that is broken. The 40-check dependency suite passes+unchanged.++**Regression.** Against 0.26.0: the 55-check keyboard sweep and the 35-step column+battery are identical, which is the number that matters most, because the+selection model was rewritten underneath them. The 52-key fingerprint moves on+three: the version string twice, and `/state`, whose shape is the release. The+dependency suite differs on two keys, both because every changelog event now+carries the row's uid alongside the `id` and `name` it always carried; the+rendered markdown is character-for-character the same, so a reader written against+the old log still reads it. A compact keeps every uid, in order, with the merged+plan comparing equal. Zero JS errors across all of it.+ ## Build notes, 2026-08-12 (0.26.0, ids that do not move)  0.25.0 gave the user a Copy IDs button and the ids it copied were the best ones
gantt.html+166−128
@@ -723,20 +723,22 @@ const importFileEl = $('importFile'); const projData = await fetch('data').then(r => r.json()); const { project, phases, items } = projData; -// Stable per-row key, assigned once from the data-file index and never touched-// again. Row order is persisted as a list of these. An id-less row (a group, a-// milestone) used to be keyed by its own label or name, so renaming a milestone-// changed its key, the key no longer resolved on the next load, the row was-// dropped, and the following save wrote the shortened plan back to the sidecar-// permanently. An index cannot go stale when the text changes.+// The row's key, everywhere: in the saved order, in the deletion list, in every+// override, and in the changelog. It is the uid, which is a number the server+// assigned once and that nothing can change.+//+// The two fallbacks below it are the previous two answers to this question, kept+// only for a plan that has not been through the uid assignment. `id` is a slug of+// the row's ORIGINAL name, and `#row<n>` is the row's index in the data file,+// which renumbers the moment anything is inserted above it. That renumbering is+// the bug this version exists to end: it silently re-pointed every saved override+// below an insertion, twice, and the standing workaround was a rule people had to+// remember (append, never insert). A server on this version stamps every row+// before it serves a request, so in practice neither fallback is ever reached. function assignRowKeys(){-items.forEach((r,i)=>{ if(!r.id) r._key='#row'+i; });+items.forEach((r,i)=>{ if(!r.uid&&!r.id) r._key='#row'+i; }); }-function rowOrderKey(r){ return r.id || r._key; }-// Milestones use the same stable key as every other row. The server recomputes it-// as `r.id || '#row' + dataFileIndex`, so the two sides must agree exactly or an-// export resolves a sidecar entry onto the wrong milestone.-function msKey(r){ return rowOrderKey(r); }+function rowOrderKey(r){ return r.uid || r.id || r._key; } // Rows the user has deleted, by stable key. Deletion has to be an explicit // signal rather than an absence: applyOrder is all-or-nothing precisely so a // stale key can never silently shrink the plan, so "this row is gone on purpose"@@ -1261,7 +1263,7 @@ function restoreRow(r,idx,droppedDeps){ // Put a fully-formed item into the plan: give it a key if it has none, build the // same DOM the data-file rows got, and drop it into its lane. function insertRow(r,silent){-  if(!r._key&&!r.id)r._key=freshKey();+  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);@@ -1353,8 +1355,7 @@ function sortedNeighbourMove(r,dir){ // row selected is a different gesture (a batch move) and is not this one. function soleSelectedRow(){   if(selected.size!==1)return null;-  const id=[...selected][0];-  return items.find(r=>r.id===id)||null;+  return rowByUid([...selected][0]); } // A held arrow key is one intent, not eight. Undo stays per press, because a // keystroke is the unit you want back, but the changelog waits for the burst to@@ -1397,7 +1398,7 @@ function planGroupMove(rows,dir){   return arr; } function moveSelectedRows(dir){-  const rows=items.filter(r=>r.id&&selected.has(r.id));+  const rows=selectedRows();   if(rows.length<2)return false;   const before=items.map(rowOrderKey);   const fromPhases=rows.map(r=>({r,from:r.phase}));@@ -1643,7 +1644,7 @@ document.addEventListener('keydown',e=>{     // 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));+      const victims=selectedRows();       if(!victims.length)return;       e.preventDefault();       const steps=[];@@ -1735,7 +1736,7 @@ document.addEventListener('keydown',e=>{   // 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.   if(reorderState&&reorderState.active){-    reorderState.moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);+    reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);       if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});     hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;didDrag=true;     return;@@ -1782,11 +1783,19 @@ function setProv(r,v){ if(v===undefined||v===null||v===''||(typeof v==='object'& // v3 adds owner, status and description. The fields are additive: a v2 sidecar // written before the table view existed still loads, and a v2 reader ignores // what it does not know.+// v4. One override list keyed by uid, where v3 had a task list keyed by slug id+// and a separate milestone list carrying a key, an index AND a name because none+// of the three could be trusted on its own. Older sidecars are translated by the+// server on the way in, so this side only ever reads and writes v4. function getState(){   return {-    v: 3,-    tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase})),-    milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,key:msKey(r),name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),phase:r.phase})),+    v: 4,+    items: items.filter(r=>r.uid).map(r=>{+      const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase};+      if(r.type==='milestone')o.date=r.date;+      else { o.s=r.start; o.e=r.end; o.deps=r.deps||[]; o.note=r.note||''; }+      return o;+    }),     phases: phases.map((p,i)=>({i,start:p.start,end:p.end})),     order: items.map(rowOrderKey),     deleted: [...deletedKeys],@@ -1794,81 +1803,47 @@ function getState(){       // `uid` is in this list because it is not optional. A created row lives only       // in the sidecar, so a field missing from this whitelist is a field that       // does not survive the next save: leaving uid out would have quietly-      // stripped the durable id off every row the user ever created.+      // stripped the durable id off every row the user ever created, and in v4 it+      // is also the key every other part of the file uses to name that row.       ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});-      o._key=r._key||rowOrderKey(r); return o;}),+      return o;}),   }; }-// A milestone entry carries a stable key, an index and a name, and all but the-// key can go stale: a reorder moves the index, a rename moves the name. Resolve-// down a ladder and claim each milestone at most once.-//-//   1. the stable row key                              (survives both)-//   2. exactly one unclaimed milestone with this name  (survives a reorder)-//   3. the milestone at this index, if unclaimed       (survives a rename)-//   4. the first unclaimed milestone with this name    (duplicate names)-//-// The old code was `find(name) || ms[i]`: find always won and always returned the-// FIRST match, so an edit to the second of two same-named milestones landed on-// the first and was lost, and a rename resolved to nothing at all.-function resolveMs(ms,sv,claimed){-  if(sv.key){ const k=ms.findIndex(m=>msKey(m)===sv.key); if(k>=0&&!claimed.has(k))return k; }-  const byName=[];-  ms.forEach((m,i)=>{ if(!claimed.has(i)&&m.name===sv.name)byName.push(i); });-  if(byName.length===1)return byName[0];-  if(typeof sv.i==='number'&&ms[sv.i]&&!claimed.has(sv.i))return sv.i;-  if(byName.length)return byName[0];-  return -1;-}+// v4 only. Everything older is translated to v4 by the server before it reaches+// here, which is why the four-rung "find the milestone by its key, or its name,+// or its index, or its name again" ladder that used to live at this spot is gone:+// with a uid there is one lookup and it either finds the row or there is no row. function applyState(state){-  if(!state)return;-  const tasks = Array.isArray(state) ? state : state.tasks;   // v1 was a bare task array-  (tasks||[]).forEach(sv=>{-    // An entry with no id matched the first id-less row, which is a group header,-    // and wrote task dates and an owner onto it, dragging the whole axis with it.-    if(!sv||!sv.id)return;-    const r=items.find(d=>d.id===sv.id);if(!r)return;-    if(sv.s)r.start=sv.s;if(sv.e)r.end=sv.e;if(sv.deps)r.deps=sv.deps;if(sv.note!==undefined)r.note=sv.note;-    if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);-    if(sv.status!==undefined)setOpt(r,'status',sv.status);-    if(sv.desc!==undefined)setOpt(r,'description',sv.desc);-    if(sv.prov!==undefined)setProv(r,sv.prov);-    if(sv.name)r.name=sv.name;-    if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase;-  });-  if(Array.isArray(state))return;-  const ms=items.filter(r=>r.type==='milestone');-  const claimed=new Set();-  (state.milestones||[]).forEach(sv=>{-    if(!sv)return;-    const i=resolveMs(ms,sv,claimed);-    if(i<0)return;-    claimed.add(i);-    const r=ms[i];-    if(sv.date)r.date=sv.date;-    // getState has always written the name; nothing ever read it back, so a-    // rename made in the milestone editor did not survive a reload.-    if(sv.name)r.name=sv.name;-    if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);-    if(sv.status!==undefined)setOpt(r,'status',sv.status);-    if(sv.desc!==undefined)setOpt(r,'description',sv.desc);-    if(sv.prov!==undefined)setProv(r,sv.prov);-    if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase;-  });-  (state.phases||[]).forEach(sv=>{ const p=phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end; } });-  // Created rows first: the overrides, the deletions and the order all reference+  if(!state||Array.isArray(state))return;+  // Created rows first: the overrides, the deletions and the order all name   // them, so they have to exist before any of those are applied.   if(Array.isArray(state.added)){     state.added.forEach(a=>{       if(!a||typeof a!=='object')return;-      const k=a._key||a.id;-      if(k&&items.some(r=>rowOrderKey(r)===k))return;+      if(a.uid&&items.some(r=>r.uid===a.uid))return;       const r=Object.assign({},a);-      if(a._key)r._key=a._key;       addedItems.push(r);       insertRow(r,true);     });   }+  const byUid=new Map();+  items.forEach(r=>{ if(r.uid)byUid.set(r.uid,r); });+  (state.items||[]).forEach(sv=>{+    if(!sv||!sv.uid)return;+    const r=byUid.get(sv.uid);if(!r)return;+    if(sv.s)r.start=sv.s;+    if(sv.e)r.end=sv.e;+    if(sv.date)r.date=sv.date;+    if(sv.deps)r.deps=sv.deps;+    if(sv.note!==undefined)r.note=sv.note;+    if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);+    if(sv.status!==undefined)setOpt(r,'status',sv.status);+    if(sv.desc!==undefined)setOpt(r,'description',sv.desc);+    if(sv.prov!==undefined)setProv(r,sv.prov);+    if(sv.name)r.name=sv.name;+    if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase;+  });+  (state.phases||[]).forEach(sv=>{ const p=phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end; } });   // Before the order, always: the saved order was written after these rows were   // removed, so applying it first would compare against the wrong length and be   // rejected by the anti-shrink guard.@@ -1904,7 +1879,7 @@ function snapSemantic(){   // look like one milestone vanishing and another appearing, so it logged nothing   // (both sides early-return on an unseen name) and instead surfaced as a phantom   // "reorder". It also made one of any same-named pair invisible to the diff.-  const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[msKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase};});+  const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[rowOrderKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase};});   return {byId,ms,order:items.map(rowOrderKey)}; } // The baseline the changelog diffs against. It has to be taken after the plan is@@ -1963,7 +1938,7 @@ function diffAndLog(via){   const curMs=cur.ms,oldMs=lastSnap.ms||{};   Object.keys(oldMs).forEach(k=>{ if(!curMs[k])evs.push({action:'delete',ms:true,key:k,name:oldMs[k].name||k}); });   Object.keys(curMs).forEach(k=>{ if(!oldMs[k]){-    const r=items.find(x=>x.type==='milestone'&&msKey(x)===k);+    const r=items.find(x=>x.type==='milestone'&&rowOrderKey(x)===k);     const born=r&&addedItems.includes(r);     evs.push({action:born?'add':'restore',ms:true,key:k,name:curMs[k].name||k,kind:born?'milestone':undefined});   }});@@ -1994,8 +1969,20 @@ function diffAndLog(via){   moveHint=null;   if(!evs.length)return;   if(SNAPSHOT)return;   // a snapshot has no server to record against+  // Every event carries the row's uid, stamped in one place rather than at the+  // twenty spots above that build one, so a new kind of event cannot be added+  // without it. `id` and `name` stay exactly as they were: the log is append-only+  // and a reader written against the old fields has to keep working forever.+  const stamp=e=>{+    const o={...e,via};+    if(o.uid===undefined){+      if(o.ms&&o.key)o.uid=o.key;+      else if(o.id){ const r=items.find(x=>x.id===o.id); if(r&&r.uid)o.uid=r.uid; }+    }+    return o;+  };   fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},-    body:JSON.stringify({actor:'user',events:evs.map(e=>({...e,via}))})})+    body:JSON.stringify({actor:'user',events:evs.map(stamp)})})     .catch(()=>{}); } function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}@@ -2454,33 +2441,54 @@ setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);  // ── Selection (Ctrl+click toggle, Shift+click range, click single) ── let selAnchor = null;-function taskIds(){return items.filter(r=>r.id).map(r=>r.id);}+// The selection holds UIDS, not task ids, and that is what lets a milestone into+// it. Milestones were excluded for one reason only: they had no id, so there was+// nothing to put in the set. They have one now.+//+// Groups stay out. A group is a lane header rather than a row of work, it has no+// dates and no owner, and every operation the selection offers (copy the ids,+// delete, move as a block) means something different for a lane than for the+// rows inside it.+function selUid(r){ return r&&r.type!=='group'?r.uid:null; }+function selKeys(){return items.filter(r=>selUid(r)).map(r=>r.uid);}+function rowByUid(u){return items.find(r=>r.uid===u)||null;} function clearSelection(){selected.clear();refreshSelVisuals();updateSelCount();} 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');}});+  // A task gets the outline on its bar AND the wash on its label row; a milestone+  // has no bar to outline, so the label row is the whole mark. Both lenses agree,+  // because both are reading the same set.+  selected.forEach(u=>{+    const r=rowByUid(u); if(!r)return;+    if(r.id&&barEls[r.id])barEls[r.id].bar.classList.add('selected');+    if(r._lDiv)r._lDiv.classList.add('multi-sel');+  });   markSelectedRows(); }-function doSelect(id, e){+function doSelect(uid, e){+  if(!uid)return;   const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;   if(shift&&selAnchor){-    const ids=taskIds(), ai=ids.indexOf(selAnchor), ci=ids.indexOf(id);+    const ids=selKeys(), ai=ids.indexOf(selAnchor), ci=ids.indexOf(uid);     if(ai>=0&&ci>=0){       const lo=Math.min(ai,ci), hi=Math.max(ai,ci);       if(!ctrl)selected.clear();       for(let i=lo;i<=hi;i++)selected.add(ids[i]);     }   } else if(ctrl){-    if(selected.has(id))selected.delete(id); else selected.add(id);-    selAnchor=id;+    if(selected.has(uid))selected.delete(uid); else selected.add(uid);+    selAnchor=uid;   } else {-    const was=selected.has(id)&&selected.size===1;+    const was=selected.has(uid)&&selected.size===1;     selected.clear();-    if(!was){selected.add(id);selAnchor=id;}else{selAnchor=null;}+    if(!was){selected.add(uid);selAnchor=uid;}else{selAnchor=null;}   }   refreshSelVisuals();updateSelCount(); }+// The rows the selection names, in plan order, which is the order every operation+// on a set walks them in.+function selectedRows(){return items.filter(r=>r.uid&&selected.has(r.uid));} // What gets copied: the uid, a tab, then the name. The uid is the durable handle, // the one string about a row that nothing can change, so it is what makes an AI // batch edit land on the right row a week and three renames later; the name rides@@ -2492,7 +2500,7 @@ function doSelect(id, e){ // serves a request, but a snapshot taken from an older build has no uids in it at // all, and handing out nothing at all would be worse than handing out the old id. function selectedIdText(){-  return items.filter(r=>r.id&&selected.has(r.id))+  return selectedRows()     .map(r=>(r.uid||r.id||rowOrderKey(r))+'\t'+(r.name||''))     .join('\n'); }@@ -2569,7 +2577,7 @@ labelsEl.addEventListener('click',e=>{   const r=items.find(x=>x._lDiv===row);   if(!r)return;   if(e.ctrlKey||e.metaKey||e.shiftKey){-    if(!r.id)return;+    if(!selUid(r)){ flash(saveInd,'a lane header cannot be selected'); return; }     // The document-level "a click outside closes the editor" handler does not have     // the label column in its allowlist, so it was seeing this very click and     // shutting the batch editor the line below had just opened. Same shape as the@@ -2577,7 +2585,7 @@ labelsEl.addEventListener('click',e=>{     // handler.     e.stopPropagation();     closeLabelEditors(true);-    doSelect(r.id,e);+    doSelect(r.uid,e);     if(selected.size>1)openBatchEditor(); else closeEditor();     return;   }@@ -2700,12 +2708,25 @@ bars.addEventListener('click',e=>{     return;   }   const msHit=e.target.closest('.ms-hit, .ms-cap');-  if(msHit){ clearSelection(); openMilestoneEditor(+msHit.dataset.msi); e.stopPropagation(); return; }+  if(msHit){+    const mr=msByIndex(+msHit.dataset.msi);+    // A modifier on a milestone builds the set, the same gesture as on a bar. A+    // plain click still opens its editor, because that is what a plain click on a+    // milestone has always meant and one is not a selection.+    if(mr&&mr.uid&&(e.ctrlKey||e.metaKey||e.shiftKey)){+      e.stopPropagation();+      doSelect(mr.uid,e);+      if(selected.size>1)openBatchEditor(); else closeEditor();+      return;+    }+    clearSelection(); openMilestoneEditor(+msHit.dataset.msi); e.stopPropagation(); return;+  }   const bar=e.target.closest('.bar[data-id]');   if(bar&&!e.target.classList.contains('drag-handle')){     const id=bar.dataset.id;-    doSelect(id, e);-    if(selected.size===1)openSingleEditor(id);+    const br=items.find(d=>d.id===id);+    doSelect(br&&br.uid, e);+    if(selected.size===1&&soleSelectedRow()===br)openSingleEditor(id);     else if(selected.size>1)openBatchEditor();     else closeEditor();     e.stopPropagation();@@ -2783,15 +2804,21 @@ function renderDepList(id){ 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();}); }+// Dates are the one thing a selection cannot do uniformly now that milestones are+// in it. A task has a start and an end; a milestone has one date and no duration,+// so "shift the end by 3 days" has no meaning for it. The panel edits the TASKS+// and says how many rows it is leaving alone, rather than inventing a rule for a+// milestone or refusing a mixed selection outright. function openBatchEditor(){-  const ids=[...selected];-  const tasks=ids.map(id=>items.find(d=>d.id===id)).filter(r=>r&&r.start);+  const rows=selectedRows();+  const tasks=rows.filter(r=>r&&r.start&&r.type!=='milestone');+  const others=rows.length-tasks.length;   if(!tasks.length){closeEditor();return;}   const earliest=tasks.reduce((a,r)=>r.start<a?r.start:a,tasks[0].start);   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 <button class="de-btn secondary" id="beCopyIds" style="float:right;padding:2px 9px;font-size:10px" title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button></div>+    <div class="batch-title">${tasks.length} task${tasks.length===1?'':'s'} selected${others?`, ${others} milestone${others===1?'':'s'} unchanged`:''} <button class="de-btn secondary" id="beCopyIds" style="float:right;padding:2px 9px;font-size:10px" title="Copy the selected items' permanent 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">@@ -2815,8 +2842,9 @@ function openBatchEditor(){     const all=parseInt($('bShiftAll').value)||0, ds=parseInt($('bShiftS').value)||0, de=parseInt($('bShiftE').value)||0;     const totalStartShift=startDelta+all+ds, totalEndShift=endDelta+all+de;     if(totalStartShift===0&&totalEndShift===0){closeEditor();return;}+    const ids=tasks.map(r=>r.id);     snapshotMany(ids);-    ids.forEach(id=>{const r=items.find(d=>d.id===id);if(!r||!r.start)return;r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);updateBar(id);});+    tasks.forEach(r=>{r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);updateBar(r.id);});     scheduleSave('batch-editor');closeEditor();   });   $('bClose').addEventListener('click',closeEditor);@@ -2875,7 +2903,7 @@ bars.addEventListener('mousedown',e=>{     const id=bar.dataset.id, r=items.find(d=>d.id===id);     const side=handle.classList.contains('left')?'left':'right';     snapshot(id);-    if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}+    if(r.uid&&!selected.has(r.uid)){selected.clear();selected.add(r.uid);selAnchor=r.uid;refreshSelVisuals();updateSelCount();}     dragState={id,side,startX:e.clientX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds:[id],originals:{[id]:{left:parseFloat(bar.style.left),width:parseFloat(bar.style.width),start:r.start,end:r.end}}};     document.body.style.cursor='ew-resize';     return;@@ -2911,7 +2939,12 @@ document.addEventListener('mousemove',e=>{       const bar=gesture.bar||barEls[id]?.bar;       if(!bar){gesture=null;return;}       const r=items.find(d=>d.id===id);-      const moveIds=(selected.has(id)&&selected.size>1)?[...selected]:[id];+      // A bar drag is a bar operation. It carries the TASKS in the selection: a+      // milestone in the set has one date and no duration, so there is no honest+      // answer to dragging its start edge, and it keeps its date. The batch panel+      // says the same thing in words.+      const moveIds=(r&&r.uid&&selected.has(r.uid)&&selected.size>1)+        ? selectedRows().filter(x=>x.id&&barEls[x.id]).map(x=>x.id) : [id];       snapshotMany(moveIds);       const originals={};       // The dates go into the snapshot as well as the pixels: a body move keeps@@ -2919,16 +2952,20 @@ document.addEventListener('mousemove',e=>{       moveIds.forEach(mid=>{const be=barEls[mid],ri=items.find(d=>d.id===mid);         if(be&&ri)originals[mid]={left:parseFloat(be.bar.style.left),width:parseFloat(be.bar.style.width),start:ri.start,end:ri.end};});       dragState={id,side:'move',startX:gesture.startX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds,originals};-      if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}+      if(r&&r.uid&&!selected.has(r.uid)){selected.clear();selected.add(r.uid);selAnchor=r.uid;refreshSelVisuals();updateSelCount();}       document.body.style.cursor='grabbing';     } else {-      // Vertical → reorder+      // Vertical → reorder. This one IS a row move rather than a date move, so it+      // carries the whole set including any milestones in it, which is what+      // Alt+Up and Alt+Down have always done. Keyed by uid for the same reason.       reorderState={active:true,id};       closeEditor();-      const moveIds=(selected.has(id)&&selected.size>1)?[...selected]:[id];-      if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}+      const dr=items.find(d=>d.id===id);+      const moveIds=(dr&&dr.uid&&selected.has(dr.uid)&&selected.size>1)+        ? selectedRows().map(x=>x.uid) : [dr&&dr.uid].filter(Boolean);+      if(dr&&dr.uid&&!selected.has(dr.uid)){selected.clear();selected.add(dr.uid);selAnchor=dr.uid;refreshSelVisuals();updateSelCount();}       reorderState.moveIds=moveIds;-      moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);if(r){r._lDiv.classList.add('dragging');r._bDiv.classList.add('dragging');}});+      moveIds.forEach(mid=>{const r=rowByUid(mid);if(r){r._lDiv.classList.add('dragging');r._bDiv.classList.add('dragging');}});       document.body.style.cursor='grabbing';     }     gesture=null;@@ -2995,7 +3032,7 @@ document.addEventListener('mousemove',e=>{     const mouseY=e.clientY-labelsRect.top+labelsEl.scrollTop;     let targetIdx=-1, insertBefore=true;     for(let i=0;i<items.length;i++){-      const r=items[i];if(reorderState.moveIds.includes(r.id))continue;+      const r=items[i];if(reorderState.moveIds.includes(r.uid))continue;       const cy=rowYCenter(r._lDiv);       if(mouseY<cy){targetIdx=i;insertBefore=true;break;}       targetIdx=i;insertBefore=false;@@ -3023,12 +3060,12 @@ document.addEventListener('mouseup',()=>{    if(reorderState&&reorderState.active){     hideDropIndicators();-    reorderState.moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});+    reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});     document.body.style.cursor='';     if(reorderState.targetIdx!=null){       snapshotOrder();-      const moveItems=reorderState.moveIds.map(mid=>items.find(d=>d.id===mid)).filter(Boolean);-      const remainingItems=items.filter(r=>!reorderState.moveIds.includes(r.id));+      const moveItems=reorderState.moveIds.map(rowByUid).filter(Boolean);+      const remainingItems=items.filter(r=>!reorderState.moveIds.includes(r.uid));       const actualInsert=remainingItems.indexOf(items[reorderState.targetIdx]);       const finalInsert=reorderState.insertBefore?actualInsert:actualInsert+1;       remainingItems.splice(finalInsert,0,...moveItems);@@ -3350,7 +3387,7 @@ function shiftPlanTo(newStartISO, via){   if(!cur||!newStartISO||newStartISO===cur) return false;   const delta=daysBetween(cur,newStartISO);   if(!delta) return false;-  snapshotGroup(taskIds());+  snapshotGroup(items.filter(r=>r.id).map(r=>r.id));   items.forEach(r=>{     if(r.start)r.start=addDays(r.start,delta);     if(r.end)r.end=addDays(r.end,delta);@@ -4435,8 +4472,8 @@ function markSelectedRows(){   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');+    const r=rowItem(t.dataset.key);+    if(r&&r.uid&&selected.has(r.uid))t.classList.add('multi-sel');   }); } function markFocusedRow(){@@ -4498,23 +4535,26 @@ function tableNavReady(){ return prefs.view==='table' && appIsIdle(); } // 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){+function tableSelect(key,e){+  const r=rowItem(key); const uid=selUid(r);+  if(!uid)return;   const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;+  // The rows the table is SHOWING, as uids, milestones among them.   const vis=[...tableBody.querySelectorAll('tr[data-key]')]-    .map(t=>t.dataset.key).filter(k=>k.charAt(0)==='t').map(k=>k.slice(2));+    .map(t=>selUid(rowItem(t.dataset.key))).filter(Boolean);   if(shift&&selAnchor&&vis.includes(selAnchor)){-    const ai=vis.indexOf(selAnchor), ci=vis.indexOf(id);+    const ai=vis.indexOf(selAnchor), ci=vis.indexOf(uid);     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;+    if(selected.has(uid))selected.delete(uid); else selected.add(uid);+    selAnchor=uid;   } else {-    selected.clear(); selected.add(id); selAnchor=id;+    selected.clear(); selected.add(uid); selAnchor=uid;   }-  setFocusedRow('t:'+id);+  setFocusedRow(key);   refreshSelVisuals(); updateSelCount(); } function tabToCell(key,field,dir){@@ -4773,9 +4813,7 @@ tableBody.addEventListener('click',e=>{     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);+    tableSelect(tr.dataset.key, e);     return;   }   const dc=e.target.closest('td.c-deps');
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.26.0",+  "version": "0.27.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.26.0",+  "version": "0.27.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+200−64
@@ -494,6 +494,10 @@ function describeEvent(e) {   if (action === 'import') {     return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`;   }+  if (action === 'state.upgrade') {+    const lost = e.dropped ? `, ${e.dropped} entry(s) that named no row dropped` : ', nothing dropped';+    return `${who} rewrote the saved edits to be keyed by uid instead of by row position (v${e.from || '?'} to v${e.to || 4}): ${e.items || 0} override(s), ${e.order || 0} rows in order, ${e.added || 0} created, ${e.deleted || 0} deleted${lost}. Inserting a row mid-plan no longer moves anything else${via}`;+  }   if (action === 'uid.assign') {     const re = e.reminted ? `, ${e.reminted} re-issued after a clash` : '';     return `${who} gave ${e.count || 0} row(s) a permanent uid${re}: ${e.live || 0} in the plan, ${e.retired || 0} already deleted and retired with theirs. From here a uid is the handle to quote at this app, and it never moves or gets reused. Next is ${e.nextUid || '?'}${via}`;@@ -655,23 +659,27 @@ function uidNum(v) { return isUid(v) ? parseInt(v, 10) : 0; } // their number, and they are simply not on screen. function mergedRefs(data, state) {   const st = (state && !Array.isArray(state)) ? state : null;-  const keys = data.items.map((r, i) => r.id || ('#row' + i));-  const rows = data.items.map((r, i) => ({ obj: r, key: keys[i], from: 'data' }));-  const seen = new Set(keys);+  // Two eras of key in one function, because this runs BEFORE uids exist (that+  // is its whole job) and again afterwards. A uid wins whenever there is one;+  // the legacy key is what a pre-uid sidecar's order and deletions speak.+  const legacy = data.items.map((r, i) => r.id || ('#row' + i));+  const rows = data.items.map((r, i) => ({ obj: r, key: r.uid || legacy[i], alt: legacy[i], from: 'data' }));+  const seen = new Set(rows.map(r => r.key).concat(rows.map(r => r.alt)));   ((st && st.added) || []).forEach(a => {     if (!a || typeof a !== 'object') return;-    const k = a._key || a.id;+    const alt = a._key || a.id;+    const k = a.uid || alt;     if (!k || seen.has(k)) return;     seen.add(k);-    rows.push({ obj: a, key: k, from: 'added' });+    rows.push({ obj: a, key: k, alt, from: 'added' });   });   const gone = new Set((st && st.deleted) || []);-  const live = rows.filter(r => !gone.has(r.key));-  const dead = rows.filter(r => gone.has(r.key));+  const live = rows.filter(r => !gone.has(r.key) && !(r.alt && gone.has(r.alt)));+  const dead = rows.filter(r => gone.has(r.key) || (r.alt && gone.has(r.alt)));   const order = st && st.order;   if (Array.isArray(order) && order.length === live.length) {     const byKey = new Map();-    live.forEach(r => byKey.set(r.key, r));+    live.forEach(r => { byKey.set(r.key, r); if (r.alt && !byKey.has(r.alt)) byKey.set(r.alt, r); });     live.forEach(r => { const k = r.obj.label || r.obj.name; if (k && !byKey.has(k)) byKey.set(k, r); });     const rr = order.map(k => byKey.get(k)).filter(Boolean);     // All or nothing, exactly as mergedData's order guard is: one key that no@@ -829,53 +837,182 @@ function ensureUids(why) {   return info; } +// ── The sidecar speaks uid (v4), and every older sidecar is translated on the+//    way in ──+//+// v1 was a bare array of task overrides. v2 added milestones, phases and row+// order. v3 added owner, status and description. All three key rows the only way+// they could: a task by its slug id, and everything else by `#row<n>`, its index+// in the data file. That index is the fragility this version exists to kill, so+// v4 keys everything by uid and folds the task and milestone override lists into+// one, because with a durable key there is no longer any difference between them.+//+// This function is total and pure. It never writes, it accepts anything, and it+// returns a v4 state. It is the ONLY place legacy keys are understood, which is+// what makes it safe to delete that understanding from everywhere else: the boot+// rewrites the file through here, `/state` serves the result of it, and `/save`+// runs an old client's body through it before storing. A sidecar written in 2026+// still loads in five years because it is translated once, on the way in, by+// code that has one job.+function upgradeState(state, data) {+  if (!state) return null;+  if (!Array.isArray(state) && state.v === 4) return state;+  const legacy = Array.isArray(state) ? { v: 1, tasks: state } : (state || {});+  const keys = data.items.map((r, i) => r.id || ('#row' + i));+  // Every way a legacy sidecar could have named a row, pointed at its uid.+  const uidOf = new Map();+  data.items.forEach((r, i) => { if (r.uid) uidOf.set(keys[i], r.uid); });+  data.items.forEach(r => { const k = r.label || r.name; if (k && r.uid && !uidOf.has(k)) uidOf.set(k, r.uid); });+  (legacy.added || []).forEach(a => {+    if (!a || typeof a !== 'object' || !a.uid) return;+    const k = a._key || a.id;+    if (k && !uidOf.has(k)) uidOf.set(k, a.uid);+    if (a.name && !uidOf.has(a.name)) uidOf.set(a.name, a.uid);+  });+  const byId = new Map();+  data.items.forEach((r, i) => { if (r.id && r.uid) byId.set(r.id, r.uid); });+  (legacy.added || []).forEach(a => { if (a && a.id && a.uid && !byId.has(a.id)) byId.set(a.id, a.uid); });++  const out = { v: 4, items: [], phases: [], order: [], deleted: [], added: [] };+  const dropped = { tasks: 0, milestones: 0, order: 0 };++  // A task override was keyed by the task's id, which is stable, so this is a+  // straight lookup. One that names no row is dropped, which is exactly what the+  // old merge did with it (`find(id)` returning nothing and returning early).+  (legacy.tasks || []).forEach(sv => {+    if (!sv || !sv.id) return;+    const uid = byId.get(sv.id);+    if (!uid) { dropped.tasks++; return; }+    const o = { uid };+    ['s', 'e', 'deps', 'note', 'owner', 'status', 'desc', 'prov', 'name'].forEach(k => { if (sv[k] !== undefined) o[k] = sv[k]; });+    if (typeof sv.phase === 'number') o.phase = sv.phase;+    out.items.push(o);+  });++  // A milestone override was keyed three ways at once because none of them was+  // reliable on its own. Run the same ladder one last time to find the row, then+  // take its uid and never do this again.+  const ms = [], msKeys = [];+  data.items.forEach((r, i) => { if (r.type === 'milestone') { ms.push(r); msKeys.push(keys[i]); } });+  const claimed = new Set();+  (legacy.milestones || []).forEach(sv => {+    if (!sv) return;+    let uid = sv.key && uidOf.get(sv.key);+    if (uid) {+      const at = ms.findIndex(m => m.uid === uid);+      if (at >= 0 && !claimed.has(at)) claimed.add(at); else uid = null;+    }+    if (!uid) {+      const i = resolveMilestone(ms, msKeys, sv, claimed);+      if (i < 0) { dropped.milestones++; return; }+      claimed.add(i);+      uid = ms[i].uid;+    }+    if (!uid) { dropped.milestones++; return; }+    const o = { uid };+    ['date', 'name', 'owner', 'status', 'desc', 'prov'].forEach(k => { if (sv[k] !== undefined) o[k] = sv[k]; });+    if (typeof sv.phase === 'number') o.phase = sv.phase;+    // A milestone and a task can now share the one list, so an override that+    // arrives twice for the same row is merged rather than fighting itself.+    const prev = out.items.find(x => x.uid === uid);+    if (prev) Object.assign(prev, o); else out.items.push(o);+  });++  out.phases = (legacy.phases || []).slice();+  (legacy.order || []).forEach(k => {+    const uid = uidOf.get(k);+    if (uid) out.order.push(uid); else dropped.order++;+  });+  // A deleted key that names no row was inert before this and stays inert: it+  // cannot match a uid, and it matched nothing when it was written either.+  (legacy.deleted || []).forEach(k => out.deleted.push(uidOf.get(k) || k));+  (legacy.added || []).forEach(a => {+    if (!a || typeof a !== 'object') return;+    const o = Object.assign({}, a);+    delete o._key;+    out.added.push(o);+  });+  if (legacy.stateRev !== undefined) out.stateRev = legacy.stateRev;+  out.upgradedFrom = legacy.v || 1;+  if (dropped.tasks || dropped.milestones || dropped.order) out.upgradeDropped = dropped;+  return out;+}++// Rewrite the sidecar in v4, once, at boot. Same care as the uid assignment: back+// the file up, bump the revision so a tab holding the old shape reloads instead+// of writing it back, say so in the changelog, and never be fatal.+function ensureStateV4() {+  let state;+  try { state = loadState(); } catch (e) {+    console.error('adom-gantt: could not read the sidecar to upgrade it:', e.message);+    return { ok: false, error: e.message };+  }+  if (!state) return { ok: true, upgraded: false };+  if (!Array.isArray(state) && state.v === 4) return { ok: true, upgraded: false };+  let next;+  try {+    next = upgradeState(state, loadData());+    next.stateRev = (next.stateRev || 0) + 1;+    backupOnce(STATE_FILE, '.pre-uid-keys.bak');+    writeAtomic(STATE_FILE, JSON.stringify(next));+  } catch (e) {+    console.error('adom-gantt: could not upgrade the sidecar to uid keys:', e.message);+    return { ok: false, error: e.message };+  }+  const d = next.upgradeDropped || {};+  try {+    appendLog([{ action: 'state.upgrade', from: next.upgradedFrom, to: 4,+                 items: next.items.length, order: next.order.length, added: next.added.length,+                 deleted: next.deleted.length, dropped: (d.tasks || 0) + (d.milestones || 0) + (d.order || 0),+                 via: 'startup' }], 'adom-gantt');+  } catch (e) {+    console.error('adom-gantt: could not record the sidecar upgrade in the changelog:', e.message);+  }+  console.log(`adom-gantt: sidecar upgraded v${next.upgradedFrom} to v4, keyed by uid (${next.items.length} override(s), ${next.order.length} in order, ${next.added.length} created, ${next.deleted.length} deleted)`);+  return { ok: true, upgraded: true, from: next.upgradedFrom, stateRev: next.stateRev };+}+ function mergedData() {   const data = loadData();-  const state = loadState();+  const state = upgradeState(loadState(), data);   if (!state) return data;-  // v1 was a bare array of tasks; v2 also carries milestones, phases and row order.-  // Keys first, deletions second, everything else after: the saved order was-  // written against the post-deletion plan, so the order guard has to compare-  // against that. A key that no longer resolves still trips the guard; a key-  // listed in `deleted` is a deliberate removal and does not.-  let keys = rowKeysOf(data.items);   // Rows created in the app live only in the sidecar, so every reader of the-  // merged plan (json, csv, svg, html) has to fold them back in. Appended with-  // their own keys; the saved order puts them where they belong.-  if (!Array.isArray(state) && Array.isArray(state.added)) {-    state.added.forEach(a => {-      if (!a || typeof a !== 'object') return;-      const k = a._key || a.id;-      if (!k || keys.includes(k)) return;-      const row = Object.assign({}, a);-      delete row._key;-      data.items.push(row);-      keys.push(k);-    });-  }-  if (!Array.isArray(state) && Array.isArray(state.deleted) && state.deleted.length) {+  // merged plan (json, csv, svg, html) has to fold them back in. Appended; the+  // saved order puts them where they belong.+  const have = new Set(data.items.map(r => r.uid).filter(Boolean));+  (state.added || []).forEach(a => {+    if (!a || typeof a !== 'object') return;+    if (!a.uid || have.has(a.uid)) return;+    have.add(a.uid);+    const row = Object.assign({}, a);+    delete row._key;+    data.items.push(row);+  });+  // Deletions before overrides and before the order: the saved order was written+  // against the post-deletion plan, so the order guard has to compare with that.+  if (Array.isArray(state.deleted) && state.deleted.length) {     const gone = new Set(state.deleted);-    const keptItems = [], keptKeys = [];-    data.items.forEach((r, i) => { if (!gone.has(keys[i])) { keptItems.push(r); keptKeys.push(keys[i]); } });-    const removedIds = new Set(data.items.filter((r, i) => gone.has(keys[i])).map(r => r.id).filter(Boolean));-    data.items = keptItems; keys = keptKeys;+    const removedIds = new Set(data.items.filter(r => gone.has(r.uid)).map(r => r.id).filter(Boolean));+    data.items = data.items.filter(r => !gone.has(r.uid));     // dependencies on a deleted row go with it, everywhere the data is read     if (removedIds.size) data.items.forEach(r => {       if (Array.isArray(r.deps)) r.deps = r.deps.filter(d => !removedIds.has(d));     });   }-  const tasks = Array.isArray(state) ? state : state.tasks;-  (tasks || []).forEach(sv => {-    // An entry with no id would match the first id-less row in the file, which is-    // a group header, and write task dates and an owner onto it. Skip it.-    if (!sv || !sv.id) return;-    const r = data.items.find(d => d.id === sv.id);+  // One list, one key, one lookup. This used to be two lists and a four-rung+  // ladder that guessed a milestone from its name and its index, because a+  // milestone had no stable key to be found by. It has one now.+  const byUid = new Map();+  data.items.forEach(r => { if (r.uid) byUid.set(r.uid, r); });+  (state.items || []).forEach(sv => {+    if (!sv || !sv.uid) return;+    const r = byUid.get(sv.uid);     if (!r) return;     if (sv.s) r.start = sv.s;     if (sv.e) r.end = sv.e;+    if (sv.date) r.date = sv.date;     if (sv.deps) r.deps = sv.deps;     if (sv.note !== undefined) r.note = sv.note;-    // v3 fields. Absent in a v2 sidecar, in which case the data file wins.     setOpt(r, 'owner', sv.owner);     setOpt(r, 'status', sv.status);     setOpt(r, 'description', sv.desc);@@ -883,33 +1020,13 @@ function mergedData() {     if (sv.name) r.name = sv.name;     if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase;   });-  if (Array.isArray(state)) return data;-  const ms = [], msKeys = [];-  data.items.forEach((r, i) => { if (r.type === 'milestone') { ms.push(r); msKeys.push(keys[i]); } });-  const claimed = new Set();-  (state.milestones || []).forEach(sv => {-    if (!sv) return;-    const i = resolveMilestone(ms, msKeys, sv, claimed);-    if (i < 0) return;-    claimed.add(i);-    const r = ms[i];-    if (sv.date) r.date = sv.date;-    // A rename made in the milestone editor is a real edit and has to persist.-    if (sv.name) r.name = sv.name;-    setOpt(r, 'owner', sv.owner);-    setOpt(r, 'status', sv.status);-    setOpt(r, 'description', sv.desc);-    setProv(r, sv.prov);-    if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase;-  });   (state.phases || []).forEach(sv => {     const p = data.phases[sv.i];     if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; }   });   if (Array.isArray(state.order) && state.order.length === data.items.length) {-    const byKey = orderKeyMap(data.items, keys);-    const reordered = state.order.map(k => byKey.get(k)).filter(Boolean);-    // All or nothing. A single unresolved key would otherwise drop a row.+    const reordered = state.order.map(u => byUid.get(u)).filter(r => r && data.items.includes(r));+    // All or nothing. A single unresolved uid would otherwise drop a row.     if (reordered.length === data.items.length && new Set(reordered).size === data.items.length) {       data.items = reordered;     }@@ -1684,9 +1801,18 @@ const server = http.createServer(async (req, res) => {             error: 'someone else changed this plan since you loaded it' });           return;         }-        incoming.stateRev = cur + 1;+        // A body in an older shape is translated rather than refused, so a+        // script, an old tab that never sent a baseRev, or a restored backup+        // cannot put legacy keys back into the file the app now reads as uids.+        let store = incoming;+        if (Array.isArray(store) || store.v !== 4) {+          try { store = upgradeState(store, loadData()) || incoming; } catch (e) {}+        }+        store.stateRev = cur + 1;+        delete store.baseRev;         delete incoming.baseRev;-        fs.writeFileSync(STATE_FILE, JSON.stringify(incoming), 'utf8');+        writeAtomic(STATE_FILE, JSON.stringify(store));+        incoming.stateRev = store.stateRev;         jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length, stateRev: incoming.stateRev });       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -1763,8 +1889,15 @@ const server = http.createServer(async (req, res) => {       return;     } +    // Always v4, whatever is on disk. The boot rewrites the file, so this is+    // belt and braces for a sidecar dropped in underneath a running server: the+    // client is v4-only on purpose, and the one translator lives on this side.     if (urlPath === '/state') {       if (fs.existsSync(STATE_FILE)) {+        let out;+        try { out = upgradeState(loadState(), loadData()); }+        catch (e) { out = null; }+        if (out) { jsonResp(res, 200, out); return; }         res.writeHead(200, { 'Content-Type': 'application/json' });         res.end(fs.readFileSync(STATE_FILE, 'utf8'));       } else {@@ -1960,6 +2093,9 @@ if (SESSION_FILE) loadSessions(); // plan where every row has a durable id, or the copy button hands out slugs for // as long as it takes the first person to load the page. ensureUids('startup');+// Second, and only ever after the first: the upgrade rewrites the sidecar's keys+// as uids, which requires every row to have one.+ensureStateV4();  server.listen(PORT, '127.0.0.1', () => {   console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`);