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
Publish 0.3.1
8 files changed
+84−25
README.md+2@@ -40,6 +40,8 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Room to scroll.** You can scroll past the last row, so the bottom of the plan can sit centred in the pane instead of pinned to the bottom edge. +**Clean group labels.** A trailing date range in a group label is dropped at render, since the axis already shows the dates and the text goes stale as soon as the plan moves.+ **Changelog.** Every edit is appended to `<datafile>-changelog.jsonl` with the item, the before and after dates, the day shift, and which gesture made it. An AI picking the project up later reads it and builds on your changes instead of overwriting them. The rev counter in the bottom-left corner is the handle: an agent notes it, then asks for only what changed since. ## In action
SKILL.md+9−2@@ -209,10 +209,17 @@ curl -s -X POST localhost:8901/log -H 'Content-Type: application/json' \ The log is append-only and never rewritten. `POST /changelog/reset` deletes it outright, so only run it when the user asks for a clean slate. +## Group labels++Do not put a date range in a group label. `"Phase 1 - Design (Jan-Apr 2026)"` renders as+`Phase 1 - Design`: the axis already states the dates, and the text goes stale the moment+the plan is shifted. The strip is narrow, only a trailing parenthetical that begins with a+month abbreviation and carries a four-digit year, so `(EVT)` and `(Year 1-2)` are kept.+ ## State file -Edits (date changes, dependency adds/removes) are saved to a sidecar JSON-file next to your data file. For example, `smt-line.json` saves state to+Edits (date changes, dependency adds/removes, notes, milestone dates, phase ranges and row+order) are saved to a sidecar JSON file next to your data file. For example, `smt-line.json` saves state to `smt-line-state.json`. This file only contains deltas. It records the current start, end, and deps for each task. Delete it to reset to the original data file values.
examples/sample-product-launch.json+4−4@@ -29,7 +29,7 @@ "items": [ { "type": "group",- "label": "Phase 1 — Design (Jan–Apr 2026)",+ "label": "Phase 1 — Design", "phase": 0 }, {@@ -113,7 +113,7 @@ }, { "type": "group",- "label": "Phase 2 — Prototype (Mar–Jul 2026)",+ "label": "Phase 2 — Prototype", "phase": 1 }, {@@ -204,7 +204,7 @@ }, { "type": "group",- "label": "Phase 3 — Validation (Jul–Oct 2026)",+ "label": "Phase 3 — Validation", "phase": 2 }, {@@ -285,7 +285,7 @@ }, { "type": "group",- "label": "Phase 4 — Production (Oct 2026–Feb 2027)",+ "label": "Phase 4 — Production", "phase": 3 }, {
examples/smt-line.json+5−5@@ -34,7 +34,7 @@ "items": [ { "type": "group",- "label": "Phase 0 — Foundation (Jun–Aug 2026)",+ "label": "Phase 0 — Foundation", "phase": 0 }, {@@ -156,7 +156,7 @@ }, { "type": "group",- "label": "Phase 1 — SPI + Loop 1 (Sep 2026–Feb 2027)",+ "label": "Phase 1 — SPI + Loop 1", "phase": 1 }, {@@ -277,7 +277,7 @@ }, { "type": "group",- "label": "Phase 2 — AOI + Loop 2 (Mar–Aug 2027)",+ "label": "Phase 2 — AOI + Loop 2", "phase": 2 }, {@@ -371,7 +371,7 @@ }, { "type": "group",- "label": "Phase 3 — Reflow + Predictive (Sep 2027–Jun 2028)",+ "label": "Phase 3 — Reflow + Predictive", "phase": 3 }, {@@ -439,7 +439,7 @@ }, { "type": "group",- "label": "Phase 4 — Research (Jul 2028+)",+ "label": "Phase 4 — Research", "phase": 4 }, {
gantt.html+36−4@@ -448,8 +448,33 @@ document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){ // ── Persistence ── let saveTimer=null;-function getState(){return items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||''}));}-function applyState(state){if(!Array.isArray(state))return;state.forEach(sv=>{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;});}+// Milestones carry no id, so an array of tasks alone loses their dates on reload and+// they snap back to the data file while the tasks stay shifted. Persist both.+function getState(){+ return {+ v: 2,+ tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||''})),+ milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,name:r.name,date:r.date})),+ phases: phases.map((p,i)=>({i,start:p.start,end:p.end})),+ order: items.map(r=>r.id||r.label||r.name),+ };+}+function applyState(state){+ if(!state)return;+ const tasks = Array.isArray(state) ? state : state.tasks; // v1 was a bare task array+ (tasks||[]).forEach(sv=>{+ 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(Array.isArray(state))return;+ const ms=items.filter(r=>r.type==='milestone');+ (state.milestones||[]).forEach(sv=>{+ const r=ms.find(m=>m.name===sv.name)||ms[sv.i];+ if(r&&sv.date)r.date=sv.date;+ });+ (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);+} // ── Changelog ── // // Rather than instrument each edit path by hand (which drifts the moment a new@@ -457,6 +482,13 @@ function applyState(state){if(!Array.isArray(state))return;state.forEach(sv=>{co // hook, and it cannot disagree with the data. `via` just labels how the change // was made, for readability in the log. +// Group labels often carry a trailing date range, e.g. "Phase 1 - Design (Jan-Apr 2026)".+// The axis already states the dates, and the text goes stale the moment the plan is+// shifted, so drop it. Narrow on purpose: it only strips a parenthetical that starts+// with a month abbreviation and carries a 4-digit year, so "(EVT)" or "(Year 1-2)" stay.+const GROUP_DATE_RE = /\s*\((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[^)]*\d{4}[^)]*\)\s*$/;+function groupLabel(t){ return (t||'').replace(GROUP_DATE_RE,''); }+ 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(){@@ -610,7 +642,7 @@ const statusLabel={live:'live',planned:'planned','not-started':'not started'}; items.forEach((r,idx)=>{ const lDiv=document.createElement('div'),bDiv=document.createElement('div');- if(r.type==='group'){lDiv.className='label-row group';lDiv.textContent=r.label;bDiv.className='bar-row group';}+ if(r.type==='group'){lDiv.className='label-row group';lDiv.textContent=groupLabel(r.label);bDiv.className='bar-row group';} else if(r.type==='milestone'){ lDiv.className='label-row ms';lDiv.textContent=r.name;bDiv.className='bar-row ms-row'; const x=toX(r.date);@@ -665,7 +697,7 @@ function relayout(){ if(r.type==='milestone'&&r._msEl){ const x=toX(r.date); r._msEl.style.left=x+'px';- if(r._msHit)r._msHit.style.left=x+'px';+ if(r._msHit){r._msHit.style.left=x+'px';r._msHit.dataset.dates=fmt(r.date);} if(r._msLine)r._msLine.style.left=x+'px'; } else if(r.id&&barEls[r.id]){const b=barEls[r.id].bar,x1=toX(r.start),x2=toX(r.end);b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';}
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.3.0",+ "version": "0.3.1", "description": "Interactive Gantt chart viewer for project timelines. 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. 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.3.0",+ "version": "0.3.1", "description": "Interactive Gantt chart viewer for project timelines. 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. 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+26−8@@ -149,14 +149,32 @@ function changelogMarkdown(log, since, rev) { function mergedData() { const data = loadData(); const state = loadState();- if (state && Array.isArray(state)) {- state.forEach(sv => {- const r = data.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 (!state) return data;+ // 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 => {+ const r = data.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 (Array.isArray(state)) return data;+ const ms = data.items.filter(r => r.type === 'milestone');+ (state.milestones || []).forEach(sv => {+ const r = ms.find(m => m.name === sv.name) || ms[sv.i];+ if (r && sv.date) r.date = sv.date;+ });+ (state.phases || []).forEach(sv => {+ const p = data.phases[sv.i];+ if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; }+ });+ if (Array.isArray(state.order) && state.order.length === data.items.length) {+ const key = r => r.id || r.label || r.name;+ const byKey = new Map(data.items.map(r => [key(r), r]));+ const reordered = state.order.map(k => byKey.get(k)).filter(Boolean);+ if (reordered.length === data.items.length) data.items = reordered; } return data; }