app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
0.26.0: permanent uid on every item, server-allocated and never reused
7 files changed
+543−28
README.md+30@@ -121,6 +121,34 @@ Create a JSON file with three sections: Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values. +## Item ids that do not move++Every row carries a `uid`: a zero-padded number, assigned once, that nothing can+change. It is written into the file for you, so you never type one.++```json+{ "type": "task", "uid": "00042", "id": "t1", "name": "Task name", "...": "..." }+```++It exists because every other name a row has is conditional. A task `id` is a slug+of the name the row had when it was made, so it reads as a lie after a rename. A+group or a milestone has no id at all and is tracked by its position in the file,+which renumbers whenever the file is restructured. A uid is a number, it means+nothing, and it survives a rename, a reorder, a move between lanes, a compact and+a round trip through CSV.++- **Assigned once**, in the order the plan reads, the first time a server on this+ version starts on your data file. It backs the file up first (`.pre-uid.bak`).+- **Handed out by the server**, from `project.nextUid`, one at a time. A browser+ never invents one, so two people adding a row in the same second cannot collide.+- **Retired on delete.** The number goes with the row and is never issued again,+ so a stale reference resolves to nothing rather than to somebody else's row.+- **Carried by every export**, and read back by every import.++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.+ ## Sharing a plan with your team Every page load and every change requires a signed-in Adom account. An unauthenticated@@ -207,6 +235,8 @@ table, the item editor or the batch editor are taken exactly as typed. | `/data` | GET | Current project data | | `/state` | GET | Saved edits (sidecar) | | `/save` | POST | Persist edits |+| `/uid` | POST | Allocate the next permanent item id. `{"count":n}` for a batch, up to 500 |+| `/uid/backfill` | POST | Give a uid to any row that has none. Runs at startup too, so this is only for a data file edited under a running server | | `/changelog` | GET | Edit history. `?since=<rev>` for the tail, `?format=md` for the readable version | | `/log` | POST | Append events to the changelog (used by the app, and by an AI recording its own edits) | | `/changelog/reset` | POST | Delete the changelog |
SKILL.md+54@@ -49,11 +49,13 @@ Create a JSON file with this structure: "items": [ { "type": "group",+ "uid": "00001", "label": "Phase 0 — Setup (Jun–Aug 2026)", "phase": 0 }, { "type": "task",+ "uid": "00002", "id": "unique-id", "name": "Task Name", "start": "2026-06-01",@@ -66,6 +68,7 @@ Create a JSON file with this structure: }, { "type": "milestone",+ "uid": "00003", "name": "M1: First milestone", "date": "2026-08-31", "phase": 0,@@ -84,6 +87,7 @@ Create a JSON file with this structure: | title | string | yes | Project name shown in header | | subtitle | string | no | Short tagline (default: "Gantt chart") | | appName | string | no | Shown in brand zone (default: "adom-gantt") |+| nextUid | number | no | The uid counter. Written and owned by the server, never by you. See uid below | | view | object | no | The author's intended default view, see below | **project.view** (optional) sets how the chart opens. It applies to the HTML export, to@@ -126,6 +130,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | Field | Type | Required | Description | |-------|------|----------|-------------|+| uid | string | assigned | Permanent id. The server writes it; never invent one. See uid below | | label | string | yes | Group header text | | phase | number | yes | Index into phases[] (controls filter visibility) | @@ -133,6 +138,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | Field | Type | Required | Description | |-------|------|----------|-------------|+| uid | string | assigned | Permanent id. The server writes it; never invent one. See uid below | | id | string | yes | Unique identifier (used for deps and state) | | name | string | yes | Task label | | start | YYYY-MM-DD | yes | Start date. Required: the chart is a function of dates |@@ -150,6 +156,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | Field | Type | Required | Description | |-------|------|----------|-------------|+| uid | string | assigned | Permanent id. The server writes it; never invent one. See uid below | | name | string | yes | Milestone label | | date | YYYY-MM-DD | yes | Milestone date, and the Deadline column in the table | | phase | number | yes | Index into phases[] |@@ -278,6 +285,48 @@ versions carry `ms: true` and a `key` instead of an `id`, since milestones have `project.view.mode` pins which lens a plan opens in, the same way `grain` pins the cell size. Use it when the plan is an assignment list more than a timeline. +## uid: the one handle worth quoting++Every item carries a `uid`, a zero-padded number assigned once and never changed.+**Prefer it over every other way of naming a row**, in what you write and in what+you ask the user for.++```json+{ "type": "task", "uid": "00042", "id": "hw-etch-run", "name": "Etch the cards" }+```++Why the alternatives are worse:++| Handle | Breaks when |+|--------|-------------|+| `name` | anybody renames the row, and two rows can share one |+| `id` (tasks only) | never breaks, but it is a slug of the ORIGINAL name, so it goes stale and misleads: a row whose id says `hw-etch-run` may now be called something else entirely |+| position, `#row<n>` | a row is inserted or removed above it, or the file is restructured. Groups and milestones have no id and used to live on this |+| `uid` | nothing. It is a number, it means nothing, and it is never reissued |++The rules, all of them:++- **The server assigns it, never you.** A uid comes from `POST /uid`, which returns+ `{"ok":true,"uid":"00145","nextUid":146}` and persists the counter before it+ answers. `{"count":n}` gets a batch, up to 500. If you are adding rows to a data+ file by hand, either allocate from `/uid` first or add them without a uid and+ `POST /uid/backfill` (the server also does this at startup).+- **A deleted row keeps its number to the grave.** Creating a row after deleting+ one gets a NEW number. So a uid you were given last week either still names the+ same row, or names nothing at all. It can never name something else.+- **`project.nextUid` is the counter** and it only ever goes up, including through+ an import. Do not edit it, and do not "tidy" uids into a tighter range: gaps are+ the record of things that were deleted.+- **Every export carries it** (`/export/json`, `/export/csv` as the first column,+ the HTML snapshot). `/import/json` and `/import/csv` read it back, so a round+ trip lands on the same handles. A duplicate or malformed uid in an imported file+ is re-issued (the first occurrence keeps it) and the response says how many.+ MS Project and Jira imports number from scratch: their ids mean nothing here.++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.+ ## Read the changelog before you edit a plan Every edit a human makes in the browser is appended to `<datafile>-changelog.jsonl`.@@ -367,6 +416,11 @@ nothing to say and the data file's value stands; an empty one means the user cle 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. +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+row keys of rows the user removed; their uids are retired and are not reissued, which+is why `project.nextUid` can be higher than any uid you can see.+ ## Example ```bash
docs/BUILD-NOTES.md+81@@ -3,6 +3,87 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## 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+the app had, which was not good enough. A task id is a slug of the name the row+had when it was made, so it survives a rename and then MISREPRESENTS the row for+the rest of its life. A group or a milestone has no id at all and rides `#row<n>`,+its original index in the data file, which renumbers the moment anything is+inserted above it. That has cost real edits twice, and the standing workaround+was a rule people had to remember: never insert, only append.++**`uid` is a number, it means nothing, and that is the entire feature.** Five+digits, zero-padded, assigned once, on every row including groups and milestones.+Padding is presentation and not identity: past 99999 the strings get+longer, and every comparison in the code is string equality, never width and+never numeric order.++**The server is the only thing that can make one.** `POST /uid` reads the+counter, increments it, writes the data file, and only then answers. The read,+the increment and the write happen in one synchronous run with no await in the+middle, so node's own event loop serialises two creates arriving together. The+client has NO fallback path: if the call cannot be made the create is refused,+the panel stays open with the reason in it, and nothing is added. That is a+deliberate choice against the friendlier option, because a browser that mints its+own number is a browser that mints the same number as the other tab, and a+duplicate durable id is a worse thing to own than a row you have to add again.+Measured both ways: a normal create calls `/uid` exactly once and gets 00145; the+same create with `/uid` rejecting shows the message, leaves the row count+unchanged, keeps the panel open, and works again as soon as the endpoint is back.++**The one place a uid IS minted locally is a snapshot**, which has no server+behind it and nothing else writing to it, and which carries `project.nextUid` in+its inlined data to carry on from.++**A deletion retires the number.** Nothing frees a uid, ever. Delete the row+holding 00145 and the next row created gets 00146, so a uid you were handed last+week either still names the same row or names nothing at all, and can never name+something else. Verified end to end through the UI, not just in the allocator.+The three rows already deleted in the live plan were given uids too, at the end,+so an undo of an old deletion still comes back with a handle.++**Assignment runs at startup, before the door opens.** It walks the MERGED+display order rather than the data file's order, so the numbers climb down the+plan the way the plan reads rather than the way the file happens to be stored,+and it needs the sidecar's `added`, `deleted` and `order` to do that. It backs+both files up once (`.pre-uid.bak`), bumps `stateRev` when it touches the sidecar+so an open tab 409s and reloads instead of writing uid-less rows back over the+new ones, and writes one loud changelog line. It does nothing at all on the+second run, which was measured: byte-identical files, no new log entry. It is+also wrapped so it can never take the server down. A migration that kills the+process is a worse bug than the one it fixes.++**The key test was compact.** `/compact` bakes the merged plan into the data+file, and the merged plan is built by copying rows, which is exactly the shape of+operation that silently drops a field nobody remembered. It does not: 141 uids,+same values and same order before and after, and the whole merged plan compared+equal. The second version of that trap was `getState()`, whose `added` entries go+through an explicit field whitelist. `uid` had to be added to it by hand or every+row the user ever created would have lost its uid on the next save.++**The counter never rewinds, including through an import.** The first CSV round+trip put it back from 145 to 142, because a CSV carries the 141 live rows and has+nowhere to put the counter, which would have handed a retired number to a new+row. Every importer now floors the counter at what this instance has already+issued. Numbers the incoming file CARRIES are still honoured, because an export+coming home has to land on the handles it went out with.++| Import | uids in the file | Result |+|---|---|---|+| JSON, CSV | present | kept, in place |+| JSON, CSV | missing | minted, above the instance's counter |+| JSON, CSV | duplicated or malformed | re-issued, first occurrence keeps it, count in the response |+| MS Project XML, Jira CSV | any | numbered from scratch, above the counter: a Jira key is Jira's handle, not this plan's |++**Regression.** 52-key fingerprint: 50 identical, and the two that moved are the+two that had to (the CSV header gained a `uid` column, `/state` gained uids on+its 40 created rows, and the rest of the sidecar compared equal field by field).+55-check keyboard sweep, 35-step column battery and the 40-key dependency suite:+zero differences. The init-shape invariant still passes at 380 top-level+statements. The HTML snapshot still renders from `file://` with its three+exact-text anchors intact, and Copy IDs inside a snapshot returns a uid.+ ## Build notes, 2026-08-12 (0.25.0, copy the ids) Select rows, copy their ids, paste them into an AI chat and ask for a batch edit. The
gantt.html+78−19@@ -666,7 +666,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button> <span class="sort-lock-note" id="sortLockNote" style="display:none"></span> <span class="table-count" id="tblCount"></span>- <button class="tt-btn" id="btnCopyIds" style="display:none" title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>+ <button class="tt-btn" id="btnCopyIds" style="display:none" 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="table-scroll"> <div class="row-drop" id="rowDrop"></div>@@ -774,6 +774,37 @@ const addedItems = []; // id anyway; this matters for created milestones, which have none. let newKeyN = 0; function freshKey(){ return '#new-'+Date.now().toString(36)+'-'+(newKeyN++); }+// ── uid, the durable handle ──+//+// A task id is a slug of the name the row had when it was made, so it reads as a+// lie the moment the row is renamed. A group or a milestone has no id at all and+// rides `#row<n>`, its original index in the data file, which renumbers whenever+// the file is restructured. `uid` is a number, it means nothing, and nothing that+// happens to the row can change it, which is exactly what a batch edit needs to+// quote back.+//+// It is NEVER minted here. The server owns the counter and hands numbers out one+// at a time from POST /uid, so two tabs creating a row in the same second cannot+// invent the same one. If the call fails the create is refused and says so: a+// row you have to add again is a smaller problem than two rows sharing a handle.+const UID_WIDTH=5;+function fmtUid(n){ return String(n).padStart(UID_WIDTH,'0'); }+// A snapshot is a single file with no server behind it and nothing else writing+// to it, so it is the one place a number can be made locally without a race. It+// carries on from the counter the plan was exported with.+let snapUidN=null;+function snapshotUid(){+ if(snapUidN===null){ const n=Math.floor(Number(project&&project.nextUid)); snapUidN=(Number.isFinite(n)&&n>0)?n:1; }+ return fmtUid(snapUidN++);+}+async function allocUid(){+ if(SNAPSHOT)return snapshotUid();+ const r=await fetch('uid',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});+ if(!r.ok)throw new Error('the server answered '+r.status);+ const j=await r.json();+ if(!j||!j.ok||!j.uid)throw new Error((j&&j.error)||'no ID came back');+ return j.uid;+} function slugify(t){ return String(t||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,40)||'item'; } function freshId(name){ const base=slugify(name); let id=base, n=2;@@ -1760,7 +1791,11 @@ function getState(){ order: items.map(rowOrderKey), deleted: [...deletedKeys], added: addedItems.map(r=>{const o={};- ['type','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});+ // `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.+ ['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;}), }; }@@ -2446,18 +2481,19 @@ function doSelect(id, e){ } refreshSelVisuals();updateSelCount(); }-// What gets copied: the stable id, a tab, then the name. The id is the thing every-// other surface keys on, /state and /export/json and the changelog all speak it, so-// it is what makes an AI batch edit land on the right row; the name rides along on-// the same line so the paste is readable by the human who made it. One row per line.+// 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+// along on the same line so the paste is readable by the human who made it, and a+// tab means it drops into a spreadsheet as two columns. One row per line. //-// The `rowOrderKey` fallback is for a row with no id. Nothing can currently reach it,-// because a row with no id cannot enter the selection in either lens, but the-// resolver is written correctly so it stays right if milestones ever become-// selectable.+// The fallback chain exists for a plan that has not been through the uid+// assignment yet: a server started on this build stamps every row before it+// 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))- .map(r=>(r.id||rowOrderKey(r))+'\t'+(r.name||''))+ .map(r=>(r.uid||r.id||rowOrderKey(r))+'\t'+(r.name||'')) .join('\n'); } function showIdFallback(txt){@@ -2505,7 +2541,7 @@ function updateSelCount(){ syncCopyBtn(); if(selected.size){ selCountEl.innerHTML=`<span class="n">${selected.size}</span> selected · Copy IDs`;- selCountEl.title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits";+ selCountEl.title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits"; selCountEl.classList.add('show'); } else selCountEl.classList.remove('show'); }@@ -2755,7 +2791,7 @@ function openBatchEditor(){ const latest=tasks.reduce((a,r)=>r.end>a?r.end:a,tasks[0].end); editorEl.innerHTML=`<div class="drag-bar"></div>- <div class="batch-title">${ids.length} tasks selected <button class="de-btn secondary" id="beCopyIds" style="float:right;padding:2px 9px;font-size:10px" title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button></div>+ <div class="batch-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-hint">All changes are relative, each task shifts from its own dates</div> <div style="margin-bottom:10px"> <div class="de-row">@@ -3504,21 +3540,44 @@ function openCreateEditor(){ editorEl.addEventListener('keydown',e=>{e.stopPropagation(); if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('ncCreate').click();} else if(e.key==='Escape'){e.preventDefault();closeEditor();}});- $('ncCreate').addEventListener('click',()=>{+ // Async, because a new row cannot exist until the server has given it a uid.+ // Every field is read BEFORE the await: the panel can be dismissed while the+ // request is in flight, and reading an input out of a torn-down panel afterwards+ // would throw and lose the row the user had just described.+ let creating=false;+ $('ncCreate').addEventListener('click',async()=>{+ if(creating)return; // Enter and a click both land here const name=$('ncName').value.trim(); if(!name){const er=$('ncErr');er.textContent='A name is required.';er.style.display='';$('ncName').focus();return;} const isMs=$('ncType').value==='milestone'; const phase=parseInt($('ncPhase').value,10)||0; const end=$('ncEnd').value||endVal; const start=$('ncStart').value||startVal;- const r={type:isMs?'milestone':'task',name,phase};+ const owner=$('ncOwner').value.trim();+ const st=$('ncStatus').value;+ const desc=$('ncDesc').value.trim();+ const prov=$('ncProv').value.trim();+ creating=true;+ const btn=$('ncCreate'); if(btn)btn.disabled=true;+ let uid=null;+ try{ uid=await allocUid(); }+ catch(err){+ creating=false;+ const b=$('ncCreate'); if(b)b.disabled=false;+ const er=$('ncErr');+ if(er){ er.textContent='No item ID could be allocated, so nothing was created ('+err.message+'). Check the connection and try again.'; er.style.display=''; }+ else flash(saveInd,'could not allocate an item ID, nothing was created');+ return;+ }+ creating=false;+ const r={type:isMs?'milestone':'task',uid,name,phase}; if(isMs){ r.date=end; } else { r.id=freshId(name); r.start=start<=end?start:end; r.end=end; } if(!isMs)r._key=undefined;- const owner=$('ncOwner').value.trim(); if(owner)r.owner=owner;- const st=$('ncStatus').value; if(st)r.status=st;- const desc=$('ncDesc').value.trim(); if(desc)r.description=desc;- const prov=$('ncProv').value.trim(); if(prov)r.provenance=prov;+ if(owner)r.owner=owner;+ if(st)r.status=st;+ if(desc)r.description=desc;+ if(prov)r.provenance=prov; // Only now, past validation and with a row actually being made. A milestone // hides the start field but the input keeps its value, so `start` is whatever // was carried in and the remembered start comes through a milestone unchanged,
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.25.0",+ "version": "0.26.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.25.0",+ "version": "0.26.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]" }-}+}
server.js+297−6@@ -266,6 +266,22 @@ function loadData() { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } +// A half-written plan file is worse than an unwritten one, and this server now+// rewrites the data file for reasons that are not "the user imported something"+// (a uid allocation is a one-field change to a 50KB file). Write beside it and+// rename, so a reader either sees the old file or the new one and never a+// truncated middle.+function writeAtomic(file, text) {+ const tmp = file + '.tmp' + process.pid;+ fs.writeFileSync(tmp, text, 'utf8');+ fs.renameSync(tmp, file);+}+function writeDataFile(data) { writeAtomic(DATA_FILE, JSON.stringify(data, null, 2) + '\n'); }+function backupOnce(file, suffix) {+ const dest = file + suffix;+ if (fs.existsSync(file) && !fs.existsSync(dest)) fs.copyFileSync(file, dest);+}+ function loadState() { if (fs.existsSync(STATE_FILE)) { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));@@ -478,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 === '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}`;+ } return `${who} ${action} ${name}${via}`; } @@ -601,6 +621,214 @@ function orderKeyMap(items, keys) { return m; } +// ── uid: the durable handle on a row ──+//+// Every other name a row has is conditional. A task id is a slug of the name it+// had when it was made, so it goes stale the moment the row is renamed and it+// reads as a lie. A group or a milestone has no id at all and rides `#row<n>`,+// its ORIGINAL index in the data file, which renumbers the instant the file is+// restructured or a row is inserted mid-plan. Both of those have cost real+// edits: the point of a uid is that it is a number, it means nothing, and+// nothing that happens to the row can change it.+//+// - assigned once, in the order the plan reads, then never again+// - a deletion RETIRES the number; it is never handed out a second time, so a+// stale reference resolves to nothing instead of to somebody else's row+// - handed out by the server only (POST /uid), never minted in a browser, so+// two tabs creating a row in the same second cannot invent the same one+// - `project.nextUid` in the data file is the counter, and it is the only+// thing that decides what comes next+//+// Zero-padded to five digits for readability, which is presentation and not+// identity: past 99999 the strings simply get longer, and comparison is by+// string equality everywhere, never by width or by numeric order.+const UID_WIDTH = 5;+function fmtUid(n) { return String(n).padStart(UID_WIDTH, '0'); }+function isUid(v) { return typeof v === 'string' && /^[0-9]{1,12}$/.test(v); }+function uidNum(v) { return isUid(v) ? parseInt(v, 10) : 0; }++// Every item the app SHOWS, in the order it shows them, as references into the+// two files that own them: the data file's items and the sidecar's `added`+// entries. mergedData() answers a similar question with copies, which is right+// for an export and useless for writing a field back into the file it came+// from. Deleted rows come back separately: they still exist, they still hold+// 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);+ ((st && st.added) || []).forEach(a => {+ if (!a || typeof a !== 'object') return;+ const k = a._key || a.id;+ if (!k || seen.has(k)) return;+ seen.add(k);+ rows.push({ obj: a, key: k, 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 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 => { 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+ // longer resolves must never be allowed to shorten the plan.+ if (rr.length === live.length && new Set(rr).size === live.length) return { live: rr, dead };+ }+ return { live, dead };+}++// The highest number anything in this instance has ever been given, so a counter+// that has gone missing (a hand-edited file, a plan restored from an export+// without its project block) can be rebuilt without ever reissuing a number.+function highWaterUid(data, state) {+ let hi = 0;+ const bump = r => { if (r && uidNum(r.uid) > hi) hi = uidNum(r.uid); };+ (data.items || []).forEach(bump);+ if (state && !Array.isArray(state)) (state.added || []).forEach(bump);+ return hi;+}++// Hand out the next n numbers and persist the counter before saying so. The+// read, the increment and the write happen in one synchronous run with no await+// between them, and node runs one request at a time, so two creates arriving+// together are serialised by the event loop rather than by hope.+function allocUids(count) {+ const data = loadData();+ if (!data.project || typeof data.project !== 'object') data.project = {};+ let next = Math.floor(Number(data.project.nextUid));+ if (!Number.isFinite(next) || next < 1) next = highWaterUid(data, loadState()) + 1;+ const uids = [];+ for (let i = 0; i < count; i++) uids.push(fmtUid(next++));+ data.project.nextUid = next;+ writeDataFile(data);+ return { uids, nextUid: next };+}++// Give every row in a plan a uid, in the order the plan reads.+//+// Keeps a uid the file already carried, because an export re-imported has to+// land on the same handles whoever read it was given. Mints a missing one.+// RE-mints a duplicate or a malformed one, first occurrence keeping it, and+// reports the count, so a repair is never silent. `fresh` throws all of that+// away and numbers from one, which is what an import from a foreign tool wants:+// an MS Project id means nothing here.+function stampUids(data, opts) {+ const fresh = !!(opts && opts.fresh);+ const items = data.items || [];+ let next = 1, kept = 0, minted = 0, reminted = 0;+ if (!fresh) {+ const claimed = new Set();+ items.forEach(r => {+ if (!r || !isUid(r.uid) || claimed.has(r.uid)) return;+ claimed.add(r.uid);+ if (uidNum(r.uid) + 1 > next) next = uidNum(r.uid) + 1;+ });+ const declared = Math.floor(Number(data.project && data.project.nextUid));+ if (Number.isFinite(declared) && declared > next) next = declared;+ }+ // The counter belongs to this INSTANCE, not to the file arriving. An import+ // replaces the plan wholesale, so without this the incoming file's numbering+ // starts again from wherever it likes and hands a number this server has+ // already retired to a brand new row, which is the one thing a uid promises+ // never happens. A number burned here stays burned, whatever the file says.+ // Numbers the file CARRIES are still honoured: a plan exported from here and+ // imported back has to land on the same handles it went out with.+ let floor = 0;+ try {+ const cur = loadData();+ const declared = Math.floor(Number(cur.project && cur.project.nextUid));+ floor = Math.max(Number.isFinite(declared) ? declared : 0, highWaterUid(cur, loadState()) + 1);+ } catch (e) {}+ if (floor > next) next = floor;+ const seen = new Set();+ items.forEach(r => {+ if (!r || typeof r !== 'object') return;+ if (!fresh && isUid(r.uid) && !seen.has(r.uid)) { seen.add(r.uid); kept++; return; }+ if (r.uid !== undefined && r.uid !== null && r.uid !== '') reminted++; else minted++;+ r.uid = fmtUid(next++);+ seen.add(r.uid);+ });+ if (!data.project || typeof data.project !== 'object') data.project = {};+ data.project.nextUid = next;+ return { kept, minted, reminted, nextUid: next };+}++// Bring an existing instance up to having uids, and keep it there.+//+// Runs at boot and on demand, and does nothing at all once every row has one, so+// it is safe to call as often as you like. Numbering follows the MERGED display+// order rather than the data file's order, because the numbers are going to be+// read by people and by an AI and they should climb down the plan the way the+// plan reads. Rows that are already deleted are numbered last: they are retired+// the moment they are born, which costs three numbers and means an undo of an+// old deletion still comes back with a handle.+//+// Never fatal. A plan that could not be stamped still serves; the copy button+// falls back to the old ids and the log says so. A migration that can take the+// server down with it is a worse bug than the one it fixes.+function ensureUids(why) {+ let data, state;+ try { data = loadData(); state = loadState(); } catch (e) {+ console.error('adom-gantt: could not read the plan to assign uids:', e.message);+ return { ok: false, error: e.message };+ }+ const { live, dead } = mergedRefs(data, state);+ const all = live.concat(dead);+ let next = 1;+ const claimed = new Set();+ all.forEach(r => {+ const u = r.obj.uid;+ if (!isUid(u) || claimed.has(u)) return;+ claimed.add(u);+ if (uidNum(u) + 1 > next) next = uidNum(u) + 1;+ });+ const declared = Math.floor(Number(data.project && data.project.nextUid));+ if (Number.isFinite(declared) && declared > next) next = declared;+ const seen = new Set();+ let dataTouched = false, addedTouched = false, minted = 0, reminted = 0;+ all.forEach(r => {+ const u = r.obj.uid;+ if (isUid(u) && !seen.has(u)) { seen.add(u); return; }+ if (u !== undefined && u !== null && u !== '') reminted++; else minted++;+ r.obj.uid = fmtUid(next++);+ seen.add(r.obj.uid);+ if (r.from === 'added') addedTouched = true; else dataTouched = true;+ });+ const counterWas = data.project && data.project.nextUid;+ if (!data.project || typeof data.project !== 'object') data.project = {};+ data.project.nextUid = next;+ if (!dataTouched && !addedTouched && counterWas === next) return { ok: true, minted: 0, reminted: 0, nextUid: next };+ try {+ backupOnce(DATA_FILE, '.pre-uid.bak');+ writeDataFile(data);+ if (addedTouched) {+ backupOnce(STATE_FILE, '.pre-uid.bak');+ // Every open tab is holding rows with no uid on them, and its next save+ // would write them straight back over these. Bumping the revision turns+ // that save into a 409 and a reload, which is the whole point of having one.+ state.stateRev = (state.stateRev || 0) + 1;+ writeAtomic(STATE_FILE, JSON.stringify(state));+ }+ } catch (e) {+ console.error('adom-gantt: could not write the uid assignment:', e.message);+ return { ok: false, error: e.message };+ }+ const info = { ok: true, minted, reminted, nextUid: next, live: live.length, retired: dead.length,+ stateRev: addedTouched ? state.stateRev : undefined };+ try {+ appendLog([{ action: 'uid.assign', count: minted + reminted, minted, reminted,+ live: live.length, retired: dead.length, nextUid: next, via: why || 'startup' }], 'adom-gantt');+ } catch (e) {+ console.error('adom-gantt: could not record the uid assignment in the changelog:', e.message);+ }+ console.log(`adom-gantt: assigned ${minted + reminted} uid(s) (${live.length} shown, ${dead.length} retired); next is ${fmtUid(next)}`);+ return info;+}+ function mergedData() { const data = loadData(); const state = loadState();@@ -758,7 +986,9 @@ function csvUnguard(s) { return (typeof s === 'string' && s.length > 1 && s[0] = // write the numeric index, which /import/csv reads as a phase name, so an export // re-imported produced phases literally called "0".."5" with every row in phase 0. function itemsToCSV(items, phases) {- const headers = ['id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'type', 'label', 'date', 'striped'];+ // uid leads, because it is the column that survives everything else in the row+ // being edited in the spreadsheet, and /import/csv reads it back.+ const headers = ['uid', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'type', 'label', 'date', 'striped']; const escape = v => { if (v == null) return ''; const s = csvGuard(String(v));@@ -800,10 +1030,14 @@ function importJSON(body) { throw new Error('phases and items must be arrays'); } assertDates(data.items);+ // A JSON import is usually this app's own export coming back, so uids the file+ // carries are KEPT: an AI handed a list of numbers must still be able to use+ // them after the round trip. Absent, duplicated or malformed ones are minted.+ const uids = stampUids(data); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); // Clear state file since data structure changed if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);- return { ok: true, items: data.items.length, phases: data.phases.length };+ return { ok: true, items: data.items.length, phases: data.phases.length, uids }; } // ── Import: CSV ──@@ -857,6 +1091,9 @@ function importCSV(body, queryString) { name: row.name || row.label || '', phase: pi, };+ // A uid column the file carried is kept as-is here and validated in one place+ // afterwards, so the dedupe rule cannot disagree with itself per importer.+ if (row.uid) { item.uid = String(row.uid).trim(); } if (row.start) { item.start = row.start; } if (row.end) { item.end = row.end; }@@ -903,9 +1140,12 @@ function importCSV(body, queryString) { }; assertDates(data.items);+ // Same rule as the JSON import: a CSV that came out of here goes back in on+ // the same numbers, and a CSV that never had a uid column gets fresh ones.+ const uids = stampUids(data); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);- return { ok: true, items: items.length, phases: phases.length };+ return { ok: true, items: items.length, phases: phases.length, uids }; } // ── Import: MS Project XML ──@@ -990,9 +1230,12 @@ function importMSProjectXML(body) { }; assertDates(data.items);+ // A foreign tool's ids mean nothing in this plan, so this one numbers from the+ // top rather than pretending an MS Project UID is a handle anybody here holds.+ const uids = stampUids(data, { fresh: true }); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);- return { ok: true, items: tasks.length };+ return { ok: true, items: tasks.length, uids }; } // ── Import: JIRA CSV ──@@ -1086,9 +1329,12 @@ function importJiraCSV(body) { }; assertDates(data.items);+ // Fresh numbers, for the same reason as the MS Project importer: a Jira key is+ // Jira's handle on the row, not this plan's.+ const uids = stampUids(data, { fresh: true }); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);- return { ok: true, items: validItems.length };+ return { ok: true, items: validItems.length, uids }; } // ── Export: SVG ──@@ -1527,6 +1773,46 @@ const server = http.createServer(async (req, res) => { return; } + // ── uid allocation ──+ //+ // The only place a uid is ever born. It is a POST and it is below the door,+ // so it needs a signed-in person: a number handed out is a number burned,+ // and an open endpoint that burns numbers is a way to make the plan's ids+ // ugly from the outside. `count` batches, for a script adding rows in bulk.+ //+ // The client has no fallback path on purpose. If this call cannot be made,+ // the create is refused and says so, rather than the browser inventing a+ // number that a second tab is inventing at the same moment.+ if (req.method === 'POST' && urlPath === '/uid') {+ const body = await readBody(req);+ let count = 1;+ if (body && body.trim()) {+ let p;+ try { p = JSON.parse(body); }+ catch (e) { jsonResp(res, 400, { ok: false, error: 'body must be JSON, or empty for one id' }); return; }+ if (p && p.count !== undefined) count = p.count;+ }+ try {+ count = Math.floor(Number(count));+ if (!Number.isFinite(count) || count < 1) count = 1;+ if (count > 500) count = 500;+ const out = allocUids(count);+ jsonResp(res, 200, { ok: true, uid: out.uids[0], uids: out.uids, nextUid: out.nextUid });+ } catch (e) {+ jsonResp(res, 500, { ok: false, error: e.message });+ }+ return;+ }++ // Self-heal, for the case the boot-time pass cannot cover: a script that+ // appends rows straight into the data file under a running server. Does+ // nothing when every row already has a uid, so it is safe to spam.+ if (req.method === 'POST' && urlPath === '/uid/backfill') {+ const out = ensureUids('backfill');+ jsonResp(res, out.ok ? 200 : 500, out);+ return;+ }+ // ── Import endpoints ── if (req.method === 'POST' && urlPath === '/compact') {@@ -1670,11 +1956,16 @@ process.on('uncaughtException', err => { if (SESSION_FILE) loadSessions(); +// Before the door opens, not after: the first request served must already see a+// 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');+ server.listen(PORT, '127.0.0.1', () => { console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`); console.log(`State file: ${STATE_FILE}`); console.log(`Data dir: ${DATA_DIR}`);- console.log(`Endpoints: /data /state /save /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+ console.log(`Endpoints: /data /state /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`); console.log(AUTH_OFF ? 'Auth: OFF (--no-auth). Anyone who can reach this port can read and change the plan.' : 'Auth: Sign in with Adom required. Direct-loopback GETs of /data /state /changelog /export/* stay open for the AI handoff loop.');