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.2
6 files changed
+107−17
README.md+2@@ -36,6 +36,8 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Days mode.** A checkbox switches the axis from months to days, with a full calendar picker, weekend shading and exact day positions. Month mode approximates within a month; day mode does not. +**Editable milestones.** Drag a milestone sideways to move its date, or click it to edit its name, date and description.+ **Bar notes.** Put a short label on the bar body, the way durations are annotated on a printed plan. Double-click a bar to type one, or use the note field in the editor. **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.
SKILL.md+14−8@@ -134,14 +134,19 @@ phase out of theming, and it will not adapt for paper. The ramp is teal, purple, blue, then a second step of each: -| Phase | Dark (default) | Light / print | Contrast (dark / white) |-|-------|----------------|---------------|-------------------------|-| 1 | `#00b8b1` teal | `#00a49d` | 7.65 / 3.09 |-| 2 | `#8c6bf7` purple | `#8c6bf7` | 5.01 / 3.78 |-| 3 | `#64abff` blue | `#3b95ff` | 7.94 / 3.03 |-| 4 | `#1fd7d0` | `#00726d` | second step, teal |-| 5 | `#bfb1ee` | `#5b32e0` | second step, purple |-| 6 | `#adcdf3` | `#0061ef` | second step, blue |+| Phase | Dark (default) | Light / print |+|-------|----------------|---------------|+| 1 | `#00b8b1` teal | `#00a49d` |+| 2 | `#8c6bf7` purple | `#8c6bf7` |+| 3 | `#64abff` blue | `#3b95ff` |+| 4 | `#a8f7f0` pale teal | `#00595a` deep teal |+| 5 | `#d3bcff` pale purple | `#4b21c9` deep purple |+| 6 | `#c7e0ff` pale blue | `#0b3f8f` deep blue |++The second triad steps away from its primary in whichever direction the background+allows: lighter on dark, darker on paper. Every pair in the ramp is at least 27 CIELAB+deltaE apart, so no two phases read as the same colour. An earlier ramp put the teal+pair only 11 apart, which looked like one colour on screen. Phase 7 wraps back to phase 1. The light values are deepened so each clears 3:1 on white; the dark values are the brand tokens exactly.@@ -162,6 +167,7 @@ rather than a category. - **Project start** in the header. Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting - **Days mode** switches the axis from months to days, with a full calendar picker, weekend shading and exact day positions. Month mode approximates within a month; day mode does not - **Bar notes** put a short label on the bar body. Double-click a bar to type one, or use the note field in the editor+- **Milestones are editable too**: drag one sideways to move its date, or click it for a panel with its name, date and description - **Dynamic today marker** shows current date - **Fills the window**: months stretch to the pane width, and stop at 34px so a long plan scrolls rather than squashing - **Milestone style toggle**: diamonds, or dashed vertical rules spanning the chart
gantt.html+85−7@@ -29,7 +29,7 @@ /* Phase ramp: teal -> purple -> blue, then a lighter step of each. Used when a phase declares no explicit colour. */ --ph1: #00b8b1; --ph2: #8c6bf7; --ph3: #64abff;- --ph4: #1fd7d0; --ph5: #bfb1ee; --ph6: #adcdf3;+ --ph4: #a8f7f0; --ph5: #d3bcff; --ph6: #c7e0ff; --bar-opacity: 0.82; } :root[data-theme="light"] {@@ -44,7 +44,7 @@ --phase-bg-alpha: 0.10; /* Deepened for paper: each clears 3:1 on white while holding its hue. */ --ph1: #00a49d; --ph2: #8c6bf7; --ph3: #3b95ff;- --ph4: #00726d; --ph5: #5b32e0; --ph6: #0061ef;+ --ph4: #00595a; --ph5: #4b21c9; --ph6: #0b3f8f; --bar-opacity: 1; } /* Printing is always the light theme, whatever the screen is set to. */@@ -53,7 +53,7 @@ --border:#d0d7de; --border-muted:#d8dee4; --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f; --accent:#00857f; --today:#00857f; --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04); --phase-bg-alpha:0.10;- --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff; --ph4:#00726d; --ph5:#5b32e0; --ph6:#0061ef; }+ --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff; --ph4:#00595a; --ph5:#4b21c9; --ph6:#0b3f8f; } html, body { overflow: visible !important; height: auto !important; } .labels-col, .timeline-col { overflow: visible !important; } header .actions, .rev-indicator, .save-indicator { display: none !important; }@@ -434,6 +434,7 @@ function undo(){ if(!undoStack.length)return; const sn=undoStack.pop(); if(sn.type==='order'){applyOrder(sn.order);}+ else if(sn.type==='ms'){sn.r.date=sn.date;updateMilestone(sn.r);} else if(sn.type==='group'){ // one Ctrl+Z undoes the whole shift, not 26 tasks one at a time sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;r.start=en.s;r.end=en.e;r.deps=en.deps;r.note=en.note;});@@ -494,7 +495,8 @@ 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||''};});- return {byId,order:items.map(r=>r.id||r.label||r.name)};+ const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[r.name]=r.date;});+ return {byId,ms,order:items.map(r=>r.id||r.label||r.name)}; } let lastSnap=snapSemantic(); function diffAndLog(via){@@ -522,6 +524,14 @@ function diffAndLog(via){ from:{start:planStartOf(lastSnap)},to:{start:planStart()}},...kept); } }+ // milestone dates: keyed by name, since milestones carry no id+ const curMs={},oldMs=lastSnap.ms||{};+ items.filter(r=>r.type==='milestone').forEach(r=>{curMs[r.name]=r.date;});+ Object.keys(curMs).forEach(n=>{+ if(oldMs[n]&&oldMs[n]!==curMs[n])+ evs.push({action:'milestone',name:n,from:{date:oldMs[n]},to:{date:curMs[n]},+ shift:{days:daysBetween(oldMs[n],curMs[n])}});+ }); const before=lastSnap.order,after=cur.order; if(JSON.stringify(before)!==JSON.stringify(after)){ const moved=after.filter((v,i)=>before[i]!==v);@@ -647,6 +657,7 @@ items.forEach((r,idx)=>{ lDiv.className='label-row ms';lDiv.textContent=r.name;bDiv.className='bar-row ms-row'; 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||''; const d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px'; bDiv.appendChild(hit);bDiv.appendChild(d);@@ -812,6 +823,8 @@ labelsEl.addEventListener('mousedown',e=>{ let didDrag = false; bars.addEventListener('click',e=>{ if(didDrag){didDrag=false;return;}+ const msHit=e.target.closest('.ms-hit');+ if(msHit){ clearSelection(); openMilestoneEditor(+msHit.dataset.msi); e.stopPropagation(); return; } const bar=e.target.closest('.bar[data-id]'); if(bar&&!e.target.classList.contains('drag-handle')){ const id=bar.dataset.id;@@ -824,6 +837,36 @@ bars.addEventListener('click',e=>{ }); document.addEventListener('click',e=>{if(!e.target.closest('.editor')&&!e.target.closest('.bar[data-id]')&&!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeEditor();}); +// ── Milestone editor ──+function openMilestoneEditor(i){+ const r=msByIndex(i);if(!r)return;+ 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>+ <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>+ <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};+ editorEl.style.left=Math.min(Math.max(8,x.left-150),window.innerWidth-320)+'px';+ editorEl.style.top=Math.min(x.bottom+8,window.innerHeight-300)+'px';+ editorEl.classList.add('show');+ $('meApply').addEventListener('click',()=>{+ snapshotMs(r);+ const nd=$('meDate').value;+ if(nd)r.date=nd;+ r.name=$('meName').value||r.name;+ r.description=$('meDesc').value;+ 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._msLine){const cap=r._msLine.querySelector('.ms-cap');if(cap)cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 ');}+ updateMilestone(r);closeEditor();scheduleSave('milestone-editor');+ });+ $('meClose').addEventListener('click',closeEditor);+}+ // ── Single editor ── function openSingleEditor(id){ const r=items.find(d=>d.id===id);if(!r)return;@@ -899,6 +942,20 @@ function openBatchEditor(){ $('bClose').addEventListener('click',closeEditor); } +function msByIndex(i){return items.filter(r=>r.type==='milestone')[i];}+function updateMilestone(r){+ if(!r||!r._msEl)return;+ const x=toX(r.date);+ r._msEl.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';+ drawDeps();+}+function snapshotMs(r){+ undoStack.push({type:'ms',r,date:r.date});+ if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+}+ 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);drawDeps();} function closeEditor(){editorEl.classList.remove('show');editorEl.innerHTML='';} function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');}@@ -925,6 +982,19 @@ bars.addEventListener('mousedown',e=>{ gesture={id:bar.dataset.id,startX:e.clientX,startY:e.clientY,src:'bar',bar}; }); +// Milestones carry no id, so they sat outside the bar drag path entirely and could+// never be moved or edited. They get their own handler.+bars.addEventListener('mousedown',e=>{+ const hit=e.target.closest('.ms-hit');+ if(!hit)return;+ e.preventDefault();hideTip();+ const r=msByIndex(+hit.dataset.msi);+ if(!r)return;+ snapshotMs(r);+ dragState={kind:'ms',r,startX:e.clientX,origLeft:toX(r.date)};+ document.body.style.cursor='ew-resize';+});+ document.addEventListener('mousemove',e=>{ // Resolve gesture → horizontal drag or vertical reorder if(gesture&&!dragState&&!reorderState){@@ -962,6 +1032,12 @@ document.addEventListener('mousemove',e=>{ if(dragState){ didDrag=true; const dx=e.clientX-dragState.startX;+ if(dragState.kind==='ms'){+ const nl=Math.max(0,dragState.origLeft+dx);+ dragState.r.date=xToDate(nl);+ updateMilestone(dragState.r);+ return;+ } const{side,origLeft,origWidth,bar,r,moveIds,originals}=dragState; if(side==='move'){ moveIds.forEach(mid=>{@@ -1003,7 +1079,7 @@ document.addEventListener('mousemove',e=>{ document.addEventListener('mouseup',()=>{ gesture=null; - if(dragState){scheduleSave(dragState.side==='move'?'drag-move':'drag-resize');dragState=null;document.body.style.cursor='';}+ if(dragState){scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));dragState=null;document.body.style.cursor='';} if(reorderState&&reorderState.active){ labelDropInd.classList.remove('show');barDropInd.classList.remove('show');@@ -1171,10 +1247,12 @@ function shiftPlanTo(newStartISO, via){ }); // Phase bands are month strings, and they define part of the axis range, so they // have to travel with the tasks or the timeline stretches to cover both.+ // Anchor on the 15th, not the 1st. Adding days to the 1st lands mid-month and then+ // truncates down, so a +181d shift moved some phases 6 months and others 5. phases.forEach(p=>{- p.start=addDays(p.start+'-01',delta).slice(0,7);+ p.start=addDays(p.start+'-15',delta).slice(0,7); const[ey,em]=p.end.split('-').map(Number);- p.end=addDays(`${ey}-${String(em).padStart(2,'0')}-01`,delta).slice(0,7);+ p.end=addDays(`${ey}-${String(em).padStart(2,'0')}-15`,delta).slice(0,7); }); rebuildTimeline(); Object.keys(barEls).forEach(id=>updateBar(id));
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.3.1",+ "version": "0.3.2", "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.1",+ "version": "0.3.2", "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+4@@ -112,6 +112,10 @@ function describeEvent(e) { const f = e.from && e.from.start, t = e.to && e.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 (e.action === 'note') { const f = e.from && e.from.note, t = e.to && e.to.note; if (!f) return `${who} labelled ${name} "${t}"${via}`;