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.4.0
5 files changed
+122−80
README.md+2−2@@ -34,9 +34,9 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Move the whole plan.** The project start sits in the header. Change it and every task, milestone and phase shifts by the same number of days, so durations and the gaps between them survive. Revert puts the plan back to the data file's own start. It is one undo step and one changelog entry, not 26. -**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.+**Cell size.** Days, Weeks, Months, Quarters or Years. Each grain carries its own minimum cell width, sized to the widest label it draws, so a fine grain scrolls rather than squashing and a coarse one stretches to fill. Days adds weekend shading and a full calendar picker. -**Editable milestones.** Drag a milestone sideways to move its date, or click it to edit its name, date and description.+**Editable milestones.** Drag a milestone sideways to move its date, or click it to edit its name, date and description. In line style the tag on the dashed rule is the handle and the milestone's own row is hidden, since the rule already spans the chart. Diamond style keeps the row. **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.
SKILL.md+2−2@@ -165,9 +165,9 @@ rather than a category. - **Auto-load** saved state on page refresh - **Dynamic date range** computed from the data every render - **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+- **Cell size** picker: Days, Weeks, Months, Quarters, Years. Each grain has its own minimum cell width sized to the widest label it draws, so a fine grain scrolls rather than squashing while a coarse one stretches to fill the pane. Day and week positions are exact; month, quarter and year approximate within a month - **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+- **Milestones are editable too**: drag one sideways to move its date, or click it for a panel with its name, date and description. In line style the tag on the dashed rule is the handle, and the milestone's grid row is hidden since the rule already carries it. In diamond style the row stays - **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+116−74@@ -78,6 +78,8 @@ header .start-ctl label { color: var(--text2); font-size: 11px; } header .date-btn { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: border-color 0.15s, color 0.15s; } header .date-btn:hover { border-color: var(--accent); color: var(--text); } header .date-btn.shifted { color: var(--accent); border-color: var(--accent); }+header .grain-sel { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 6px; cursor: pointer; outline: none; }+header .grain-sel:hover, header .grain-sel:focus { border-color: var(--accent); color: var(--text); } header .days-toggle { display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; } header .days-toggle input { appearance: none; width: 13px; height: 13px; border: 1px solid var(--border); border-radius: 3px; background: var(--bg); cursor: pointer; position: relative; flex-shrink: 0; } header .days-toggle input:checked { background: var(--accent); border-color: var(--accent); }@@ -154,7 +156,7 @@ header .act-btn svg { width: 14px; height: 14px; } .bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: var(--bar-opacity, 0.82); cursor: pointer; transition: opacity 0.15s; } .bar:hover { opacity: 1; } .bar.selected { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }-.bar-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 500; color: var(--on-accent); pointer-events: none; overflow: hidden; white-space: nowrap; padding: 0 6px; }+.bar-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; color: var(--on-accent); pointer-events: none; overflow: hidden; white-space: nowrap; padding: 0 6px; } :root[data-theme="dark"] .bar-note { color: rgba(6,20,24,0.82); } .bar-note-edit { position: absolute; inset: 0; width: 100%; height: 100%; background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-align: center; outline: none; z-index: 20; } .bar-striped { background-image: repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0,0,0,0.12) 4px, rgba(0,0,0,0.12) 8px); }@@ -169,9 +171,14 @@ header .act-btn svg { width: 14px; height: 14px; } .ms-hit:hover ~ .ms-diamond, .ms-row:hover .ms-diamond { box-shadow: 0 0 10px rgba(230,237,243,0.3); } /* Line style: a dashed vertical rule spanning the whole chart at the milestone date. */ .ms-line { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1.5px dashed currentColor; opacity: 0.75; z-index: 4; pointer-events: none; }-.ms-line .ms-cap { position: absolute; top: 2px; left: 0; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1; color: inherit; background: var(--bg); padding: 1px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: nowrap; }+.ms-line .ms-cap:hover { background: var(--elevated); filter: brightness(1.25); }+.ms-line .ms-cap { position: absolute; top: 2px; left: 0; transform: translateX(-50%); pointer-events: auto; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1; color: inherit; background: var(--bg); padding: 1px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: nowrap; } :root[data-ms="diamond"] .ms-line { display: none; } :root[data-ms="line"] .ms-diamond { display: none; }+/* In line style the dashed rule and its tag carry the milestone, so the grid row is+ redundant. Both the label and bar row go, together, or the columns fall out of step. */+:root[data-ms="line"] .label-row.ms, :root[data-ms="line"] .bar-row.ms-row { display: none; }+:root[data-ms="line"] .ms-hit { display: none; } .label-row.ms::before { content: '\25C6\00a0'; } :root[data-ms="line"] .label-row.ms::before { content: '\00a6\00a0'; } .today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 8; pointer-events: none; box-shadow: 0 0 6px rgba(0,184,176,0.3); }@@ -273,7 +280,15 @@ input[type="file"] { display: none; } <script> (async function() { -const MIN_COL_MONTH = 34, MIN_COL_DAY = 16; // below these the labels stop being readable, so we scroll+// Each grain needs its own minimum cell width, sized to the widest label it draws+// ("31" vs "Mar" vs "Q1" vs "2026"). Below it the timeline scrolls instead of squashing.+const GRAINS = {+ day: { label:'Days', min:18 },+ week: { label:'Weeks', min:26 },+ month: { label:'Months', min:34 },+ quarter: { label:'Quarters', min:48 },+ year: { label:'Years', min:56 },+}; let COL = 66; // month column width, recomputed from the viewport in fitCOL() const $ = id => document.getElementById(id); const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip');@@ -325,17 +340,20 @@ const ORIGINAL_START = (()=>{ let m=null; return m; })(); let START=new Date(), UNITS=0; // UNITS = months, or days when granularity is 'day'+function addD(d,n){const x=new Date(d.getFullYear(),d.getMonth(),d.getDate());x.setDate(x.getDate()+n);return x;} function computeRange(){ const {minD,maxD}=scanRange();- const a=parseISO(minD), b=parseISO(maxD);- if(prefs.granularity==='day'){- START=new Date(a.getFullYear(),a.getMonth(),a.getDate());- START.setDate(START.getDate()-3); // a little air before the first bar- UNITS=Math.max(14, Math.round((b-START)/86400000)+8);- } else {- START=new Date(a.getFullYear(),a.getMonth(),1);- UNITS=Math.max(2,(b.getFullYear()-START.getFullYear())*12+(b.getMonth()-START.getMonth())+2);- }+ const a=parseISO(minD), b=parseISO(maxD), g=G();+ if(g==='day'){ START=addD(a,-3);+ UNITS=Math.max(14, dayDiff(START,b)+8); }+ else if(g==='week'){ const s0=new Date(a.getFullYear(),a.getMonth(),a.getDate()); s0.setDate(s0.getDate()-s0.getDay()-7);+ START=s0; UNITS=Math.max(8, Math.ceil(dayDiff(START,b)/7)+2); }+ else if(g==='month'){ START=new Date(a.getFullYear(),a.getMonth(),1);+ UNITS=Math.max(2, mDiff(START,b)+2); }+ else if(g==='quarter'){START=new Date(a.getFullYear(),Math.floor(a.getMonth()/3)*3,1);+ UNITS=Math.max(2, Math.ceil((mDiff(START,b)+1)/3)+1); }+ else { START=new Date(a.getFullYear(),0,1);+ UNITS=Math.max(2, (b.getFullYear()-START.getFullYear())+2); } } computeRange(); let W = UNITS*COL;@@ -346,31 +364,60 @@ let W = UNITS*COL; function fitCOL(){ // -1 leaves room for the closing grid line, which sits at x=W and is 1px wide const avail = (timelineEl.clientWidth || (window.innerWidth - 272)) - 1;- COL = Math.max(isDayMode()?MIN_COL_DAY:MIN_COL_MONTH, Math.floor((avail / UNITS) * 100) / 100);+ COL = Math.max((GRAINS[G()]||GRAINS.month).min, Math.floor((avail / UNITS) * 100) / 100); W = UNITS * COL; } function mDiff(a,b){return(b.getFullYear()-a.getFullYear())*12+b.getMonth()-a.getMonth();} function dayDiff(a,b){return Math.round((new Date(b.getFullYear(),b.getMonth(),b.getDate())-new Date(a.getFullYear(),a.getMonth(),a.getDate()))/86400000);}-const isDayMode = () => prefs.granularity==='day';-// One x per calendar unit. Month mode approximates within a month (30ths); day mode-// is exact, which is the whole point of turning Days on.+// Function declarations, not const arrows: computeRange() runs at module level before+// this point in the file, and a const would still be in its temporal dead zone.+function G(){ return GRAINS[prefs.granularity] ? prefs.granularity : 'month'; }+function isDayMode(){ return G()==='day'; }+// Months per unit for the calendar-based grains; day and week are handled separately+// because their length in months is not constant.+const MONTHS_PER = { month:1, quarter:3, year:12 };+ function toX(ds){- const d=parseISO(ds);- if(isDayMode()) return dayDiff(START,d)*COL;- const[y,mo,dd]=ds.split('-').map(Number);- return (mDiff(START,new Date(y,mo-1,dd||1))+((dd?(dd-1)/30:0)))*COL;+ const d=parseISO(ds), g=G();+ if(g==='day') return dayDiff(START,d)*COL;+ if(g==='week') return dayDiff(START,d)/7*COL;+ const dd=+(ds.split('-')[2]||1);+ const mf=mDiff(START,d)+(dd-1)/30;+ return mf/MONTHS_PER[g]*COL; } function xToDate(x){- if(isDayMode()){- const d=new Date(START.getFullYear(),START.getMonth(),START.getDate()+Math.round(x/COL));- return isoDay(d);- }- const tm=x/COL,mo=Math.floor(tm),df=tm-mo;+ const g=G(), u=x/COL;+ if(g==='day') return isoDay(addD(START,Math.round(u)));+ if(g==='week') return isoDay(addD(START,Math.round(u*7)));+ const mf=u*MONTHS_PER[g], mo=Math.floor(mf), df=mf-mo; const d=new Date(START.getFullYear(),START.getMonth()+mo,1); const day=Math.max(1,Math.min(28,Math.round(df*30)+1)); return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`; }+// The calendar date at the left edge of unit i, and the two header labels for it.+function unitDate(i){+ const g=G();+ if(g==='day') return addD(START,i);+ if(g==='week') return addD(START,i*7);+ if(g==='month') return new Date(START.getFullYear(),START.getMonth()+i,1);+ if(g==='quarter') return new Date(START.getFullYear(),START.getMonth()+i*3,1);+ return new Date(START.getFullYear()+i,0,1);+}+function topLabel(d){+ const g=G();+ if(g==='day'||g==='week') return `${mNames[d.getMonth()]} ${d.getFullYear()}`;+ if(g==='month') return `Q${Math.floor(d.getMonth()/3)+1} ${d.getFullYear()}`;+ if(g==='quarter') return `${d.getFullYear()}`;+ return `${Math.floor(d.getFullYear()/10)*10}s`;+}+function botLabel(d){+ const g=G();+ if(g==='day'||g==='week') return String(d.getDate());+ if(g==='month') return mNames[d.getMonth()];+ if(g==='quarter') return 'Q'+(Math.floor(d.getMonth()/3)+1);+ return String(d.getFullYear());+} function fmt(ds){const[y,mo,d]=ds.split('-').map(Number),mn=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return d?`${mn[mo-1]} ${d}, ${y}`:`${mn[mo-1]} ${y}`;} function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Date(y,mo-1,d);dt.setDate(dt.getDate()+n);return`${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;} @@ -383,8 +430,11 @@ $('hdr').innerHTML = ` <div class="start-ctl" title="Project start. Changing it moves the whole plan."> <label>starts</label> <button class="date-btn" id="startBtn"></button>- <label class="days-toggle" title="Day-level dates instead of month-level">- <input type="checkbox" id="daysChk"><span>Days</span></label>+ <select id="grainSel" class="grain-sel" title="Timeline cell size">+ <option value="day">Days</option><option value="week">Weeks</option>+ <option value="month">Months</option><option value="quarter">Quarters</option>+ <option value="year">Years</option>+ </select> </div> <div class="actions"> <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line">@@ -574,62 +624,44 @@ const gridLayer=document.createElement('div');gridLayer.style.cssText='position: // Quarter and month header cells, rebuilt whenever START or UNITS changes. function buildTimeHeaders(){ qRow.innerHTML='';mRow.innerHTML='';qs=[];- if(isDayMode()){- // top row groups by month, bottom row is the day of the month- let cm=new Date(START),cq=null;- for(let i=0;i<UNITS;i++){- const ql=`${mNames[cm.getMonth()]} ${cm.getFullYear()}`;- if(!cq||cq.l!==ql){if(cq)qs.push(cq);cq={l:ql,n:1};}else cq.n++;- cm=new Date(cm.getFullYear(),cm.getMonth(),cm.getDate()+1);- }- if(cq)qs.push(cq);- qRow.style.width=W+'px';- qs.forEach(q=>{const c=document.createElement('div');c.className='q-cell';c.style.width=(q.n*COL)+'px';c.textContent=q.l;qRow.appendChild(c);});- mRow.style.width=W+'px';- cm=new Date(START);- for(let i=0;i<UNITS;i++){- const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';- const wd=cm.getDay();- if(wd===0||wd===6)c.classList.add('weekend');- c.textContent=cm.getDate();- mRow.appendChild(c);- cm=new Date(cm.getFullYear(),cm.getMonth(),cm.getDate()+1);- }- return;+ const g=G();+ let cq=null;+ for(let i=0;i<UNITS;i++){+ const l=topLabel(unitDate(i));+ if(!cq||cq.l!==l){if(cq)qs.push(cq);cq={l,n:1};}else cq.n++; }- let cm=new Date(START),cq=null;- for(let i=0;i<UNITS;i++){const ql=`Q${Math.floor(cm.getMonth()/3)+1} ${cm.getFullYear()}`;if(!cq||cq.l!==ql){if(cq)qs.push(cq);cq={l:ql,n:1};}else cq.n++;cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);} if(cq)qs.push(cq); qRow.style.width=W+'px'; qs.forEach(q=>{const c=document.createElement('div');c.className='q-cell';c.style.width=(q.n*COL)+'px';c.textContent=q.l;qRow.appendChild(c);}); mRow.style.width=W+'px';- cm=new Date(START);- for(let i=0;i<UNITS;i++){const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';c.textContent=mNames[cm.getMonth()];mRow.appendChild(c);cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);}+ for(let i=0;i<UNITS;i++){+ const d=unitDate(i);+ const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';+ if(g==='day'&&(d.getDay()===0||d.getDay()===6))c.classList.add('weekend');+ c.textContent=botLabel(d);+ mRow.appendChild(c);+ } } function buildPhaseBands(){ bgLayer.innerHTML='';phaseBgEls.length=0; phases.forEach((p,i)=>{ const bg=document.createElement('div');bg.className='phase-bg'; const[sy,sm]=p.start.split('-').map(Number),[ey,em]=p.end.split('-').map(Number);- let l,w;- if(isDayMode()){- const a=new Date(sy,sm-1,1), b=new Date(ey,em,0); // last day of the end month- l=dayDiff(START,a)*COL; w=(dayDiff(a,b)+1)*COL;- } else {- l=mDiff(START,new Date(sy,sm-1,1))*COL;- w=(mDiff(START,new Date(ey,em-1,1))+1)*COL-l;- }- bg.style.left=l+'px';bg.style.width=w+'px';+ // toX handles every grain, so the band maths no longer branches per unit+ const l=toX(`${sy}-${String(sm).padStart(2,'0')}-01`);+ const endLast=new Date(ey,em,0);+ const r=toX(isoDay(endLast));+ bg.style.left=l+'px';bg.style.width=Math.max(2,r-l)+'px'; bg.style.background=phaseColor(i);bgLayer.appendChild(bg);phaseBgEls.push(bg); }); } function buildGridLines(){ gridLayer.innerHTML='';gridEls.length=0;- if(isDayMode()){- // a rule every day is noise: draw month boundaries, and shade weekends- const cm=new Date(START);+ const g=G();+ if(g==='day'){+ // a rule per day is noise: month boundaries only, with weekends shaded for(let i=0;i<=UNITS;i++){- const d=new Date(START.getFullYear(),START.getMonth(),START.getDate()+i);+ const d=addD(START,i); if(d.getDate()===1||i===0||i===UNITS){ const l=document.createElement('div'); l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;@@ -642,7 +674,11 @@ function buildGridLines(){ } return; }- for(let i=0;i<=UNITS;i++){const l=document.createElement('div');l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;gridLayer.appendChild(l);gridEls.push(l);}+ for(let i=0;i<=UNITS;i++){+ const l=document.createElement('div');+ l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;+ gridLayer.appendChild(l);gridEls.push(l);+ } } buildTimeHeaders();buildPhaseBands();buildGridLines(); depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.classList.add('dep-svg');depSvg.setAttribute('width',W);depSvg.setAttribute('height','1');bars.appendChild(depSvg);@@ -666,6 +702,8 @@ items.forEach((r,idx)=>{ const ln=document.createElement('div');ln.className='ms-line';ln.style.left=x+'px'; 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||''; ln.appendChild(cap);msLineEls.push(ln); r._msEl=d;r._msHit=hit;r._msLine=ln; }@@ -823,7 +861,7 @@ labelsEl.addEventListener('mousedown',e=>{ let didDrag = false; bars.addEventListener('click',e=>{ if(didDrag){didDrag=false;return;}- const msHit=e.target.closest('.ms-hit');+ const msHit=e.target.closest('.ms-hit, .ms-cap'); 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')){@@ -948,7 +986,11 @@ function updateMilestone(r){ 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';+ if(r._msLine){+ r._msLine.style.left=x+'px';+ const cap=r._msLine.querySelector('.ms-cap');+ if(cap){cap.dataset.dates=fmt(r.date);cap.dataset.title=r.name;cap.dataset.body=r.description||'';}+ } drawDeps(); } function snapshotMs(r){@@ -985,7 +1027,7 @@ bars.addEventListener('mousedown',e=>{ // 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');+ const hit=e.target.closest('.ms-hit, .ms-cap'); if(!hit)return; e.preventDefault();hideTip(); const r=msByIndex(+hit.dataset.msi);@@ -1229,7 +1271,7 @@ function syncStartBtn(){ const ps=planStart(); $('startBtn').textContent = isDayMode() ? fmt(ps) : fmt(ps.slice(0,7)); $('startBtn').classList.toggle('shifted', ps!==ORIGINAL_START);- $('unitCount').textContent = isDayMode() ? `${UNITS} days` : `${UNITS} months`;+ $('unitCount').textContent = `${UNITS} ${(GRAINS[G()]||GRAINS.month).label.toLowerCase()}`; } // Moving the project start moves the whole plan: every task and milestone shifts by@@ -1279,9 +1321,9 @@ $('btnMsStyle').addEventListener('click',()=>{ }); syncMsBtn(); -$('daysChk').checked = isDayMode();-$('daysChk').addEventListener('change',function(){- prefs.granularity = this.checked ? 'day' : 'month';+$('grainSel').value = G();+$('grainSel').addEventListener('change',function(){+ prefs.granularity = this.value; savePrefs(); rebuildTimeline(); });
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.3.2",+ "version": "0.4.0", "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.2",+ "version": "0.4.0", "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": {