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.9.0: provenance, saying where every row came from
A new optional per-item field, provenance, and a Provenance column in the table.
Accepts {label, url} or a bare string, so 'Drew added this' is as valid as a link
to the plan of record.
The url is user-controlled, which makes this an injection surface. Only http and
https ever become an href: safeUrl gates the scheme and then runs it through new
URL(), which also kills protocol-relative //evil.example and anything smuggled
past the regex. Everything else falls through to the same chip rendered as inert
text, so a javascript: provenance shows its label and does nothing. Labels are
escaped like the rest and the chart tooltip renders it as a text node. Verified
with javascript:, data:, protocol-relative and an <img onerror> label: zero
anchors created, zero execution. Normalising on the way into the sidecar means a
hostile url does not even survive a save.
Editing is the label only; a url is something you paste, not something you retype
in a grid cell, so it is preserved underneath a label edit. Sidecar gains prov
(additive), the changelog gains a provenance action, undo covers it, and CSV gets
provenance and provenance_url as two columns so an export re-imported keeps the
label and the link as separate things.
Data: all 73 DC items carry provenance, added by a targeted transformation that
asserted afterwards that nothing but the provenance key changed. 66 POR, the 4
DoW tasks to the #adom DC-trip thread, hw-pcb-reflow to the exact message that
raised it (located with adom-google, not the guessed anchor), travel provenance
to log-travel and the fly milestone only.
7 files changed
+158−18
README.md+2@@ -6,6 +6,8 @@ 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. +**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.+ **Owner is a first-class field.** Put `"owner": "Adonis"` on a task or a milestone: one name, never a team. It drives the table's owner filter, it round-trips through CSV and JSON export, and changing it is recorded in the changelog like any other edit. **Visual timeline.** Color-coded phase bars, diamond milestones, dependency arrows, and a live "today" marker. Date range and month count auto-computed from data.
SKILL.md+2@@ -139,6 +139,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | end | YYYY-MM-DD | yes | End date. Required | | 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" | | description | string | no | Tooltip body text, and the Notes column in the table | | deps | string[] | no | Array of task IDs this depends on |@@ -224,6 +225,7 @@ named in its own column. | Deadline | `end` | `date` | | Status | `status`, or "none" | `status`, or "none" | | Notes | `description`, truncated | same |+| Provenance | `provenance` as a chip, linked when the url is http(s) | same | **Sorting.** Click any header. Default is deadline ascending. Blank values sort last in both directions, so an unassigned row never displaces what you are looking for.
docs/BUILD-NOTES.md+36@@ -3,6 +3,42 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.9.0, provenance)++A new optional per-item field, `provenance`, and a Provenance column in the table+saying where each row came from. Accepts `{label, url}` or a bare string, so+"Drew added this" is as valid as a link to the plan of record.++**The url is user-controlled, which makes this an injection surface.** Only http+and https ever become an `href`: `safeUrl` regex-gates the scheme and then runs it+through `new URL()`, which also kills protocol-relative `//evil.example` and+anything smuggled past the regex. Everything else falls through to the same chip+rendered as inert text, so a `javascript:` provenance shows its label and does+nothing. Labels go through `esc()` like the rest, and the chart tooltip renders it+as a text node. Verified with `javascript:`, `data:`, protocol-relative and an+`<img onerror>` label: zero anchors created, zero script execution.++A pleasant side effect of normalising through `safeUrl` on the way into the+sidecar: a hostile url does not survive a save. It is stripped to a label-only+provenance the first time the row is written.++Editing is the label only. A url is something you paste from somewhere, not+something you retype in a grid cell, so it is preserved underneath a label edit+and cleared only when the label is cleared. Sidecar gains `prov` (additive), the+changelog gains a `provenance` action, undo covers it, and CSV export writes+`provenance` and `provenance_url` as two columns so an export re-imported keeps+the label and the link as separate things.++**Data.** All 73 DC items carry provenance, added by a targeted transformation+that asserted afterwards that nothing but the `provenance` key had changed. 66 are+POR; the 4 DoW tasks point at the #adom DC-trip thread; `hw-pcb-reflow` points at+the exact message that raised it, located with `adom-google chat read` (thread+WMWfXbMoR3M, 2026-08-07T14:05:17Z, "we might need a temp reflow solution.. we have+the hot plate"), not the thread anchor that was guessed for it. Travel provenance+went to `log-travel` and the "Fly DFW to DCA" milestone only: the reception and+show-floor milestones are the plan of record's own event section, not a travel+note, so pointing them at the travel chat would have been less accurate, not more.+ ## Build notes, 2026-08-07 (0.8.7) The text-size control moved from the left cluster to the far right of the header.
gantt.html+90−14@@ -255,6 +255,7 @@ body.col-resizing { cursor: col-resize; user-select: none; } .tip .tip-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--accent); margin-bottom: 4px; } .tip .tip-dates { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); margin-bottom: 6px; } .tip .tip-body { color: var(--text2); }+.tip .tip-prov { margin-top: 6px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); } /* ── Editor panel (single + batch) ── */ .editor { position: fixed; z-index: 99998; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 0 14px 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); font-family: 'Satoshi', sans-serif; display: none; min-width: 280px; }@@ -378,6 +379,14 @@ table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; mi .data-table .cell-input { width: 100%; min-width: 90px; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; } .data-table select.cell-input { font-family: 'Satoshi', sans-serif; } .data-table .table-empty { padding: 22px 12px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; }+/* Provenance: a short chip, because the useful part is usually one token ("POR",+ a channel name) and the full text is on the tooltip. A chip with a link looks+ like a link; one without stays quiet chrome. */+.data-table td.c-prov { white-space: nowrap; }+.data-table .prov-chip { display: inline-block; max-width: calc(150px * var(--fs)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); line-height: 1.5; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--border); color: var(--text2); text-decoration: none; }+a.prov-chip { border-color: rgba(0,184,177,0.35); color: var(--accent); }+a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; }+.data-table td.c-prov.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; } @media print { .table-scroll { overflow: visible !important; } .table-tools { display: none !important; }@@ -424,6 +433,7 @@ input[type="file"] { display: none; } .tip { font-size: calc(12px * var(--fs)); } .tip .tip-title { font-size: calc(13px * var(--fs)); } .tip .tip-dates { font-size: calc(10px * var(--fs)); }+.tip .tip-prov { font-size: calc(10px * var(--fs)); } .tip .tip-body { font-size: calc(12px * var(--fs)); } table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * var(--fs)); } .data-table thead th { font-size: calc(11px * var(--fs)); }@@ -485,6 +495,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <th class="sortable" data-sort="deadline">Deadline</th> <th class="sortable" data-sort="status">Status</th> <th class="sortable" data-sort="notes">Notes</th>+ <th class="sortable" data-sort="prov" title="Where this item came from">Provenance</th> </tr></thead> <tbody id="tableBody"></tbody> </table>@@ -786,7 +797,7 @@ const undoStack = []; let undoTimer = null; // The snapshot carries owner, status and description too, so a table edit is one // Ctrl+Z like every other edit rather than a second class of change.-function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);} function snapshot(id){snapshotMany([id]);} // One undo entry covering many rows, for bulk operations like a plan shift. function snapshotGroup(ids){@@ -826,6 +837,7 @@ function undo(){ sn.r.date=sn.date; if(sn.name!==undefined)sn.r.name=sn.name; setOpt(sn.r,'owner',sn.owner);setOpt(sn.r,'status',sn.status);setOpt(sn.r,'description',sn.desc);+ if('prov' in sn)setProv(sn.r,sn.prov); refreshMsChrome(sn.r);updateMilestone(sn.r); } else if(sn.type==='group'){@@ -839,6 +851,7 @@ function undo(){ if('owner' in sn)setOpt(r,'owner',sn.owner); if('status' in sn)setOpt(r,'status',sn.status); if('desc' in sn)setOpt(r,'description',sn.desc);+ if('prov' in sn)setProv(r,sn.prov); updateBar(sn.id);refreshLabelRow(r);} if(prefs.view==='table')renderTable(); clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo');@@ -852,14 +865,17 @@ let saveTimer=null; // An empty value means "no value", not the empty string, so clearing an owner in // the table leaves the item as clean as it was in the data file. function setOpt(r,k,v){ if(v===undefined||v===null||v==='')delete r[k]; else r[k]=v; }+// provenance is an object or a string, so it needs its own setter rather than+// setOpt's empty-means-absent rule applied to a truthy object.+function setProv(r,v){ if(v===undefined||v===null||v===''||(typeof v==='object'&&!v.label&&!v.url))delete r.provenance; else r.provenance=v; } // v3 adds owner, status and description. The fields are additive: a v2 sidecar // written before the table view existed still loads, and a v2 reader ignores // what it does not know. function getState(){ return { v: 3,- tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||''})),- milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,key:msKey(r),name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||''})),+ tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r)})),+ milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,key:msKey(r),name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r)})), phases: phases.map((p,i)=>({i,start:p.start,end:p.end})), order: items.map(rowOrderKey), };@@ -897,6 +913,7 @@ function applyState(state){ if(sv.owner!==undefined)setOpt(r,'owner',sv.owner); if(sv.status!==undefined)setOpt(r,'status',sv.status); if(sv.desc!==undefined)setOpt(r,'description',sv.desc);+ if(sv.prov!==undefined)setProv(r,sv.prov); }); if(Array.isArray(state))return; const ms=items.filter(r=>r.type==='milestone');@@ -914,6 +931,7 @@ function applyState(state){ if(sv.owner!==undefined)setOpt(r,'owner',sv.owner); if(sv.status!==undefined)setOpt(r,'status',sv.status); if(sv.desc!==undefined)setOpt(r,'description',sv.desc);+ if(sv.prov!==undefined)setProv(r,sv.prov); }); (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; } }); if(Array.isArray(state.order)&&state.order.length===items.length)applyOrder(state.order);@@ -936,12 +954,12 @@ function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;} 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||''};});+ 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)};}); // 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 // "reorder". It also made one of any same-named pair invisible to the diff.- const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[msKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||''};});+ const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[msKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r)};}); return {byId,ms,order:items.map(rowOrderKey)}; } let lastSnap=snapSemantic();@@ -958,6 +976,7 @@ function diffAndLog(via){ 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.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||''}}); b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)})); a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)})); });@@ -987,6 +1006,7 @@ function diffAndLog(via){ if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',ms:true,key:k,name:nm,from:{owner:a.owner||''},to:{owner:b.owner||''}}); if((a.status||'')!==(b.status||'')) evs.push({action:'status',ms:true,key:k,name:nm,from:{status:a.status||''},to:{status:b.status||''}}); if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,key:k,name:nm,from:{desc:a.desc||''},to:{desc:b.desc||''}});+ if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',ms:true,key:k,name:nm,from:{label:a.provL||''},to:{label:b.provL||''}}); }); const before=lastSnap.order,after=cur.order; if(JSON.stringify(before)!==JSON.stringify(after)){@@ -1121,7 +1141,7 @@ items.forEach((r,idx)=>{ const x=toX(r.date); const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px'; hit.dataset.msi=String(items.filter(q=>q.type==='milestone').indexOf(r));- hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';+ hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';hit.dataset.prov=provLabel(r); const d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px'; bDiv.appendChild(hit);bDiv.appendChild(d); // The dashed rule lives on the chart container, not the row, so it can span@@ -1130,11 +1150,11 @@ items.forEach((r,idx)=>{ ln.style.color=phaseColor(r.phase); const cap=document.createElement('div');cap.className='ms-cap';cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 '); cap.dataset.msi=String(items.filter(q=>q.type==='milestone').indexOf(r));- cap.dataset.title=r.name;cap.dataset.dates=fmt(r.date);cap.dataset.body=r.description||'';+ cap.dataset.title=r.name;cap.dataset.dates=fmt(r.date);cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r); 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.id=r.id;b.dataset.idx=idx;+ 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; 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);@@ -1494,7 +1514,7 @@ function updateMilestone(r){ drawDeps(); } function snapshotMs(r){- undoStack.push({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description});+ undoStack.push({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance}); if(undoStack.length>100)undoStack.splice(0,undoStack.length-100); } // Milestone chrome that is not the date: the label row, the tooltip body and the@@ -1502,16 +1522,16 @@ function snapshotMs(r){ function refreshMsChrome(r){ if(!r)return; if(r._lDiv)r._lDiv.textContent=r.name;- if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';}+ if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);} if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');- if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';}}+ if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);}} } // The bar's tooltip lives in its data-* attributes, so restating the item means // restating those too. This used to patch only the geometry and the dates, so a // description restored from the sidecar (or edited in the table) left the bar // hovering the version that was in the data file.-function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';drawDeps();}+function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);drawDeps();} function closeEditor(){editorEl.classList.remove('show');editorEl.innerHTML='';} function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');} @@ -1696,10 +1716,12 @@ function tipLine(cls,text){const d=document.createElement('div');d.className=cls function showTip(el,e){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{ const t=el.dataset.title,d=el.dataset.dates,b=el.dataset.body; if(!t)return;+ const pv=el.dataset.prov; tipEl.textContent=''; tipEl.appendChild(tipLine('tip-title',t)); if(d)tipEl.appendChild(tipLine('tip-dates',d)); if(b)tipEl.appendChild(tipLine('tip-body',b));+ if(pv)tipEl.appendChild(tipLine('tip-prov','source: '+pv)); tipEl.classList.add('show');posTip(e);},500);} function posTip(e){tipEl.style.left=Math.min(e.clientX+14,window.innerWidth-360)+'px';tipEl.style.top=Math.min(e.clientY+18,window.innerHeight-120)+'px';} function hideTip(){clearTimeout(tipTimer);tipEl.classList.remove('show');}@@ -2098,6 +2120,35 @@ function isMs(r){return r.type==='milestone';} function deadlineOf(r){return isMs(r)?(r.date||''):(r.end||'');} function phaseNameOf(r){const p=phases[r.phase];return p?p.name:'';} function ownerOf(r){return (r.owner||'').trim();}+// Only http and https ever become an href. Everything else, javascript: above+// all, falls through to inert text: the label still shows, it just is not a+// link. new URL() also kills protocol-relative "//evil.example" and any scheme+// smuggled past a naive regex.+function safeUrl(u){+ if(typeof u!=='string')return '';+ const t=u.trim();+ if(!/^https?:\/\//i.test(t))return '';+ try{ const q=new URL(t); return (q.protocol==='http:'||q.protocol==='https:') ? q.href : ''; }+ catch(e){ return ''; }+}+// provenance is {label, url} or a bare string label. Normalised once here so+// nothing downstream has to care which shape the data file used.+function provOf(r){+ const p=r&&r.provenance;+ if(!p)return null;+ if(typeof p==='string'){ const l=p.trim(); return l?{label:l,url:''}:null; }+ if(typeof p==='object'){+ const l=String(p.label==null?'':p.label).trim(), u=safeUrl(p.url);+ if(!l&&!u)return null;+ return {label:l||u, url:u};+ }+ return null;+}+function provLabel(r){ const p=provOf(r); return p?p.label:''; }+// What goes in the sidecar and the changelog: the bare string when there is no+// url, so a hand-written provenance stays hand-written.+function provValue(r){ const p=provOf(r); return p ? (p.url?{label:p.label,url:p.url}:p.label) : ''; }+function provSig(r){ const p=provOf(r); return p ? p.label+'\u0001'+p.url : ''; } function msIndexOf(r){return items.filter(q=>isMs(q)).indexOf(r);} function rowKey(r){return isMs(r)?('m:'+msIndexOf(r)):('t:'+r.id);} function rowItem(key){@@ -2143,7 +2194,7 @@ function tableRows(){ if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return; if(st&&(r.status||'')!==st)return; if(q){- const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)}`.toLowerCase();+ const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${provLabel(r)}`.toLowerCase(); if(hay.indexOf(q)<0)return; } rows.push(r);@@ -2155,6 +2206,7 @@ function tableRows(){ if(sortKey==='start')return isMs(r)?'':(r.start||''); if(sortKey==='status')return r.status||''; if(sortKey==='notes')return (r.description||'').toLowerCase();+ if(sortKey==='prov')return provLabel(r).toLowerCase(); return deadlineOf(r); }; // A blank sorts last in both directions: an unassigned row should never push@@ -2178,6 +2230,17 @@ function syncSortHeaders(){ }); } +// Blank when absent, never the string "undefined". A link only when the url+// survived safeUrl; otherwise the same chip as plain text, so a javascript: url+// shows its label and does nothing at all.+function provCell(r){+ const p=provOf(r);+ if(!p)return '';+ const tip=esc(p.label+(p.url?', '+p.url:''));+ if(p.url) return `<a class="prov-chip" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" title="${tip}">${esc(p.label)}</a>`;+ return `<span class="prov-chip" title="${tip}">${esc(p.label)}</span>`;+}+ function renderTable(){ if(!tableBody)return; syncOwnerControls();@@ -2185,7 +2248,7 @@ function renderTable(){ const rows=tableRows(); const total=items.filter(r=>!isGroup(r)).length; countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;- if(!rows.length){tableBody.innerHTML='<tr><td colspan="7" class="table-empty">No items match these filters.</td></tr>';return;}+ if(!rows.length){tableBody.innerHTML='<tr><td colspan="8" 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||'');@@ -2202,6 +2265,7 @@ function renderTable(){ +`<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-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>` +'</tr>'; }).join(''); }@@ -2221,6 +2285,11 @@ function startCellEdit(td){ } else if(field==='start'||field==='deadline'){ el=document.createElement('input');el.type='date'; el.value=(field==='start'?r.start:deadlineOf(r))||'';+ } else if(field==='prov'){+ // The label only. A url is a thing you paste from somewhere, not something+ // you retype in a grid cell, so it is preserved as-is underneath.+ el=document.createElement('input');el.type='text';+ el.placeholder='where this came from';el.value=provLabel(r); } else if(field==='owner'){ el=document.createElement('input');el.type='text';el.setAttribute('list','ownerList'); el.placeholder='one name';el.value=r.owner||'';@@ -2256,6 +2325,13 @@ function commitCell(r,field,v){ } else if(field==='status'){ if((r.status||'')===v)return; snap();setOpt(r,'status',STATUS_OPTS.includes(v)?v:'');+ } else if(field==='prov'){+ v=v.trim();+ const cur=provOf(r);+ if((cur?cur.label:'')===v)return;+ snap();+ if(!v)delete r.provenance;+ else r.provenance = (cur&&cur.url) ? {label:v,url:cur.url} : v; } else if(field==='notes'){ v=v.trim(); if((r.description||'')===v)return;
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.8.8",+ "version": "0.9.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.8.8",+ "version": "0.9.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+25−1@@ -209,6 +209,12 @@ function describeEvent(e) { if (!t) return `${who} cleared the notes on ${name}${via}`; return `${who} rewrote the notes on ${name}: "${t}"${via}`; }+ if (action === 'provenance') {+ const f = fld('from', 'label', ''), t = fld('to', 'label', '');+ if (!f) return `${who} recorded where ${name} came from: "${t}"${via}`;+ if (!t) return `${who} cleared the source on ${name} (was "${f}")${via}`;+ return `${who} changed the source on ${name}: "${f}" -> "${t}"${via}`;+ } if (action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep || '?'}"${via}`; if (action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep || '?'}"${via}`; if (action === 'reorder') return `${who} reordered rows: ${(Array.isArray(e.moved) ? e.moved : []).join(', ') || 'row order changed'}${via}`;@@ -258,6 +264,13 @@ function setOpt(r, k, v) { else r[k] = v; } +// provenance is {label, url} or a bare string, so setOpt's rules do not fit.+function setProv(r, v) {+ if (v === undefined) return;+ if (v === null || v === '' || (typeof v === 'object' && !v.label && !v.url)) delete r.provenance;+ else r.provenance = v;+}+ // Sidecar milestone entries carry a stable key, an index and a name, and all but // the key can go stale: a reorder moves the index, a rename moves the name. // Resolve down a ladder and claim each milestone at most once.@@ -319,6 +332,7 @@ function mergedData() { setOpt(r, 'owner', sv.owner); setOpt(r, 'status', sv.status); setOpt(r, 'description', sv.desc);+ setProv(r, sv.prov); }); if (Array.isArray(state)) return data; const ms = [], msKeys = [];@@ -336,6 +350,7 @@ function mergedData() { setOpt(r, 'owner', sv.owner); setOpt(r, 'status', sv.status); setOpt(r, 'description', sv.desc);+ setProv(r, sv.prov); }); (state.phases || []).forEach(sv => { const p = data.phases[sv.i];@@ -416,7 +431,7 @@ 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', 'deps', 'type', 'label', 'date', 'striped'];+ const headers = ['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));@@ -434,6 +449,10 @@ function itemsToCSV(items, phases) { const row = headers.map(h => { if (h === 'deps') return escape((item.deps || []).join(';')); 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.+ 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]); }); lines.push(row.join(','));@@ -514,6 +533,11 @@ function importCSV(body, queryString) { if (row.end) { item.end = row.end; } if (row.date) { item.date = row.date; } if (row.owner) { item.owner = row.owner; }+ if (row.provenance || row.provenance_url) {+ item.provenance = row.provenance_url+ ? { label: row.provenance || row.provenance_url, url: row.provenance_url }+ : row.provenance;+ } if (row.status) { item.status = row.status; } if (row.description) { item.description = row.description; } if (deps.length) { item.deps = deps; }