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.11.0: one filter line, a New button, and a four-state status vocabulary
The legend and the table filters were two stacked bands saying the same kind of
thing. The tools now join the legend row; below 1150px the phase chips drop their
words before any control wraps, since the colour and the group headers already
say which lane is which. Single line measured by vertical centre at 1600, 1280
and 1100, no overflow. The gantt legend is unchanged.
New creates a task or a milestone through the same inline panel the milestone
editor uses. It is the one teal-filled control in the chrome now: the active
view-toggle segment gave up its teal fill and reads as selected through an
elevated fill, accent text, weight and an accent underline instead.
Identity for created rows is the subtle part. #row<n> is the data file's key
namespace and it grows, so a created row must never land in it. Created tasks key
on a generated slug id; created milestones get #new-<base36 time>-<counter>, a
namespace #row<n> cannot reach. rowKeysOf now reads r.id || r._key || #row+i, and
created rows are appended after the keys are computed, which keeps the existing
numbering stable. They live in the sidecar as added:[...], merged by both client
and server so every export contains them, and are ordinary rows once inserted.
Deleting one that was never in the data file drops it from added rather than
recording it in deleted, so the sidecar never accumulates added/deleted pairs.
Status becomes four states: not started, in progress, blocked, complete. The old
vocabulary is aliased on READ everywhere, so the data file, the examples and a
sidecar mid-edit keep working untouched and only a fresh save writes the new
words. Verified against a sidecar that still says live/planned on disk. Colour
follows brand semantics: accent for in progress, warn for blocked, ok for
complete; red stays destructive-only. An unknown status from a CSV renders as
itself rather than being coerced. The DC data file was migrated by a targeted
pass, 58 statuses, diff asserting nothing else moved.
One bug caught in the process: snapSemantic runs at module level and now calls
statusOf, which put STATUS_ORDER in its temporal dead zone and killed the app at
boot. The status block is declared with the row-key helpers at the top now.
7 files changed
+340−45
README.md+4@@ -6,6 +6,10 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Two lenses over one plan.** A **Gantt / Table** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is really an assignment list. +**Add rows.** The New button creates a task or a milestone: name, lane, owner, dates, status, notes and provenance. Created rows live in the sidecar, so the data file is never rewritten, and they appear in every export.++**Four statuses.** Not started, in progress, blocked, complete. Plans written against the older "live" and "planned" still read correctly without being changed.+ **Edit the plan from the table.** Names, phase, owner, dates, status, notes and provenance are all editable in place, and a row can be deleted with one click and an undo toast. Moving a row to another phase re-homes it under that lane's group header on the chart, not just in the data. Deletions are recorded explicitly in the sidecar, so restoring a saved plan can never confuse "removed on purpose" with "row lost". **Provenance.** Every item can say where it came from: `"provenance": {"label": "POR", "url": "https://..."}`, or just `"Drew added this"`. The table shows it as a chip, linked when the url is http or https and as plain text otherwise, so a hostile url can never become a link.
SKILL.md+2−1@@ -140,7 +140,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | phase | number | yes | Index into phases[] | | owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter | | provenance | object or string | no | Where this row came from. `{"label":"POR","url":"https://..."}` or just `"Drew added this"`. Only http and https urls render as links; anything else shows as text |-| status | string | no | One of: "live", "planned", "not-started" |+| status | string | no | One of: "not-started", "in-progress", "blocked", "complete". The old "live" and "planned" are still read and shown as in progress / not started | | description | string | no | Tooltip body text, and the Notes column in the table | | deps | string[] | no | Array of task IDs this depends on | | note | string | no | Short label drawn on the bar itself, e.g. `6m` or `Y1`. Keep it to a few characters |@@ -187,6 +187,7 @@ rather than a category. ## Features +- **New** button: create a task or milestone. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export - **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type - **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only - **Gantt / Table toggle** pinned first in the header, left of every conditional control so its position never shifts. Two lenses over the same items: the chart answers "when", the table answers "whose, and by when". See The table view below
docs/BUILD-NOTES.md+57@@ -3,6 +3,63 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.11.0)++**One filter line.** The legend and the table's filters were two stacked bands+saying the same kind of thing (what am I looking at). The table tools now move+into the legend row at startup and sit right of the chips, and the row is+`nowrap`. Below 1150px the phase chips drop their words before any control wraps:+the colour and the group headers already say which lane is which, and the full+name is on the chip's tooltip. Measured single-line by vertical centre (spread 0)+at 1600, 1280 and 1100 with no overflow. The gantt legend is unchanged, one line+as before, with the tools hidden.++**New.** One teal-filled button in the header opening the same inline editor panel+the milestone editor uses. It sits second from the right of the right-aligned+group, behind a fixed-width control, so its position is as stable as the last+element's: identical rect across view, milestone-style, theme and font-scale+switches. Teal is now reserved for it: the active view-toggle segment gave up its+teal fill and reads as selected through an elevated fill, accent text, weight and+an accent underline instead, so there is exactly one teal-filled control in the+chrome, which is what the brand rule asks for.++**Identity for created rows, the subtle part.** `#row<n>` is the data file's key+namespace, and it grows, so a created row must never land in it. Created tasks key+on a generated slug id; created milestones have no id and get `#new-<base36+time>-<counter>`, a namespace `#row<n>` can never reach. On the server+`rowKeysOf` now reads `r.id || r._key || '#row'+i`, so data-file rows are numbered+exactly as before and created rows carry their own key. Created rows are appended+after the keys are computed, which is what keeps the existing numbering stable.++Created rows live in the sidecar as `added: [...]` and are merged by the client+and by `mergedData`, so json, csv, svg and html all contain them. They are ordinary+rows once inserted: editable, renamable, phase-movable, deletable, pickable as a+dependency. Deleting one that was never in the data file removes it from `added`+rather than recording it in `deleted`, so the sidecar does not accumulate+added/deleted pairs; verified tidy after both a delete and an undo-of-create. An+older client ignores `added` and simply does not show those rows.++**Status is four states**: not started, in progress, blocked, complete. The old+vocabulary is aliased on READ everywhere (`live` to in progress, `planned` to not+started, hyphenated or spaced), so the data file, the examples and a sidecar the+user is mid-edit in all keep working untouched, and only a fresh save writes the+new words. Verified: an example still carrying live/planned renders as the new+vocabulary with the sidecar provably unmodified on disk, and CSV export emits the+canonical words while import accepts either. Colour follows the brand semantics:+accent for in progress, `--warn` for blocked, `--ok` for complete, grey for not+started; red stays destructive-only. A status that arrives from a CSV and is none+of the four renders as itself in an italic neutral chip and is offered in the edit+dropdown for that row only, rather than being silently coerced or dropped.++The DC data file was migrated by the same kind of targeted pass as the provenance+round: 58 statuses changed, and the diff asserted nothing else moved.++**One bug I introduced and caught.** Hoisting aside, `snapSemantic()` runs at+module level and now calls `statusOf`, which put `STATUS_ORDER` in its temporal+dead zone and killed the whole app at boot. The status block is declared with the+row-key helpers at the top now. Worth remembering that this file has a long+module-level prologue, so anything it touches has to be declared above it.+ ## Build notes, 2026-08-07 (0.10.0, editing the plan itself) Names, phase and deletion are editable from the table, so the table stops being a
gantt.html+234−38@@ -37,6 +37,7 @@ --border: #30363d; --border-muted: #21262d; --text: #e6edf3; --text-body: #c9d1d9; --text2: #8b949e; --text3: #484f58; --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc; --today: #00b8b1;+ --ok: #3fb950; --warn: #d29922; --on-accent: #05221f; --grid-line: rgba(48,54,61,0.18); --weekend: rgba(255,255,255,0.022);@@ -52,6 +53,7 @@ --border: #d0d7de; --border-muted: #d8dee4; --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f; --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: #00857f;+ --ok: #1a7f37; --warn: #9a6700; --on-accent: #ffffff; --grid-line: rgba(31,35,40,0.10); --weekend: rgba(31,35,40,0.04);@@ -138,6 +140,12 @@ header .act-btn svg { width: 14px; height: 14px; } /* Their labels change length ("Lines"/"Diamonds", "Light"/"Dark"). .actions is right-aligned, so a width change there drags everything left of it, including the Fit button. Pin them to their widest label. */+/* The single primary action in the chrome, and the only teal fill. Sits second+ from the right of the right-aligned group, behind a fixed-width control, so+ its position is as stable as the last element's. */+header .new-btn { display: flex; align-items: center; gap: 6px; background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); border-radius: 6px; font-family: 'Satoshi', sans-serif; font-size: 11px; font-weight: 600; padding: 4px 12px; cursor: pointer; flex-shrink: 0; white-space: nowrap; transition: background 0.15s; }+header .new-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); }+header .new-btn svg { width: 13px; height: 13px; } header #btnMsStyle { min-width: 96px; justify-content: center; } header #btnTheme { min-width: 56px; justify-content: center; } /* Below this the header is out of room and the project's own name starts to@@ -163,7 +171,10 @@ header #btnTheme { min-width: 56px; justify-content: center; } } /* ── Legend ── */-.legend { display: flex; gap: 4px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: wrap; flex-shrink: 0; }+/* One line, always. The legend and the table's filters are the same kind of+ thing (what am I looking at), so stacking them into two bands wasted a row and+ read as two unrelated toolbars. nowrap plus a shed ladder below. */+.legend { display: flex; gap: 4px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: nowrap; flex-shrink: 0; overflow: hidden; } .legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); font-family: 'Satoshi', sans-serif; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: opacity 0.15s, background 0.15s; user-select: none; } .legend-item:hover { background: var(--row-hover); } .legend-item.off { opacity: 0.3; }@@ -202,9 +213,13 @@ body.col-resizing { cursor: col-resize; user-select: none; } .label-row.dim { opacity: 0.15; } .label-row.multi-sel { background: rgba(0,184,176,0.06); } .tag { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; vertical-align: middle; }-.tag-live { background: rgba(63,185,80,0.12); color: #3fb950; }-.tag-planned { background: rgba(100,171,255,0.12); color: #64ABFF; } .tag-not-started { background: rgba(139,148,158,0.10); color: var(--text2); }+.tag-in-progress { background: rgba(0,184,177,0.14); color: var(--accent); }+.tag-blocked { background: rgba(210,153,34,0.15); color: var(--warn); }+.tag-complete { background: rgba(63,185,80,0.14); color: var(--ok); }+/* A status that came in from a CSV and is none of the four renders as itself,+ quietly. Better than pretending it is one of ours or dropping it. */+.tag-other { background: rgba(139,148,158,0.10); color: var(--text2); font-style: italic; } .bar-row { height: 34px; position: relative; border-bottom: 1px solid rgba(48,54,61,0.15); transition: opacity 0.15s; } .bar-row.group { height: 28px; background: var(--surface); }@@ -329,9 +344,10 @@ body.col-resizing { cursor: col-resize; user-select: none; } .view-toggle .vt-btn { display: flex; align-items: center; gap: 6px; background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1; padding: 6px 13px; cursor: pointer; transition: background 0.15s, color 0.15s; white-space: nowrap; } .view-toggle .vt-btn svg { width: 14px; height: 14px; flex-shrink: 0; } .view-toggle .vt-btn:hover { background: rgba(255,255,255,0.05); color: var(--text); }-/* Active carries three differences, not one: fill, text colour and weight, so it- still reads as selected without relying on colour alone. */-.view-toggle .vt-btn.on { background: var(--accent); color: var(--on-accent); font-weight: 600; }+/* Active carries four differences: fill, text colour, weight and an accent rule.+ It is deliberately NOT teal-filled any more: a filled teal control is the+ primary action, and there is exactly one of those in the chrome (New). */+.view-toggle .vt-btn.on { background: var(--elevated); color: var(--accent); font-weight: 600; box-shadow: inset 0 -2px 0 var(--accent); } /* ── Table view ── A second lens over the same items. The chart answers "when"; the table answers@@ -345,7 +361,17 @@ body.col-resizing { cursor: col-resize; user-select: none; } :root[data-view="table"] header .start-ctl, :root[data-view="table"] #btnMsStyle, :root[data-view="table"] .legend-item.gantt-only { display: none; }-.table-tools { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); flex-shrink: 0; flex-wrap: wrap; }+/* Now a group inside the legend line, not a band of its own. */+.table-tools { display: flex; align-items: center; gap: 8px; flex-shrink: 0; flex-wrap: nowrap; margin-left: auto; padding-left: 12px; }+:root[data-view="gantt"] .table-tools { display: none; }+.legend-item { flex-shrink: 0; }+/* Below this the phase chips give up their words before any control wraps: the+ colour and the group headers already say which lane is which. */+@media (max-width: 1150px) {+ .legend-item.phase-chip span.chip-label { display: none; }+ .legend-item.phase-chip { padding: 3px 6px; }+ .table-tools input[type="search"] { width: 130px; }+} .table-tools label { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); } .table-tools select, .table-tools input[type="search"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 8px; outline: none; } .table-tools select:hover, .table-tools select:focus,@@ -480,15 +506,16 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="timeline-col" id="timeline"></div> </div> <div class="table-view" id="tableView">- <div class="table-tools">+ <div class="table-tools" id="tableTools"> <label for="tblOwner">owner</label> <select id="tblOwner" title="Show only one person's work"></select> <label for="tblStatus">status</label> <select id="tblStatus"> <option value="">All</option>- <option value="live">live</option>- <option value="planned">planned</option> <option value="not-started">not started</option>+ <option value="in-progress">in progress</option>+ <option value="blocked">blocked</option>+ <option value="complete">complete</option> </select> <input type="search" id="tblSearch" placeholder="Filter by text" aria-label="Filter rows by text"> <button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button>@@ -567,6 +594,43 @@ function msKey(r){ return rowOrderKey(r); } // and "this key no longer resolves" must not look the same. const deletedKeys = new Set(); +// Four states. The old vocabulary is aliased on READ, everywhere, so stored data+// (the data file, an example, a sidecar the user is mid-edit in) keeps working+// untouched and only starts writing the new words when it is next saved.+const STATUS_ORDER=['not-started','in-progress','blocked','complete'];+const STATUS_ALIAS={+ 'live':'in-progress','in progress':'in-progress','in_progress':'in-progress',+ 'planned':'not-started','not started':'not-started','notstarted':'not-started',+ 'done':'complete','completed':'complete',+};+function statusKey(v){+ const t=String(v==null?'':v).trim().toLowerCase();+ if(!t)return '';+ if(STATUS_ORDER.includes(t))return t;+ return STATUS_ALIAS[t]||t; // unknown values survive as themselves+}+function statusOf(r){ return statusKey(r&&r.status); }+const statusTag={'not-started':'tag-not-started','in-progress':'tag-in-progress',blocked:'tag-blocked',complete:'tag-complete'};+const statusLabel={'not-started':'not started','in-progress':'in progress',blocked:'blocked',complete:'complete'};+function statusTagFor(k){ return statusTag[k]||'tag-other'; }+function statusTextFor(k){ return statusLabel[k]||k; }+// Rows the user created. The app never rewrites the data file, so a new row has+// nowhere to live but the sidecar, and it has to be merged back by both the+// client and the server or an export would not contain it.+const addedItems = [];+// A created row needs a key that cannot collide with a data-file key now OR+// after the data file grows. #row<n> is the data file's namespace, so created+// rows get their own: #new-<base36 time>-<counter>. A created TASK keys on its+// id anyway; this matters for created milestones, which have none.+let newKeyN = 0;+function freshKey(){ return '#new-'+Date.now().toString(36)+'-'+(newKeyN++); }+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;+ while(items.some(r=>r.id===id))id=base+'-'+(n++);+ return id;+}+ // A row with no usable date used to take the whole chart down with it: parseISO // split an undefined during the build, the exception escaped, and one bad row // blanked all 72 good ones with no way back except hand-editing the data file.@@ -780,6 +844,8 @@ $('hdr').innerHTML = ` <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML or Jira CSV"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button> <button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button> <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>+ <button class="new-btn" id="btnNew" title="Add a task or a milestone to this plan">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>New</button> <div class="vsep"></div> <div class="fs-ctl" id="fsCtl"> <button class="fs-btn" id="fsDown" title="Smaller text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="17" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="14" font-weight="600">A</text></svg></button>@@ -874,40 +940,65 @@ function removeRow(r,silent){ if(r._bDiv&&r._bDiv.parentNode)r._bDiv.remove(); if(r._msLine&&r._msLine.parentNode)r._msLine.remove(); if(r.id)delete barEls[r.id];- deletedKeys.add(rowOrderKey(r));+ // A row that only ever existed in the sidecar just goes. Recording it as+ // deleted as well would leave an added+deleted pair sitting there for good.+ const ai=addedItems.indexOf(r);+ if(ai>=0)addedItems.splice(ai,1);+ else deletedKeys.add(rowOrderKey(r));+ r._wasAdded=ai>=0; items.forEach((x,i)=>{x._itemIdx=i;if(x.id&&barEls[x.id])barEls[x.id].idx=i;}); if(!silent){keepTailLast();applyFilter();refreshChartGeometry();} return {idx,droppedDeps}; } function restoreRow(r,idx,droppedDeps){- deletedKeys.delete(rowOrderKey(r));+ if(r._wasAdded){ if(!addedItems.includes(r))addedItems.push(r); }+ else deletedKeys.delete(rowOrderKey(r)); items.splice(Math.max(0,Math.min(idx,items.length)),0,r); (droppedDeps||[]).forEach(d=>{const o=items.find(x=>x.id===d.id); if(o){o.deps=o.deps||[];if(!o.deps.includes(d.dep))o.deps.push(d.dep);}}); if(r.id&&r._bDiv){const b=r._bDiv.querySelector('.bar[data-id]');if(b)barEls[r.id]={bar:b,idx,row:r._bDiv};} reflowRows();applyFilter();refreshChartGeometry(); }-// A group header is a row like any other, so moving a task to another phase has-// to move it under that phase's header. Changing only item.phase would leave it-// sitting visually in the lane it came from, which is worse than not moving it.-function moveRowToPhase(r,np){- r.phase=np;- const gi=items.findIndex(g=>g.type==='group'&&g.phase===np);+// 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();+ items.push(r);+ buildRowDom(r,items.length-1);+ if(r.type==='milestone'&&r._msLine)bars.appendChild(r._msLine);+ homeRow(r);+ if(!silent){applyFilter();refreshChartGeometry();}+ return r;+}+// Place a row at the end of its phase's block. Shared by creation and by a phase+// move, so a new row lands exactly where a moved one would.+function homeRow(r){+ const gi=items.findIndex(g=>g.type==='group'&&g.phase===r.phase); if(gi>=0){ let end=gi+1; while(end<items.length&&items[end].type!=='group')end++; const cur=items.indexOf(r); if(cur>=0){items.splice(cur,1);items.splice(cur<end?end-1:end,0,r);} }+ reflowRows();+}++// A group header is a row like any other, so moving a task to another phase has+// to move it under that phase's header. Changing only item.phase would leave it+// sitting visually in the lane it came from, which is worse than not moving it.+function moveRowToPhase(r,np){+ r.phase=np;+ homeRow(r); if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np); if(r._msLine)r._msLine.style.color=phaseColor(np);- reflowRows();applyFilter();refreshChartGeometry();+ applyFilter();refreshChartGeometry(); } function undo(){ if(!undoStack.length)return; const sn=undoStack.pop(); if(sn.type==='order'){applyOrder(sn.order);} else if(sn.type==='delete'){restoreRow(sn.r,sn.idx,sn.droppedDeps);}+ else if(sn.type==='create'){removeRow(sn.r);renderTable();} else if(sn.type==='phase'){ sn.r.phase=sn.phase; if(sn.r.id&&barEls[sn.r.id])barEls[sn.r.id].bar.style.background=phaseColor(sn.phase);@@ -961,6 +1052,9 @@ function getState(){ phases: phases.map((p,i)=>({i,start:p.start,end:p.end})), 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];});+ o._key=r._key||rowOrderKey(r); return o;}), }; } // A milestone entry carries a stable key, an index and a name, and all but the@@ -1020,6 +1114,19 @@ function applyState(state){ 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+ // 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;+ const r=Object.assign({},a);+ if(a._key)r._key=a._key;+ addedItems.push(r);+ insertRow(r,true);+ });+ } // 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.@@ -1047,7 +1154,7 @@ function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;} function snapSemantic(){ const byId={};- items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase};});+ items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase};}); // Keyed by the stable row key, not by the name. Keying by name made a rename // 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@@ -1067,7 +1174,7 @@ function diffAndLog(via){ } if((a.note||'')!==(b.note||'')) evs.push({action:'note',id,name:nameOf(id),from:{note:a.note||''},to:{note:b.note||''}}); if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',id,name:nameOf(id),from:{owner:a.owner||''},to:{owner:b.owner||''}});- if((a.status||'')!==(b.status||'')) evs.push({action:'status',id,name:nameOf(id),from:{status:a.status||''},to:{status:b.status||''}});+ if((a.status||'')!==(b.status||'')) evs.push({action:'status',id,name:nameOf(id),from:{status:statusTextFor(a.status)},to:{status:statusTextFor(b.status)}}); if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',id,name:nameOf(id),from:{desc:a.desc||''},to:{desc:b.desc||''}}); if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',id,name:nameOf(id),from:{label:a.provL||''},to:{label:b.provL||''}}); if((a.name||'')!==(b.name||'')) evs.push({action:'rename',id,name:b.name||'',from:{name:a.name||''},to:{name:b.name||''}});@@ -1084,7 +1191,10 @@ function diffAndLog(via){ }); Object.keys(cur.byId).forEach(id=>{ if(lastSnap.byId[id])return;- evs.push({action:'restore',id,name:cur.byId[id].name||id});+ const r=items.find(x=>x.id===id);+ const born=r&&addedItems.includes(r);+ evs.push({action:born?'add':'restore',id,name:cur.byId[id].name||id,+ kind:born?(r.type==='milestone'?'milestone':'task'):undefined}); }); // A plan shift moves every task by the same delta. Logging 26 identical rows // buries the one fact a reader wants, so collapse it into a single event.@@ -1101,7 +1211,11 @@ function diffAndLog(via){ // milestones: keyed by the stable row key, since they carry no id 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])evs.push({action:'restore',ms:true,key:k,name:curMs[k].name||k}); });+ Object.keys(curMs).forEach(k=>{ if(!oldMs[k]){+ const r=items.find(x=>x.type==='milestone'&&msKey(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});+ }}); Object.keys(curMs).forEach(k=>{ const a=oldMs[k],b=curMs[k]; if(!a)return;@@ -1160,7 +1274,7 @@ function flash(el,text,ms){el.textContent=text;el.classList.add('show');setTimeo // ── Legend ── const legendEl = $('legend');-phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item';el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div>${esc(ph.name)}`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);});+phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item phase-chip';el.title=ph.name;el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div><span class="chip-label">${esc(ph.name)}</span>`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);}); const msL=document.createElement('div');msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL); const depL=document.createElement('div');depL.className='legend-item gantt-only';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies'; depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off',!depsVis);depSvg.classList.toggle('hidden',!depsVis);});legendEl.appendChild(depL);@@ -1176,6 +1290,9 @@ todayL.addEventListener('click',()=>{ positionToday(); }); legendEl.appendChild(todayL);+// The table's filters join the legend line rather than owning a second band.+// Done here, after the chips, so they sit to the right of them.+legendEl.appendChild($('tableTools')); // ── Build chart ── fitCOL();@@ -1242,10 +1359,12 @@ function buildGridLines(){ buildTimeHeaders();buildGridLines(); depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.classList.add('dep-svg');depSvg.setAttribute('width',W);depSvg.setAttribute('height','1');bars.appendChild(depSvg); -const statusTag={live:'tag-live',planned:'tag-planned','not-started':'tag-not-started'};-const statusLabel={live:'live',planned:'planned','not-started':'not started'}; -items.forEach((r,idx)=>{++// Extracted from the initial build loop so a row created at runtime is built by+// exactly the same code as a row from the data file, rather than a second+// almost-identical path that drifts.+function buildRowDom(r,idx){ const lDiv=document.createElement('div'),bDiv=document.createElement('div'); if(r.type==='group'){lDiv.className='label-row group';lDiv.textContent=groupLabel(r.label);bDiv.className='bar-row group';} else if(r.type==='milestone'){@@ -1266,11 +1385,12 @@ items.forEach((r,idx)=>{ ln.appendChild(cap);msLineEls.push(ln); r._msEl=d;r._msHit=hit;r._msLine=ln; }- else{lDiv.className='label-row';lDiv.dataset.id=r.id;const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${esc(statusLabel[r.status]||r.status)}</span>`:'';lDiv.innerHTML=esc(r.name)+tg;bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.id=r.id;b.dataset.idx=idx;+ else{lDiv.className='label-row';lDiv.dataset.id=r.id;const sk=statusOf(r);const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:'';lDiv.innerHTML=esc(r.name)+tg;bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.id=r.id;b.dataset.idx=idx; const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt; b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};} r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);-});+}+items.forEach((r,idx)=>buildRowDom(r,idx)); // Appended after the rows so the dashed rules sit above the phase bands. msLineEls.forEach(ln=>bars.appendChild(ln));@@ -1993,6 +2113,79 @@ $('grainSel').addEventListener('change',function(){ rebuildTimeline(); }); +// ── Create a row ──+//+// The same inline editor panel the milestone editor uses, so creating a row and+// editing one feel like the same gesture. Everything typed here flows into the+// same rendering paths as data-file content, which is where the escaping lives.+function openCreateEditor(){+ closeEditor();+ const today=isoDay(new Date());+ const plus=addDays(today,14);+ editorEl.innerHTML=`<div class="drag-bar"></div>+ <div class="batch-title">New item</div>+ <div class="de-row">+ <div><label>type</label><select id="ncType"><option value="task">Task</option><option value="milestone">Milestone</option></select></div>+ <div style="flex:1"><label>phase</label><select id="ncPhase">${phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('')}</select></div>+ </div>+ <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="ncName" placeholder="What is it?"></div>+ <div class="de-row" style="margin-top:8px">+ <div id="ncStartWrap"><label>start</label><input type="date" id="ncStart" value="${today}"></div>+ <div><label id="ncEndLabel">deadline</label><input type="date" id="ncEnd" value="${plus}"></div>+ </div>+ <div class="de-row">+ <div style="flex:1"><label>owner</label><input type="text" id="ncOwner" list="ownerList" placeholder="one name"></div>+ <div style="flex:1"><label>status</label><select id="ncStatus">${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>+ </div>+ <div class="de-note-row"><label>notes</label><input type="text" id="ncDesc" placeholder="Shown on hover and in the table"></div>+ <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="ncProv" value="Added by hand"></div>+ <div class="de-actions"><button class="de-btn primary" id="ncCreate">Create</button>+ <button class="de-btn secondary" id="ncCancel">Cancel</button></div>+ <div id="ncErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;+ const r=$('btnNew').getBoundingClientRect();+ editorEl.style.left=Math.max(8,Math.min(r.left-220,window.innerWidth-340))+'px';+ editorEl.style.top=(r.bottom+8)+'px';+ editorEl.classList.add('show');+ $('ncStatus').value='not-started';+ const syncType=()=>{+ const ms=$('ncType').value==='milestone';+ $('ncStartWrap').style.display=ms?'none':'';+ $('ncEndLabel').textContent=ms?'date':'deadline';+ };+ $('ncType').addEventListener('change',syncType); syncType();+ $('ncName').focus();+ $('ncCancel').addEventListener('click',closeEditor);+ 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',()=>{+ 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||plus;+ const start=$('ncStart').value||today;+ const r={type:isMs?'milestone':'task',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;+ addedItems.push(r);+ insertRow(r);+ undoStack.push({type:'create',r});+ if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ closeEditor();+ renderTable();+ rebuildTimeline(); // a new date can widen the axis+ scheduleSave('create');+ flash(saveInd,'added '+(isMs?'milestone':'task'));+ });+}+$('btnNew').addEventListener('click',e=>{e.stopPropagation();closeMenus();openCreateEditor();});+ // ── Text scale ── // // Applies to both lenses, because it is a statement about the reader and not@@ -2218,7 +2411,7 @@ document.addEventListener('mouseup',()=>{edDrag=null;}); const tableBody=$('tableBody'), tableHead=$('tableHead'), ownerListEl=$('ownerList'); const ownerSel=$('tblOwner'), statusSel=$('tblStatus'), searchInp=$('tblSearch'), countEl=$('tblCount');-const STATUS_OPTS=['live','planned','not-started'];+const STATUS_OPTS=STATUS_ORDER; const UNASSIGNED='__unassigned__'; // sentinel, so a real owner can never collide with it let sortKey='deadline', sortDir=1, tblSearchTimer=null; if(prefs.tableSort&&prefs.tableSort.key){sortKey=prefs.tableSort.key;sortDir=prefs.tableSort.dir===-1?-1:1;}@@ -2271,7 +2464,8 @@ function rowItem(key){ // already true on the chart when you toggle back. function refreshLabelRow(r){ if(!r||!r._lDiv||!r.id)return;- const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${esc(statusLabel[r.status]||r.status)}</span>`:'';+ const sk=statusOf(r);+ const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:''; r._lDiv.innerHTML=esc(r.name)+tg; } @@ -2303,7 +2497,7 @@ function tableRows(){ if(isMs(r)&&!msVis)return; const o=ownerOf(r); if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return;- if(st&&(r.status||'')!==st)return;+ if(st&&statusOf(r)!==st)return; if(q){ const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${provLabel(r)}`.toLowerCase(); if(hay.indexOf(q)<0)return;@@ -2315,7 +2509,7 @@ function tableRows(){ if(sortKey==='phase')return (phaseNameOf(r)||'').toLowerCase(); if(sortKey==='owner')return ownerOf(r).toLowerCase(); if(sortKey==='start')return isMs(r)?'':(r.start||'');- if(sortKey==='status')return r.status||'';+ if(sortKey==='status'){const k=statusOf(r);const i=STATUS_ORDER.indexOf(k);return i<0?(k?'z'+k:''):String(i);} if(sortKey==='notes')return (r.description||'').toLowerCase(); if(sortKey==='prov')return provLabel(r).toLowerCase(); return deadlineOf(r);@@ -2362,7 +2556,7 @@ function renderTable(){ if(!rows.length){tableBody.innerHTML='<tr><td colspan="9" class="table-empty">No items match these filters.</td></tr>';return;} tableBody.innerHTML=rows.map(r=>{ const ms=isMs(r), editable=ms||!!r.id;- const o=ownerOf(r), st=r.status||'', dl=deadlineOf(r), sd=ms?'':(r.start||'');+ const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||''); const dot=r.phase!==undefined?`<span class="phase-dot" style="background:${esc(phaseColor(r.phase))}"></span>`:''; const dates=ms?(dl?fmt(dl):''):((sd&&dl)?fmt(sd)+' → '+fmt(dl):''); const ed=editable?' editable':'';@@ -2374,7 +2568,7 @@ function renderTable(){ +(ms?'<td class="c-date empty"></td>' :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(sd):'not set'}</td>`) +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(dl):'not set'}</td>`- +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTag[st]||'tag-not-started'}">${esc(statusLabel[st]||st)}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`+ +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>` +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>` +`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>` +`<td class="c-del">${editable?`<button class="row-del" type="button" title="Delete this row from the plan">×</button>`:''}</td>`@@ -2392,8 +2586,10 @@ function startCellEdit(td){ let el; if(field==='status'){ el=document.createElement('select');- el.innerHTML='<option value="">none</option>'+STATUS_OPTS.map(s=>`<option value="${s}">${statusLabel[s]}</option>`).join('');- el.value=r.status||'';+ el.innerHTML='<option value="">none</option>'+STATUS_OPTS.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('');+ const cur=statusOf(r);+ if(cur&&!STATUS_OPTS.includes(cur))el.innerHTML+=`<option value="${esc(cur)}">${esc(cur)}</option>`;+ el.value=cur; } else if(field==='start'||field==='deadline'){ el=document.createElement('input');el.type='date'; el.value=(field==='start'?r.start:deadlineOf(r))||'';@@ -2441,7 +2637,7 @@ function commitCell(r,field,v){ if((r.owner||'')===v)return; snap();setOpt(r,'owner',v); } else if(field==='status'){- if((r.status||'')===v)return;+ if(statusOf(r)===v)return; snap();setOpt(r,'status',STATUS_OPTS.includes(v)?v:''); } else if(field==='name'){ v=v.trim();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.10.0",+ "version": "0.11.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.10.0",+ "version": "0.11.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+40−3@@ -201,7 +201,7 @@ function describeEvent(e) { return `${who} reassigned ${name} from ${f} to ${t}${via}`; } if (action === 'status') {- return `${who} set ${name} status ${fld('from', 'status', '') || 'none'} -> ${fld('to', 'status', '') || 'none'}${via}`;+ return `${who} set ${name} status ${statusText(fld('from', 'status', '')) || 'none'} -> ${statusText(fld('to', 'status', '')) || 'none'}${via}`; } if (action === 'desc') { const f = fld('from', 'desc', ''), t = fld('to', 'desc', '');@@ -215,6 +215,9 @@ function describeEvent(e) { if (action === 'phase') { return `${who} moved ${name} from the ${fld('from', 'phase', '?')} lane to ${fld('to', 'phase', '?')}${via}`; }+ if (action === 'add') {+ return `${who} added a new ${e.kind || 'item'} to the plan: ${name}${via}`;+ } if (action === 'restore') { return `${who} put ${name} back into the plan${via}`; }@@ -268,6 +271,23 @@ function changelogMarkdown(log, since, rev) { return lines.join('\n') + '\n'; } +// The status vocabulary is four states. Old values are aliased on READ so stored+// data keeps working untouched; only a fresh write uses the new words. Kept in+// step with statusKey() in gantt.html.+const STATUS_ORDER = ['not-started', 'in-progress', 'blocked', 'complete'];+const STATUS_ALIAS = {+ 'live': 'in-progress', 'in progress': 'in-progress', 'in_progress': 'in-progress',+ 'planned': 'not-started', 'not started': 'not-started', 'notstarted': 'not-started',+ 'done': 'complete', 'completed': 'complete',+};+function statusKey(v) {+ const t = String(v == null ? '' : v).trim().toLowerCase();+ if (!t) return '';+ if (STATUS_ORDER.includes(t)) return t;+ return STATUS_ALIAS[t] || t; // an unknown status survives as itself+}+function statusText(v) { const k = statusKey(v); return k ? k.replace(/-/g, ' ') : ''; }+ // An absent field in the sidecar means "the sidecar has nothing to say", so the // data file's own value stands. An empty one means the user cleared it. function setOpt(r, k, v) {@@ -320,7 +340,7 @@ function resolveMilestone(ms, msKeys, sv, claimed) { // Keys are the row's ORIGINAL index in the data file, so they must be computed // before anything is removed. Filtering first and then numbering would renumber // every surviving row and break every saved key.-function rowKeysOf(items) { return items.map((r, i) => r.id || ('#row' + i)); }+function rowKeysOf(items) { return items.map((r, i) => r.id || r._key || ('#row' + i)); } function orderKeyMap(items, keys) { const m = new Map(); items.forEach((r, i) => { m.set(keys[i], r); });@@ -338,6 +358,20 @@ function mergedData() { // 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) { const gone = new Set(state.deleted); const keptItems = [], keptKeys = [];@@ -486,6 +520,8 @@ function itemsToCSV(items, phases) { if (h === 'phase') return escape(phaseName(item.phase)); // Two columns rather than one packed cell, so an export re-imported keeps // the label and the link as separate things.+ // Export the canonical vocabulary, so a CSV never carries the old words out.+ if (h === 'status') return escape(statusKey(item.status)); if (h === 'provenance') { const p = item.provenance; return escape(typeof p === 'string' ? p : (p && p.label) || ''); } if (h === 'provenance_url') { const p = item.provenance; return escape((p && typeof p === 'object' && p.url) || ''); } return escape(item[h]);@@ -573,7 +609,8 @@ function importCSV(body, queryString) { ? { label: row.provenance || row.provenance_url, url: row.provenance_url } : row.provenance; }- if (row.status) { item.status = row.status; }+ // Import accepts either vocabulary and stores the canonical one.+ if (row.status) { item.status = statusKey(row.status); } if (row.description) { item.description = row.description; } if (deps.length) { item.deps = deps; } if (row.label) { item.label = row.label; }