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.6.1
5 files changed
+86−15
README.md+2@@ -38,6 +38,8 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **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. +**Resizable name column.** Drag the divider between the names and the timeline, or double-click it to fit the longest name. The width persists.+ **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.
SKILL.md+3−2@@ -166,11 +166,12 @@ rather than a category. - **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. Line style is the default: the tag on the dashed rule is the handle, and the milestone's grid row is hidden since the rule already carries it. Diamond style keeps the row. Long titles wrap, and any tag that would still collide with its neighbour drops to a second row, so tags never overlap-- **Dynamic today marker** shows current date+- **Dynamic today marker** shows the current date, positioned with the same grain-aware mapping as the bars, so it is correct at every cell size+- **Resizable name column**: drag the divider, or double-click it to fit the longest name. Width persists in `localStorage` - **Light mode** for printing (dark is the default; printing always uses the light theme) - **Scroll past the last row** so the bottom of the plan can sit centred in the pane -View preferences (theme, milestone style, cell size, today marker) persist in+View preferences (theme, milestone style, cell size, today marker, name column width) persist in `localStorage` per browser. They are a view concern, so they are not written to the data file. The project start is NOT a view preference: it edits real dates.
gantt.html+79−11@@ -124,7 +124,13 @@ header .act-btn svg { width: 14px; height: 14px; } /* ── Chart ── */ .chart-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }-.labels-col { width: clamp(150px, 24vw, 272px); flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); }+.labels-col { width: var(--label-w, clamp(150px, 24vw, 272px)); flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); }+/* Drag handle sitting on the divider. Wider than the 1px border so it is grabbable,+ and it keeps its own cursor so the row underneath still behaves normally. */+.col-resizer { width: 7px; margin-left: -4px; margin-right: -3px; flex-shrink: 0; cursor: col-resize; position: relative; z-index: 30; }+.col-resizer::after { content: ''; position: absolute; inset: 0 3px; background: transparent; transition: background 0.15s; }+.col-resizer:hover::after, .col-resizer.dragging::after { background: var(--accent); }+body.col-resizing { cursor: col-resize; user-select: none; } .timeline-col { flex: 1; overflow: auto; scrollbar-width: none; } .timeline-col::-webkit-scrollbar { height: 0; width: 0; } .tailroom { flex-shrink: 0; pointer-events: none; }@@ -138,7 +144,7 @@ header .act-btn svg { width: 14px; height: 14px; } .label-h-m { height: 24px; border-bottom: 1px solid var(--border); } /* ── Rows ── */-.label-row { height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: background 0.15s, opacity 0.15s; }+.label-row { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: background 0.15s, opacity 0.15s; } .label-row:hover { background: var(--row-hover); } .label-row.group { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--text2); background: var(--surface); height: 28px; letter-spacing: 0.02em; } .label-row.ms { color: var(--text2); font-size: 11px; font-style: italic; }@@ -264,6 +270,7 @@ input[type="file"] { display: none; } <div class="legend" id="legend"></div> <div class="chart-wrap"> <div class="labels-col" id="labels"></div>+ <div class="col-resizer" id="colResizer" title="Drag to resize the name column. Double-click to fit the longest name."></div> <div class="timeline-col" id="timeline"></div> </div> </div>@@ -720,7 +727,7 @@ document.documentElement.setAttribute('data-ms', prefs.msStyle); if(prefs.showToday===false)todayL.classList.add('off'); const today=new Date();-function todayX(){return mDiff(START,today)*COL+(today.getDate()/30)*COL;}+function todayX(){return toX(isoDay(today));} function positionToday(){ const tx=todayX(), on=prefs.showToday!==false && tx>0 && tx<W; if(todayLineEl){todayLineEl.style.left=tx+'px';todayLineEl.style.display=on?'':'none';}@@ -748,7 +755,11 @@ function relayout(){ 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||'';}+ } } 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';} });@@ -777,15 +788,27 @@ function layoutMsCaps(){ if(cap){cap.style.top='2px';caps.push({cap,x:toX(r.date)});} }); caps.sort((a,b)=>a.x-b.x);- const rows=[]; // right edge occupied per row const GAP=6;+ // Occupied spans per row, not just a right edge, so a cap can sit in a gap to the+ // LEFT of something already placed. Row 0 starts pre-occupied by the today tag,+ // which shares this band and would otherwise be overlapped.+ const rows=[[]];+ if(todayTagEl&&todayTagEl.style.display!=='none'&&prefs.showToday!==false){+ const tw=todayTagEl.offsetWidth||34, tx=todayX();+ rows[0].push([tx-tw/2-GAP, tx+tw/2+GAP]);+ }+ const fits=(row,l,r)=>row.every(([a,b])=>r<a||l>b); caps.forEach(c=>{- const w=c.cap.offsetWidth||80;- const left=c.x-w/2, right=c.x+w/2;- let row=0;- while(rows[row]!==undefined && left < rows[row]+GAP) row++;- rows[row]=right;- c.cap.style.top=(2+row*(c.cap.offsetHeight+3))+'px';+ const w=c.cap.offsetWidth||80, h=c.cap.offsetHeight||14;+ const l=c.x-w/2-GAP, r=c.x+w/2+GAP;+ let n=0;+ while(true){+ if(!rows[n])rows[n]=[];+ if(fits(rows[n],l,r))break;+ n++;+ }+ rows[n].push([l,r]);+ c.cap.style.top=(2+n*(h+3))+'px'; }); } @@ -1440,6 +1463,51 @@ $('startBtn').addEventListener('click',e=>{ }); document.addEventListener('click',e=>{ if(!e.target.closest('.datepop')&&!e.target.closest('#startBtn')) closeDatePicker(); }); +// ── Resizable name column ──+// Long task names were simply cut off with no way to see them. The width persists,+// and a double-click sizes it to the longest name currently rendered.+const LABEL_MIN=120, LABEL_MAX=760;+function applyLabelWidth(w){+ if(w) document.documentElement.style.setProperty('--label-w', w+'px');+ else document.documentElement.style.removeProperty('--label-w');+ scheduleRelayout();+}+if(prefs.labelW) applyLabelWidth(prefs.labelW);++(()=>{+ const rz=$('colResizer');+ let startX=0, startW=0, dragging=false;+ rz.addEventListener('mousedown',e=>{+ e.preventDefault();+ dragging=true; startX=e.clientX; startW=labelsEl.getBoundingClientRect().width;+ rz.classList.add('dragging'); document.body.classList.add('col-resizing');+ });+ document.addEventListener('mousemove',e=>{+ if(!dragging)return;+ const w=Math.round(Math.max(LABEL_MIN,Math.min(LABEL_MAX,startW+(e.clientX-startX))));+ prefs.labelW=w; applyLabelWidth(w);+ });+ document.addEventListener('mouseup',()=>{+ if(!dragging)return;+ dragging=false; rz.classList.remove('dragging'); document.body.classList.remove('col-resizing');+ savePrefs();+ });+ // Double-click: measure the widest rendered name and fit to it.+ rz.addEventListener('dblclick',()=>{+ const probe=document.createElement('span');+ probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;font-family:Satoshi,sans-serif;font-size:12px;';+ document.body.appendChild(probe);+ let max=0;+ labelsEl.querySelectorAll('.label-row').forEach(el=>{+ probe.textContent=el.textContent;+ max=Math.max(max,probe.offsetWidth);+ });+ probe.remove();+ const w=Math.round(Math.max(LABEL_MIN,Math.min(LABEL_MAX,max+40)));+ prefs.labelW=w; applyLabelWidth(w); savePrefs();+ });+})();+ // Close menus on outside click document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();});
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.6.0",+ "version": "0.6.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.6.0",+ "version": "0.6.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": {