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.8.1: fix data loss on milestone rename, a fatal changelog crash, and stored XSS
Renaming a milestone deleted it. Row order was keyed by id||label||name, so a
rename changed the key, the key stopped resolving on the next load, and
applyOrder pushed only what resolved: 67 rows became 66, 9 milestones became 8,
and the next autosave wrote the shortened plan over the sidecar. Rows now carry
a stable #row<n> key that text edits cannot move, applyOrder is all or nothing,
and applyState finally reads the milestone name back so a rename persists at
all. A rename now logs milestone.rename instead of a phantom reorder.
One malformed event killed the server permanently. POST /log an event with no
from, then GET /changelog?format=md: the head was already out, describeEvent
threw, the outer catch wrote a second head, and ERR_HTTP_HEADERS_SENT exited
the process. The poison line stayed in the append-only log, so it died again on
every restart. describeEvent is now total, the markdown renders before the head
goes out, the outer catch destroys the socket once headers are sent, /log needs
a string action, and there is an uncaughtException backstop.
Stored XSS: the tooltip read Notes back out of a data-* attribute, which decodes
on read, and assigned it with innerHTML, so the cell's escaping was nullified at
the sink. It is built from text nodes now, and every other innerHTML fed by
author or user text is escaped. The HTML export escapes < in the inlined data,
so a note containing a closing script tag can no longer break out.
Also: duplicate milestone names no longer misroute edits to the first match, a
sidecar entry with no id no longer writes task dates onto a group header, a
posted rev can no longer overwrite the server's counter, CSV export writes the
phase name so a round trip keeps its phases, CSV cells opening with a formula
character are apostrophe-guarded, and request bodies are capped at 8 MB.
Data: group labels carry their lane owner again, and 32 tasks were re-dated so
no task finishes on or before a dependency of its own. All 9 milestones keep
their backward-scheduled dates.
7 files changed
+379−91
README.md+6−2@@ -24,9 +24,9 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker. -**Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot).+**Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). CSV carries the phase by name, so an export re-imported keeps its phases, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off. -**Auto-save.** Edits persist to a sidecar JSON file next to the data file. Delete the sidecar to reset. The format is additive: a sidecar written by an older version still loads.+**Auto-save.** Edits persist to a sidecar JSON file next to the data file. Delete the sidecar to reset. The format is additive: a sidecar written by an older version still loads. Rows are keyed by a stable index rather than by their own text, so renaming a milestone keeps it in the plan, and the saved row order is applied all-or-nothing so a stale key can never drop a row. **Fills the window.** Cells stretch to the width of the pane, and stop at each grain's minimum so a long plan scrolls horizontally rather than squashing into unreadability. Resizing reflows without a reload. @@ -74,6 +74,8 @@ Open `http://localhost:8901` in a browser, or point a Hydrogen webview tab at th Create a JSON file with three sections: +<!-- prose-lint-disable (literal phase labels from the shipped example data) -->+ ```json { "project": {@@ -91,6 +93,8 @@ Create a JSON file with three sections: } ``` +<!-- prose-lint-enable -->+ 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. ## Keyboard shortcuts
SKILL.md+14−5@@ -238,8 +238,8 @@ lands in the sidecar, appends to the changelog with `via: "table-edit"`, shows o chart the moment you toggle back, and is one Ctrl+Z. Pulling a deadline in front of a start drags the start with it rather than drawing a backwards bar. -New changelog actions: `owner`, `status`, `desc`. Milestone versions of these carry-`ms: true` and no `id`, since milestones are keyed by name.+New changelog actions: `owner`, `status`, `desc` and `milestone.rename`. Milestone+versions carry `ms: true` and a `key` instead of an `id`, since milestones have no id. `project.view.mode` pins which lens a plan opens in, the same way `grain` pins the cell size. Use it when the plan is really an assignment list.@@ -261,8 +261,12 @@ Each event records the action, the item, the before and after dates, the day shi `batch-editor`, `bar-note`, `table-edit`, `milestone-editor`, `start-picker`, `start-revert`, `undo`). -Actions are `dates`, `plan.shift`, `milestone`, `note`, `owner`, `status`, `desc`,-`dep.add`, `dep.remove` and `reorder`.+Actions are `dates`, `plan.shift`, `milestone`, `milestone.rename`, `note`, `owner`,+`status`, `desc`, `dep.add`, `dep.remove` and `reorder`.++Every event must carry a string `action`; `POST /log` rejects one that does not. A+malformed event never breaks a reader: `?format=md` renders whatever it can and says+`?` where a field is missing. Moving the project start collapses into a single `plan.shift` event rather than one row per task, so the log says what happened instead of burying it in 26 identical lines.@@ -302,7 +306,12 @@ This file only contains deltas. Delete it to reset to the original data file val The sidecar is at `v: 3`. Task entries are `{id, s, e, deps, note, owner, status, desc}` and milestone entries are-`{i, name, date, owner, status, desc}`. The `owner`, `status` and `desc` fields are+`{i, key, name, date, owner, status, desc}`, where `key` is the milestone's stable+row key (`#row<n>`, its index in the data file). `order` is a list of those same+stable keys, so renaming a milestone no longer changes its key. Milestone entries+resolve by key first, then by a unique name, then by index, and each milestone is+claimed at most once, so two milestones sharing a name still edit independently. A+task entry with no `id` is ignored rather than matched against the first id-less row. The `owner`, `status` and `desc` fields are additive: a `v: 2` sidecar written before the table view existed still loads, and so does the original `v: 1` bare task array. An absent field means the sidecar has nothing to say and the data file's value stands; an empty one means the user cleared
docs/BUILD-NOTES.md+84−1@@ -3,7 +3,90 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. -## Build notes, 2026-08-07+## Build notes, 2026-08-07 (0.8.1, adversarial review pass)++A fresh-context reviewer went at 0.8.0 and found real defects. All of them were+reproduced before being fixed. Nothing was rebutted.++### Two that could destroy data or the service++**Renaming a milestone deleted it.** Row order was persisted as a list of+`id || label || name`, so a rename changed the key, the key no longer resolved on+the next load, and `applyOrder` pushed only what resolved: the plan silently went+from 67 rows to 66 and from 9 milestones to 8. The next autosave then wrote the+shortened plan back over the sidecar, permanently. Three things were wrong and all+three are fixed: rows now carry a stable `_key` (`#row<n>`, their index in the data+file) that text edits cannot move; `applyOrder` is all-or-nothing and refuses an+order that does not resolve to exactly the same set of rows; and `applyState` now+reads the milestone `name` back, which it never did, so the rename persists at all.+The changelog used to report a rename as a phantom `reorder` (the name-keyed+milestone diff early-returned on both sides); milestone identity in the diff is now+the stable key, and a rename emits a real `milestone.rename` event.++**One malformed changelog event killed the server for good.** `POST /log`+`{"action":"dates","id":"x"}` with no `from`, then `GET /changelog?format=md`:+`res.writeHead(200)` had already run, `describeEvent` dereferenced `e.from.start`,+the throw reached the outer catch, `jsonResp` wrote a second head, and+`ERR_HTTP_HEADERS_SENT` escaped every handler and exited the process. The poison+line stayed in the append-only log, so the server died again on every restart.+`describeEvent` is now total (every field goes through a defaulting accessor), the+markdown is rendered before the head goes out, the outer catch destroys the socket+if a head is already sent, `POST /log` rejects an event with no string `action`, and+there is an `uncaughtException` handler as a last resort.++### Injection++**Stored XSS through the Notes column.** The table cell escaped correctly, but the+tooltip read the text back out of a `data-body` attribute (which decodes on read)+and assigned it with `innerHTML`, so `esc()` was nullified at the sink. A payload+typed into Notes fired on hover, persisted to the sidecar, and fired for every+later viewer. The tooltip is now built from `textContent` nodes. Every other+`innerHTML` fed by author or user text was escaped in the same pass: the chart's+first-paint label row, the legend, the header, the dependency picker and list, and+the editor value attributes (which only escaped quotes, so they also mangled `&`).++**`</script>` breakout in the HTML export.** `JSON.stringify` does not escape `<`,+so a note containing `</script>` closed the tag and ran as markup in the exported+snapshot. Every `<` in the inlined data is now `\u003c`.++**CSV formula injection.** Owner and Notes cells opening with `= + - @` are now+apostrophe-guarded, and import strips the guard, so the round trip stays lossless.++### Correctness++**Duplicate milestone names misrouted edits.** `ms.find(name) || ms[i]`: find always+won and always returned the first match, so editing the second of two same-named+milestones silently edited the first. Resolution is now a claim-tracking ladder,+key then unique name then index then first name match, on the client and the server.++**A sidecar entry with no `id` corrupted a group row.** `find(d => d.id === sv.id)`+with `sv.id` undefined matched the first id-less item, which is a group header, and+wrote task dates and an owner onto it, dragging the whole axis back to 2020. Both+sides now skip entries with no id.++**`rev` was client-overridable.** `{rev: ++rev, ...e}` spread the caller last, so a+posted `rev` overwrote the counter and broke `?since=` monotonicity. Server fields+now go on last.++**CSV export could not be re-imported.** Export wrote the phase index, import reads+the phase name, so a round trip produced phases literally called "0" to "5" with+everything in phase 0. Export writes the name. Also added: an 8 MB request body cap.++### Data++Group labels carry their lane owner again: "Hardware, the long pole (Adonis)",+"Test (Noah)", "Logistics (Dan)", "Software & Content (Kyle)", "NSF Coordination+(John)". These are lane leads, not task owners; the per-task `owner` is the granular+truth, and the two can differ (Drew owns most Logistics tasks under Dan's lane).++Every dependency date inversion is gone. The reviewer flagged 8; a full sweep found+18 under the rule now enforced: **no task may finish on or before any dependency of+its own, and none may start more than 3 days before a dependency ends.** 32 tasks+were re-dated. All 9 milestones keep their original backward-scheduled dates, and+the tasks that feed them still land on them: workcells, PCBs and Keysight by 8/27,+dry run by 9/8 with safety docs at 9/5, ship by 9/15.++## Build notes, 2026-08-07 (0.8.0, initial build) Shipped the **table view** (0.8.0): a second lens over the same items, with owner, deadline and status editable in place, flowing through the existing sidecar and
gantt.html+101−30@@ -397,6 +397,16 @@ const importFileEl = $('importFile'); // ── Load data ── const projData = await fetch('data').then(r => r.json()); const { project, phases, items } = projData;++// Stable per-row key, assigned once from the data-file index and never touched+// again. Row order is persisted as a list of these. An id-less row (a group, a+// milestone) used to be keyed by its own label or name, so renaming a milestone+// changed its key, the key no longer resolved on the next load, the row was+// dropped, and the following save wrote the shortened plan back to the sidecar+// permanently. An index cannot go stale when the text changes.+items.forEach((r,i)=>{ if(!r.id) r._key='#row'+i; });+function rowOrderKey(r){ return r.id || r._key; }+function msKey(r){ return r._key || r.name; } document.title = `${project.title} · adom-gantt`; // ── View preferences (theme, milestone style, start-date override) ──@@ -549,9 +559,9 @@ function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Dat // ── Header with import/export buttons ── $('hdr').innerHTML = ` <div class="brand"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>- <span class="app-name">${project.appName||'adom-gantt'}</span><span class="sep">·</span><span class="app-subtitle">${project.subtitle||'Gantt chart'}</span></div>+ <span class="app-name">${esc(project.appName||'adom-gantt')}</span><span class="sep">·</span><span class="app-subtitle">${esc(project.subtitle||'Gantt chart')}</span></div> <div class="vsep"></div>- <div class="subject"><span class="subject-name">${project.title}</span><span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span></span></div>+ <div class="subject"><span class="subject-name">${esc(project.title)}</span><span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span></span></div> <div class="start-ctl" title="Project start. Changing it moves the whole plan."> <label>starts</label> <button class="date-btn" id="startBtn"></button>@@ -608,13 +618,27 @@ function snapshotGroup(ids){ undoStack.push({type:'group',entries,ms:ms.map(r=>({r,date:r.date})),phases:phases.map(p=>({p,start:p.start,end:p.end}))}); if(undoStack.length>100)undoStack.splice(0,undoStack.length-100); }-function snapshotOrder(){undoStack.push({type:'order',order:items.map(r=>r.id||r.label||r.name)});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+function snapshotOrder(){undoStack.push({type:'order',order:items.map(rowOrderKey)});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+// Legacy label and name keys are still accepted, so an order saved before row+// keys became stable still restores.+function orderKeyMap(){+ const m=new Map();+ items.forEach(r=>m.set(rowOrderKey(r),r));+ items.forEach(r=>{const k=r.label||r.name;if(k&&!m.has(k))m.set(k,r);});+ return m;+}+// All or nothing. This used to push whatever resolved and drop the rest, so one+// stale key shrank the plan and the shrunken plan was then saved over the real one. function applyOrder(order){- const byKey=new Map();items.forEach(r=>byKey.set(r.id||r.label||r.name, r));- items.length=0;order.forEach(k=>{const r=byKey.get(k);if(r)items.push(r);});+ if(!Array.isArray(order))return false;+ const byKey=orderKeyMap();+ const next=order.map(k=>byKey.get(k)).filter(Boolean);+ if(next.length!==items.length||new Set(next).size!==items.length)return false;+ items.length=0;items.push(...next); items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;}); keepTailLast(); drawDeps();+ return true; } function undo(){ if(!undoStack.length)return;@@ -657,15 +681,39 @@ 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,name:r.name,date:r.date,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||''})), phases: phases.map((p,i)=>({i,start:p.start,end:p.end})),- order: items.map(r=>r.id||r.label||r.name),+ order: items.map(rowOrderKey), }; }+// A milestone entry carries a stable key, an index and a name, and all but the+// key can go stale: a reorder moves the index, a rename moves the name. Resolve+// down a ladder and claim each milestone at most once.+//+// 1. the stable row key (survives both)+// 2. exactly one unclaimed milestone with this name (survives a reorder)+// 3. the milestone at this index, if unclaimed (survives a rename)+// 4. the first unclaimed milestone with this name (duplicate names)+//+// The old code was `find(name) || ms[i]`: find always won and always returned the+// FIRST match, so an edit to the second of two same-named milestones landed on+// the first and was lost, and a rename resolved to nothing at all.+function resolveMs(ms,sv,claimed){+ if(sv.key){ const k=ms.findIndex(m=>msKey(m)===sv.key); if(k>=0&&!claimed.has(k))return k; }+ const byName=[];+ ms.forEach((m,i)=>{ if(!claimed.has(i)&&m.name===sv.name)byName.push(i); });+ if(byName.length===1)return byName[0];+ if(typeof sv.i==='number'&&ms[sv.i]&&!claimed.has(sv.i))return sv.i;+ if(byName.length)return byName[0];+ return -1;+} function applyState(state){ if(!state)return; const tasks = Array.isArray(state) ? state : state.tasks; // v1 was a bare task array (tasks||[]).forEach(sv=>{+ // An entry with no id matched the first id-less row, which is a group header,+ // and wrote task dates and an owner onto it, dragging the whole axis with it.+ if(!sv||!sv.id)return; const r=items.find(d=>d.id===sv.id);if(!r)return; if(sv.s)r.start=sv.s;if(sv.e)r.end=sv.e;if(sv.deps)r.deps=sv.deps;if(sv.note!==undefined)r.note=sv.note; if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);@@ -674,10 +722,17 @@ function applyState(state){ }); if(Array.isArray(state))return; const ms=items.filter(r=>r.type==='milestone');+ const claimed=new Set(); (state.milestones||[]).forEach(sv=>{- const r=ms.find(m=>m.name===sv.name)||ms[sv.i];- if(!r)return;+ if(!sv)return;+ const i=resolveMs(ms,sv,claimed);+ if(i<0)return;+ claimed.add(i);+ const r=ms[i]; if(sv.date)r.date=sv.date;+ // getState has always written the name; nothing ever read it back, so a+ // rename made in the milestone editor did not survive a reload.+ if(sv.name)r.name=sv.name; if(sv.owner!==undefined)setOpt(r,'owner',sv.owner); if(sv.status!==undefined)setOpt(r,'status',sv.status); if(sv.desc!==undefined)setOpt(r,'description',sv.desc);@@ -704,10 +759,12 @@ function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v. 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||''};});- // Milestones carry no id, so they are keyed by name and hold the same shape as- // a task minus the dates, which lets one diff cover both.- const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[r.name]={date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||''};});- return {byId,ms,order:items.map(r=>r.id||r.label||r.name)};+ // 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||''};});+ return {byId,ms,order:items.map(rowOrderKey)}; } let lastSnap=snapSemantic(); function diffAndLog(via){@@ -738,18 +795,20 @@ function diffAndLog(via){ from:{start:planStartOf(lastSnap)},to:{start:planStart()}},...kept); } }- // milestones: keyed by name, since they carry no id- const curMs={},oldMs=lastSnap.ms||{};- items.filter(r=>r.type==='milestone').forEach(r=>{curMs[r.name]={date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||''};});- Object.keys(curMs).forEach(n=>{- const a=oldMs[n],b=curMs[n];+ // milestones: keyed by the stable row key, since they carry no id+ const curMs=cur.ms,oldMs=lastSnap.ms||{};+ Object.keys(curMs).forEach(k=>{+ const a=oldMs[k],b=curMs[k]; if(!a)return;+ const nm=b.name;+ if(a.name!==b.name)+ evs.push({action:'milestone.rename',ms:true,key:k,name:nm,from:{name:a.name},to:{name:b.name}}); if(a.date!==b.date&&a.date&&b.date)- evs.push({action:'milestone',name:n,from:{date:a.date},to:{date:b.date},+ evs.push({action:'milestone',ms:true,key:k,name:nm,from:{date:a.date},to:{date:b.date}, shift:{days:daysBetween(a.date,b.date)}});- if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',ms:true,name:n,from:{owner:a.owner||''},to:{owner:b.owner||''}});- if((a.status||'')!==(b.status||'')) evs.push({action:'status',ms:true,name:n,from:{status:a.status||''},to:{status:b.status||''}});- if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,name:n,from:{desc:a.desc||''},to:{desc:b.desc||''}});+ 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||''}}); }); const before=lastSnap.order,after=cur.order; if(JSON.stringify(before)!==JSON.stringify(after)){@@ -772,7 +831,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:${phaseColor(i)}"></div>${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';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);}); 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);@@ -876,7 +935,7 @@ 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]||''}">${statusLabel[r.status]||r.status}</span>`:'';lDiv.innerHTML=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.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);@@ -1110,10 +1169,10 @@ function openMilestoneEditor(i){ closeEditor(); editorEl.innerHTML=`<div class="drag-bar"></div> <div class="de-row"><div style="flex:1"><label>milestone</label>- <input type="text" id="meName" value="${(r.name||'').replace(/"/g,'"')}"></div></div>+ <input type="text" id="meName" value="${esc(r.name||'')}"></div></div> <div class="de-row"><div><label>date</label><input type="date" id="meDate" value="${r.date}"></div></div> <div class="de-note-row"><label>description <span class="de-hint">shown on hover</span></label>- <input type="text" id="meDesc" value="${(r.description||'').replace(/"/g,'"')}"></div>+ <input type="text" id="meDesc" value="${esc(r.description||'')}"></div> <div class="de-actions"><button class="de-btn primary" id="meApply">Apply</button> <button class="de-btn secondary" id="meClose">Cancel</button></div>`; const x=r._msHit?r._msHit.getBoundingClientRect():{left:window.innerWidth/2,bottom:200};@@ -1138,7 +1197,7 @@ function openMilestoneEditor(i){ function openSingleEditor(id){ const r=items.find(d=>d.id===id);if(!r)return; const allTasks=items.filter(d=>d.id&&d.id!==id),deps=r.deps||[];- editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${(r.note||'').replace(/"/g,'"')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${t.id}">${t.name}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`;+ editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${esc(r.note||'')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`; renderDepList(id); const rect=barEls[id].bar.getBoundingClientRect(); const edW=300, edH=300;@@ -1158,7 +1217,7 @@ function renderDepList(id){ const incoming=(r.deps||[]).map(depId=>{const dep=items.find(d=>d.id===depId);return{depId,name:dep?dep.name:depId,dir:'in'};}); const outgoing=items.filter(d=>d.deps&&d.deps.includes(id)).map(d=>({depId:d.id,name:d.name,dir:'out'})); const all=[...incoming,...outgoing];- list.innerHTML=all.map(d=>`<li><span style="font-family:'JetBrains Mono',monospace;font-size:9px;color:${d.dir==='in'?'var(--accent)':'var(--text2)'};margin-right:4px;min-width:48px">${d.dir==='in'?'← needs':'→ blocks'}</span><span style="flex:1;color:${d.dir==='in'?'var(--text)':'var(--text2)'}">${d.name}</span><span class="dep-rm" data-dep="${d.depId}" data-dir="${d.dir}">×</span></li>`).join('')||'<li style="color:var(--text2)">none</li>';+ list.innerHTML=all.map(d=>`<li><span style="font-family:'JetBrains Mono',monospace;font-size:9px;color:${d.dir==='in'?'var(--accent)':'var(--text2)'};margin-right:4px;min-width:48px">${d.dir==='in'?'← needs':'→ blocks'}</span><span style="flex:1;color:${d.dir==='in'?'var(--text)':'var(--text2)'}">${esc(d.name)}</span><span class="dep-rm" data-dep="${esc(d.depId)}" data-dir="${esc(d.dir)}">×</span></li>`).join('')||'<li style="color:var(--text2)">none</li>'; list.querySelectorAll('.dep-rm').forEach(btn=>{btn.addEventListener('click',()=>{ const depId=btn.dataset.dep, dir=btn.dataset.dir; if(dir==='in'){snapshot(id);r.deps=r.deps.filter(d=>d!==depId);}@@ -1413,7 +1472,19 @@ document.addEventListener('dblclick',e=>{ // ── Tooltip ── let tipTimer=null;-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;tipEl.innerHTML=`<div class="tip-title">${t}</div><div class="tip-dates">${d}</div>${b?'<div class="tip-body">'+b+'</div>':''}`;tipEl.classList.add('show');posTip(e);},500);}+// Built as nodes, never as markup. The title and body are user-typed (a task name,+// a note typed into the table), and reading them back out of a data-* attribute+// decodes the escaping that put them there, so an innerHTML here executed whatever+// the last person to edit the plan wrote, for everyone who opened it afterwards.+function tipLine(cls,text){const d=document.createElement('div');d.className=cls;d.textContent=text;return d;}+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;+ tipEl.textContent='';+ tipEl.appendChild(tipLine('tip-title',t));+ if(d)tipEl.appendChild(tipLine('tip-dates',d));+ if(b)tipEl.appendChild(tipLine('tip-body',b));+ 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');} document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-title]');if(el)showTip(el,e);});@@ -1829,7 +1900,7 @@ function renderTable(){ 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 dot=r.phase!==undefined?`<span class="phase-dot" style="background:${phaseColor(r.phase)}"></span>`:'';+ 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':''; return `<tr data-key="${esc(rowKey(r))}"${ms?' class="is-ms"':''}>`
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.8.0",+ "version": "0.8.1", "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+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.8.0",+ "version": "0.8.1", "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": {
server.js+172−51@@ -32,12 +32,26 @@ const MIME = { // ── Helpers ── +// 8 MB is far above any real plan and far below anything that hurts. Without a+// cap an unauthenticated POST can grow the process until it dies.+const MAX_BODY = 8 * 1024 * 1024;+ function readBody(req) { return new Promise((resolve, reject) => {- let body = '';- req.on('data', c => body += c);- req.on('end', () => resolve(body));- req.on('error', reject);+ let body = '', size = 0, done = false;+ req.on('data', c => {+ if (done) return;+ size += c.length;+ if (size > MAX_BODY) {+ done = true;+ req.destroy();+ reject(new Error(`request body over ${MAX_BODY} bytes`));+ return;+ }+ body += c;+ });+ req.on('end', () => { if (!done) { done = true; resolve(body); } });+ req.on('error', e => { if (!done) { done = true; reject(e); } }); }); } @@ -84,64 +98,79 @@ function currentRev() { function appendLog(events, actor) { let rev = currentRev(); const ts = new Date().toISOString();+ // The server's own fields go on LAST. Spreading the caller's event last let a+ // posted `rev` overwrite the counter, which breaks ?since= monotonicity for+ // every reader after it. const rows = events.map(e => ({+ ...e, rev: ++rev, ts: e.ts || ts, actor: actor || e.actor || 'user',- ...e, })); fs.appendFileSync(LOG_FILE, rows.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8'); return { rev, added: rows.length, rows }; } +// Total by construction. The log is append-only and anyone can POST to /log, so a+// single event missing a field it "should" have used to throw here, and the throw+// surfaced as a dead server (see the /changelog handler). Every field access goes+// through fld(); an event that says nothing useful degrades to a bare sentence. function describeEvent(e) {+ if (!e || typeof e !== 'object') return 'unreadable event';+ const fld = (side, k, dflt) => {+ const o = e[side];+ if (!o || typeof o !== 'object' || o[k] === undefined || o[k] === null) return dflt;+ return o[k];+ };+ const num = (side, k) => { const v = fld(side, k, 0); return typeof v === 'number' ? v : 0; };+ const sgn = n => (n > 0 ? '+' : '') + n; const who = e.actor || 'user'; const via = e.via ? ` (${e.via})` : '';- const name = e.name ? `"${e.name}"` : e.id || '';- if (e.action === 'dates') {- const ds = e.shift ? e.shift.start : 0, de = e.shift ? e.shift.end : 0;- const move = (ds === de && ds !== 0) ? `moved ${ds > 0 ? '+' : ''}${ds}d`- : `start ${ds > 0 ? '+' : ''}${ds}d, end ${de > 0 ? '+' : ''}${de}d`;- return `${who} ${move} on ${name}: ${e.from.start}..${e.from.end} -> ${e.to.start}..${e.to.end}${via}`;+ const name = e.name ? `"${e.name}"` : (e.id || 'an item');+ const action = typeof e.action === 'string' ? e.action : 'changed';+ if (action === 'dates') {+ const ds = num('shift', 'start'), de = num('shift', 'end');+ const move = (ds === de && ds !== 0) ? `moved ${sgn(ds)}d` : `start ${sgn(ds)}d, end ${sgn(de)}d`;+ return `${who} ${move} on ${name}: ${fld('from', 'start', '?')}..${fld('from', 'end', '?')} -> ${fld('to', 'start', '?')}..${fld('to', 'end', '?')}${via}`; }- if (e.action === 'plan.shift') {- const d = e.shift ? e.shift.start : 0;- return `${who} moved the whole plan ${d > 0 ? '+' : ''}${d}d (${e.count} items): starts ${e.from.start} -> ${e.to.start}${via}`;+ if (action === 'plan.shift') {+ return `${who} moved the whole plan ${sgn(num('shift', 'start'))}d (${e.count || 0} items): starts ${fld('from', 'start', '?')} -> ${fld('to', 'start', '?')}${via}`; }- if (e.action === 'timeline.start') {- const f = e.from && e.from.start, t = e.to && e.to.start;+ if (action === 'timeline.start') {+ const f = fld('from', 'start', ''), t = fld('to', 'start', '?'); return `${who} set the timeline to start at ${t}${f ? ` (was ${f})` : ''}${via}`; }- if (e.action === 'milestone') {- const d = e.shift ? e.shift.days : 0;- return `${who} moved milestone "${e.name}" ${d > 0 ? '+' : ''}${d}d: ${e.from.date} -> ${e.to.date}${via}`;+ if (action === 'milestone') {+ return `${who} moved milestone ${name} ${sgn(num('shift', 'days'))}d: ${fld('from', 'date', '?')} -> ${fld('to', 'date', '?')}${via}`;+ }+ if (action === 'milestone.rename') {+ return `${who} renamed milestone "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`; }- if (e.action === 'note') {- const f = e.from && e.from.note, t = e.to && e.to.note;+ if (action === 'note') {+ const f = fld('from', 'note', ''), t = fld('to', 'note', ''); if (!f) return `${who} labelled ${name} "${t}"${via}`; if (!t) return `${who} cleared the label on ${name} (was "${f}")${via}`; return `${who} relabelled ${name} "${f}" -> "${t}"${via}`; }- if (e.action === 'owner') {- const f = e.from && e.from.owner, t = e.to && e.to.owner;- if (!f) return `${who} assigned ${name} to ${t}${via}`;+ if (action === 'owner') {+ const f = fld('from', 'owner', ''), t = fld('to', 'owner', '');+ if (!f) return `${who} assigned ${name} to ${t || 'nobody'}${via}`; if (!t) return `${who} left ${name} unassigned (was ${f})${via}`; return `${who} reassigned ${name} from ${f} to ${t}${via}`; }- if (e.action === 'status') {- const f = (e.from && e.from.status) || 'none', t = (e.to && e.to.status) || 'none';- return `${who} set ${name} status ${f} -> ${t}${via}`;+ if (action === 'status') {+ return `${who} set ${name} status ${fld('from', 'status', '') || 'none'} -> ${fld('to', 'status', '') || 'none'}${via}`; }- if (e.action === 'desc') {- const f = e.from && e.from.desc, t = e.to && e.to.desc;+ if (action === 'desc') {+ const f = fld('from', 'desc', ''), t = fld('to', 'desc', ''); if (!f) return `${who} added notes to ${name}: "${t}"${via}`; if (!t) return `${who} cleared the notes on ${name}${via}`; return `${who} rewrote the notes on ${name}: "${t}"${via}`; }- if (e.action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep}"${via}`;- if (e.action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep}"${via}`;- if (e.action === 'reorder') return `${who} reordered rows: ${(e.moved || []).join(', ') || 'row order changed'}${via}`;- return `${who} ${e.action} ${name}${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}`;+ return `${who} ${action} ${name}${via}`; } function changelogMarkdown(log, since, rev) {@@ -162,7 +191,11 @@ function changelogMarkdown(log, since, rev) { lines.push('Treat these as deliberate human edits. Build on them; do not reset them.'); lines.push(''); }- log.forEach(e => lines.push(`- [rev ${e.rev}] ${e.ts.slice(0, 19).replace('T', ' ')} ${describeEvent(e)}`));+ log.forEach(e => {+ const ts = typeof (e && e.ts) === 'string' ? e.ts.slice(0, 19).replace('T', ' ') : '';+ const rev = (e && e.rev !== undefined) ? e.rev : '?';+ lines.push(`- [rev ${rev}]${ts ? ' ' + ts : ''} ${describeEvent(e)}`);+ }); return lines.join('\n') + '\n'; } @@ -174,6 +207,40 @@ function setOpt(r, k, v) { else r[k] = v; } +// Sidecar milestone entries carry both an index and a name, and either can go+// stale: a reorder moves the index, a rename moves the name. Resolve with a+// preference ladder and claim each milestone at most once, so two entries can+// never collapse onto one row and a rename is not mistaken for a new milestone.+//+// 1. exactly one unclaimed milestone with this name (survives a reorder)+// 2. the milestone at this index, if unclaimed (survives a rename)+// 3. the first unclaimed milestone with this name (duplicate names)+//+// The old code was `find(name) || ms[i]`, where find always won and always+// returned the FIRST match, so an edit to the second of two same-named+// milestones landed on the first and was lost.+function resolveMilestone(ms, sv, claimed) {+ const byName = [];+ ms.forEach((m, i) => { if (!claimed.has(i) && m.name === sv.name) byName.push(i); });+ if (byName.length === 1) return byName[0];+ const i = sv.i;+ if (typeof i === 'number' && ms[i] && !claimed.has(i)) return i;+ if (byName.length) return byName[0];+ return -1;+}++// Row-order keys. An id-less row (a group, a milestone) used to be keyed by its+// own label or name, so renaming one changed its key, the key stopped resolving,+// and the row silently vanished from the plan. Index into the data file instead:+// that never changes when the text does. Legacy label and name keys are still+// accepted so a sidecar written before this change still restores its order.+function orderKeyMap(items) {+ const m = new Map();+ items.forEach((r, i) => { m.set(r.id || ('#row' + i), r); });+ items.forEach(r => { const k = r.label || r.name; if (k && !m.has(k)) m.set(k, r); });+ return m;+}+ function mergedData() { const data = loadData(); const state = loadState();@@ -181,6 +248,9 @@ function mergedData() { // v1 was a bare array of tasks; v2 also carries milestones, phases and row order. const tasks = Array.isArray(state) ? state : state.tasks; (tasks || []).forEach(sv => {+ // An entry with no id would match the first id-less row in the file, which is+ // a group header, and write task dates and an owner onto it. Skip it.+ if (!sv || !sv.id) return; const r = data.items.find(d => d.id === sv.id); if (!r) return; if (sv.s) r.start = sv.s;@@ -194,10 +264,16 @@ function mergedData() { }); if (Array.isArray(state)) return data; const ms = data.items.filter(r => r.type === 'milestone');+ const claimed = new Set(); (state.milestones || []).forEach(sv => {- const r = ms.find(m => m.name === sv.name) || ms[sv.i];- if (!r) return;+ if (!sv) return;+ const i = resolveMilestone(ms, sv, claimed);+ if (i < 0) return;+ claimed.add(i);+ const r = ms[i]; if (sv.date) r.date = sv.date;+ // A rename made in the milestone editor is a real edit and has to persist.+ if (sv.name) r.name = sv.name; setOpt(r, 'owner', sv.owner); setOpt(r, 'status', sv.status); setOpt(r, 'description', sv.desc);@@ -207,10 +283,12 @@ function mergedData() { if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; } }); if (Array.isArray(state.order) && state.order.length === data.items.length) {- const key = r => r.id || r.label || r.name;- const byKey = new Map(data.items.map(r => [key(r), r]));+ const byKey = orderKeyMap(data.items); const reordered = state.order.map(k => byKey.get(k)).filter(Boolean);- if (reordered.length === data.items.length) data.items = reordered;+ // All or nothing. A single unresolved key would otherwise drop a row.+ if (reordered.length === data.items.length && new Set(reordered).size === data.items.length) {+ data.items = reordered;+ } } return data; }@@ -261,26 +339,42 @@ function parseCSV(text) { for (let i = 1; i < lines.length; i++) { const vals = parseCSVRow(lines[i]); const obj = {};- headers.forEach((h, j) => { obj[h] = vals[j] || ''; });+ headers.forEach((h, j) => { obj[h] = csvUnguard(vals[j] || ''); }); rows.push(obj); } return rows; } -function itemsToCSV(items) {+// A spreadsheet treats a cell opening with = + - @ (or a tab or CR) as a formula,+// so an owner or a note typed into the table would execute on open. Guard it with+// a leading apostrophe, which is the convention every spreadsheet understands.+// CSV import strips the guard back off, so the round trip is lossless.+const CSV_FORMULA_LEAD = /^[=+\-@\t\r]/;+function csvGuard(s) { return CSV_FORMULA_LEAD.test(s) ? "'" + s : s; }+function csvUnguard(s) { return (typeof s === 'string' && s.length > 1 && s[0] === "'" && CSV_FORMULA_LEAD.test(s.slice(1))) ? s.slice(1) : s; }++// `phases` is passed so the phase column can carry the phase NAME. It used to+// 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 escape = v => { if (v == null) return '';- const s = String(v);- if (s.includes(',') || s.includes('"') || s.includes('\n')) {+ const s = csvGuard(String(v));+ if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; };+ const phaseName = i => {+ const p = phases && phases[i];+ return p && p.name ? p.name : (i == null ? '' : i);+ }; const lines = [headers.join(',')]; items.forEach(item => { const row = headers.map(h => { if (h === 'deps') return escape((item.deps || []).join(';'));+ if (h === 'phase') return escape(phaseName(item.phase)); return escape(item[h]); }); lines.push(row.join(','));@@ -513,7 +607,7 @@ function importJiraCSV(body) { const created = row.created || row['created date'] || ''; const dueDate = row['due date'] || row.due || row.duedate || ''; - // Parse dates — JIRA uses various formats+ // Parse dates: JIRA uses various formats const parseJiraDate = ds => { if (!ds) return ''; // Try ISO format first@@ -735,9 +829,13 @@ function generateHTMLSnapshot() { const ganttHtml = fs.readFileSync(path.join(APP_DIR, 'gantt.html'), 'utf8'); const data = mergedData(); - // Replace the fetch('data') call with inline data+ // Replace the fetch('data') call with inline data. // The original code: const projData = await fetch('data').then(r => r.json());- const inlineDataScript = `const projData = ${JSON.stringify(data)};`;+ //+ // JSON.stringify does NOT escape "<", so a task note containing "</script>"+ // closed this tag and everything after it ran as markup. Escaping every "<" as+ // < is still valid JS and still parses back to the same string.+ const inlineDataScript = `const projData = ${JSON.stringify(data).replace(/</g, '\\u003c')};`; // Also replace the state fetch with a no-op // The original: const r=await fetch('state');if(r.ok){const st=await r.json();...@@ -776,9 +874,11 @@ function generateHTMLSnapshot() { // has in localStorage from some other chart. project.view supplies the defaults. html = html.replace(/const SNAPSHOT = false;/, 'const SNAPSHOT = true;'); - // Add a comment at the top indicating this is a snapshot+ // Add a comment at the top indicating this is a snapshot. "--" cannot appear+ // inside an HTML comment, so squash any that the view object carries. const v = (data.project && data.project.view) || {};- html = html.replace('<!DOCTYPE html>', `<!DOCTYPE html>\n<!-- adom-gantt snapshot — generated ${new Date().toISOString()}${Object.keys(v).length ? ' — view ' + JSON.stringify(v) : ''} -->`);+ const vTxt = Object.keys(v).length ? ', view ' + JSON.stringify(v).replace(/-{2,}/g, '-').replace(/>/g, '') : '';+ html = html.replace('<!DOCTYPE html>', `<!DOCTYPE html>\n<!-- adom-gantt snapshot, generated ${new Date().toISOString()}${vTxt} -->`); return html; }@@ -823,7 +923,14 @@ const server = http.createServer(async (req, res) => { const body = await readBody(req); try { const payload = JSON.parse(body);- const events = Array.isArray(payload) ? payload : (payload.events || []);+ const raw = Array.isArray(payload) ? payload : (payload.events || []);+ // describeEvent copes with anything, but a row with no action says nothing+ // at all, so refuse it at the door rather than storing noise forever.+ const events = (Array.isArray(raw) ? raw : []).filter(e => e && typeof e === 'object' && typeof e.action === 'string' && e.action);+ if (Array.isArray(raw) && raw.length && !events.length) {+ jsonResp(res, 400, { ok: false, error: 'every event needs a string "action"', rev: currentRev() });+ return;+ } if (!events.length) { jsonResp(res, 200, { ok: true, added: 0, rev: currentRev() }); return; } const out = appendLog(events, payload.actor); jsonResp(res, 200, { ok: true, added: out.added, rev: out.rev, file: path.basename(LOG_FILE) });@@ -839,8 +946,12 @@ const server = http.createServer(async (req, res) => { const rev = all.length ? all[all.length - 1].rev : 0; const sel = since ? all.filter(e => e.rev > since) : all; if (parsed.query.format === 'md') {+ // Render BEFORE the head goes out. Otherwise a throw in here lands in the+ // outer catch, which writes a second head, and ERR_HTTP_HEADERS_SENT takes+ // the whole process down.+ const md = changelogMarkdown(sel, since, rev); res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });- res.end(changelogMarkdown(sel, since, rev));+ res.end(md); } else { jsonResp(res, 200, { rev,@@ -929,7 +1040,7 @@ const server = http.createServer(async (req, res) => { if (urlPath === '/export/csv') { const data = mergedData();- const csv = itemsToCSV(data.items);+ const csv = itemsToCSV(data.items, data.phases); res.writeHead(200, { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="adom-gantt-export.csv"'@@ -967,10 +1078,20 @@ const server = http.createServer(async (req, res) => { fs.createReadStream(filePath).pipe(res); } catch (e) {+ // Once a head is out, writing a second one throws ERR_HTTP_HEADERS_SENT from+ // outside any catch and kills the process. Drop the connection instead: one+ // bad request must never be able to take the server with it.+ if (res.headersSent) { try { res.destroy(); } catch (_) {} return; } jsonResp(res, 500, { ok: false, error: e.message }); } }); +// Last line of defence. A single malformed row in an append-only log should cost+// one request, not the process and every future restart.+process.on('uncaughtException', err => {+ console.error('adom-gantt: uncaught', err && err.stack ? err.stack : err);+});+ 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}`);