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.18.0: timeline drags snap to the visible grid
Dragged bars used to land wherever the cursor stopped, at meaningless fractions
of a cell, which is what made the chart look misaligned against its own grid
lines. They now land on the lines the axis is drawing, at whatever grain is
active: day boundaries at days, week starts at weeks, month, quarter and year
firsts at the coarser ones.
One rule rather than five. The grid, the axis cells and the bar geometry are
already the same function of COL, so unit i is at exactly i*COL px and at exactly
unitDate(i), and a snap is a rounded index. Rounding is what makes the preview
jump line to line as the cursor crosses each midpoint.
The preview IS the commit: geometry is redrawn from the dates, so there is no
separate preview position that could round differently from what gets saved.
Resizing the start moves the start and leaves the end exactly where it was, and
the mirror for the end. A body drag snaps the START and preserves the DURATION,
so the end lands at start plus the same number of days; snapping both ends
independently would quietly stretch every bar the user only meant to slide. With
several bars selected the one under the cursor lands on the line and the rest
move by the same number of days.
Minimum duration is the first grid line strictly past the fixed edge, so never
zero and never negative. Milestones snap the same way. The index is clamped to
the chart's range.
Nothing that sets an explicit date is affected: table cells, the item modal, the
batch editor, the project-start shift and the 0.17.0 Alt+Arrow row moves.
Verified at four grains in all three modes: every committed date lands on a
boundary with the right shape (a Sunday at weeks, day 01 at months, 01 Jan/Apr/
Jul/Oct at quarters), durations preserved, live mid-drag samples are step
functions on exact multiples of COL, the snapped edge is 0px from the nearest
drawn grid line at weeks, months and quarters and 0.163px from an axis day cell
at days, undo restores the pre-drag dates exactly, and the changelog carries the
snapped dates. The 55-check keyboard and regression sweep is unchanged and the
52-key fingerprint is still the 0.15.1 baseline apart from the version.
6 files changed
+139−20
README.md+7@@ -137,6 +137,13 @@ were already making is what tells Alt+Up and Alt+Down which row you mean. ## Interactions +Dragging a bar snaps it to the visible time grid: day boundaries at the day cell+size, week starts at weeks, month, quarter and year firsts at the coarser ones.+The preview jumps line to line as you cross each midpoint, and what you see is what+is committed. Resizing an edge moves that edge only; dragging the body moves the+start and keeps the duration. Milestones snap the same way. Dates typed into the+table, the item editor or the batch editor are taken exactly as typed.+ | Gesture | Action | |---------|--------| | Gantt / Table | Switch lens. Pinned to the left of the header so it never moves |
SKILL.md+1−1@@ -193,7 +193,7 @@ rather than a category. - **Gantt / Table toggle** pinned first in the header, left of every conditional control so its position never shifts. Two lenses over the same items: the chart answers "when", the table answers "whose, and by when". See The table view below - **Click legend** to filter phases, milestones, dependency arrows, or the today line - **Click a bar** to open the date editor with start/end pickers and dependency management-- **Drag bar edges** to resize (changes start or end date)+- **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded - **Ctrl+Z** to undo (100-level stack) - **Shift+N** opens the New panel, gated on the app being idle: no editor, panel, menu or drag in progress, and nothing focused that a person could be typing into - **Alt+Up / Alt+Down** moves one row one slot. The table moves its focused row (the row whose cell editor was touched last, marked with an accent bar on its left edge); the chart moves its selected task. In plan order the movement is free, and stepping over a group header re-homes the row into that lane, phase, bar colour and dashed rule together, exactly as a drag does. Under a column sort the movement is bounded to the contiguous run of rows the sort considers TIED with this one, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. A row alone in its sort key does not move and says so. Undo is per press; the changelog coalesces a burst into one line
docs/BUILD-NOTES.md+70@@ -3,6 +3,76 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-10 (0.18.0, bars snap to the visible grid)++Dragged bars used to land wherever the cursor stopped, which put them at+meaningless fractions of a cell and made the chart look misaligned against its own+grid lines. They now land on the lines the axis is drawing.++**One rule, not five.** The grid, the axis cells and the bar geometry are already+the same function of `COL`: unit `i` sits at exactly `i*COL` px and at exactly+`unitDate(i)`. So a snap is a rounded index and nothing more, and it follows the+active grain for free: day boundaries at the day grain, week starts at the week+grain, month, quarter and year firsts at the coarser ones. Nothing had to learn a+per-grain special case, and switching the cell-size picker changes what "snap"+means without another line of code.++**Rounding, so it jumps at the midpoint.** `Math.round(x/COL)` is what makes the+preview move from line to line as the cursor crosses halfway rather than sticking+until the boundary is reached, which is the difference between a control that feels+magnetic and one that feels stuck.++**The preview is the commit, literally.** Geometry is redrawn FROM the dates rather+than from the cursor, so `left` is `toX(start)` and the width is `toX(end)` minus+that. There is no separate "preview position" that could round differently from+what gets saved, which is the classic way a snapping implementation ends up one+pixel or one day off on release.++**Three modes, three different fixed points.** Resizing the start moves the start+and leaves the end exactly where it was; resizing the end is the mirror; a body+drag snaps the START and preserves the DURATION, so the end lands at start plus the+same number of days it always had. Snapping both ends of a body drag independently+would quietly stretch or shrink every bar the user only meant to slide, which is+worse than not snapping at all. With more than one bar selected, the bar under the+cursor is the one that lands on the line and the rest move by the same number of+days, so the arrangement the user selected survives.++**Minimum duration is the first grid line strictly past the fixed edge.** Not one+whole cell: the fixed edge is fixed, and if it sits mid-cell (because a table edit+or the data file put it there) then the shortest legal bar is the remainder. Never+zero and never negative, which is the thing that actually matters. In practice that+is a full cell when the fixed edge is on a boundary and less when it is not:+measured, dragging the end edge far past the start gives 1 day at the day grain, 2+days at the week grain, 25 at the month grain and 55 at the quarter grain, each+landing on the first line after a start of 2026-08-07.++**Milestones snap the same way**, being a single date, and the index is clamped to+the chart's own range so a drag past either end stops at the first or last line+rather than inventing dates outside the axis.++**What deliberately does NOT snap.** Anything that sets an explicit date: the table+cell editors, the item modal, the batch editor, the project-start shift, and the+Alt+Arrow moves from 0.17.0, which move rows and not dates at all. Typing+2026-09-17 into a deadline means 2026-09-17. Verified: a table deadline edit at the+month grain commits the typed day, unrounded.++**Evidence.** Driven headless at the day, week, month and quarter grains, all three+drag modes at each. Every committed date lands on a boundary of the active grain+and has the right shape for it: a Sunday at the week grain, day 01 at the month+grain, day 01 of January, April, July or October at the quarter grain. Durations+are preserved on body drags (7 days in, 7 days out, at all four grains). The live+samples taken mid-drag are step functions whose every value is an exact multiple of+`COL`, so the snapping is visual and not just a rounding on release. The bar's+snapped edge sits 0px from the nearest DRAWN grid line at the week, month and+quarter grains; at the day grain the app deliberately draws only month rules (a+rule per day is noise), and the visible day grid is the axis cells and the weekend+shading, so it was measured against those instead: the axis cells sit at `i*COL`+within 0.553px and the snapped edge is 0.163px from a day-cell edge. Undo restores+the pre-drag dates exactly in every mode at every grain, the changelog carries the+snapped dates, and the whole 55-check keyboard and regression sweep from 0.17.0+comes back identical. The 52-key fingerprint is still the 0.15.1 baseline apart+from the version string. Zero JS errors.+ ## Build notes, 2026-08-10 (0.17.0, the keyboard round) **The focused row costs no new click target.** It is the row whose cell editor you
gantt.html+58−16@@ -921,6 +921,18 @@ function xToDate(x){ 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')}`; }+// ── Snapping to the visible grid ──+//+// A dragged bar lands on the lines the axis is actually drawing: day boundaries at+// the day grain, week starts at the week grain, month, quarter and year firsts at+// the coarser ones. That is one rule rather than five, because the grid, the axis+// cells and the bar geometry are all the same function of COL: unit i sits at+// exactly i*COL px and at exactly unitDate(i), so a snap is a rounded index and+// nothing else. Rounding is what makes the preview jump as the cursor crosses each+// midpoint rather than at the boundary itself.+function snapIndex(x){ return Math.max(0,Math.min(UNITS,Math.round(x/COL))); }+function snapDateAt(i){ return isoDay(unitDate(i)); }+function snapX(x){ const i=snapIndex(x); return {i, x:i*COL, date:snapDateAt(i)}; } // The calendar date at the left edge of unit i, and the two header labels for it. function unitDate(i){ const g=G();@@ -2293,7 +2305,7 @@ bars.addEventListener('mousedown',e=>{ const side=handle.classList.contains('left')?'left':'right'; snapshot(id); if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}- dragState={id,side,startX:e.clientX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds:[id],originals:{[id]:{left:parseFloat(bar.style.left),width:parseFloat(bar.style.width)}}};+ dragState={id,side,startX:e.clientX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds:[id],originals:{[id]:{left:parseFloat(bar.style.left),width:parseFloat(bar.style.width),start:r.start,end:r.end}}}; document.body.style.cursor='ew-resize'; return; }@@ -2330,7 +2342,10 @@ document.addEventListener('mousemove',e=>{ const moveIds=(selected.has(id)&&selected.size>1)?[...selected]:[id]; snapshotMany(moveIds); const originals={};- moveIds.forEach(mid=>{const be=barEls[mid];if(be)originals[mid]={left:parseFloat(be.bar.style.left),width:parseFloat(be.bar.style.width)};});+ // The dates go into the snapshot as well as the pixels: a body move keeps+ // its duration, and duration is a number of days, not a number of pixels.+ moveIds.forEach(mid=>{const be=barEls[mid],ri=items.find(d=>d.id===mid);+ if(be&&ri)originals[mid]={left:parseFloat(be.bar.style.left),width:parseFloat(be.bar.style.width),start:ri.start,end:ri.end};}); dragState={id,side:'move',startX:gesture.startX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds,originals}; if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();} document.body.style.cursor='grabbing';@@ -2352,25 +2367,52 @@ document.addEventListener('mousemove',e=>{ 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);+ // A milestone is a single date, so it lands on a grid line like any edge.+ const s=snapX(dragState.origLeft+dx);+ if(dragState.r.date!==s.date){ dragState.r.date=s.date; updateMilestone(dragState.r); } return; }- const{side,origLeft,origWidth,bar,r,moveIds,originals}=dragState;+ const{id,side,origLeft,origWidth,bar,r,moveIds,originals}=dragState;+ // Geometry is redrawn FROM the dates rather than from the cursor, so what is+ // on screen mid-drag is exactly what will be committed, down to the pixel.+ const paint=(be,ri)=>{+ const x1=toX(ri.start),x2=toX(ri.end);+ be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';+ be.bar.dataset.dates=fmt(ri.start)+' → '+fmt(ri.end);+ }; if(side==='move'){- moveIds.forEach(mid=>{- const be=barEls[mid],orig=originals[mid],ri=items.find(d=>d.id===mid);- if(!be||!orig||!ri)return;- const nl=Math.max(0,orig.left+dx);- be.bar.style.left=nl+'px';ri.start=xToDate(nl);ri.end=xToDate(nl+orig.width);- be.bar.dataset.dates=fmt(ri.start)+' → '+fmt(ri.end);- });+ // The start snaps; the DURATION is preserved, so the end lands at start plus+ // the same number of days it always had. Snapping both ends independently+ // would quietly stretch or shrink every bar the user only meant to slide.+ const base=originals[id];+ if(base){+ const s=snapX(origLeft+dx);+ const shift=dayDiff(parseISO(base.start),parseISO(s.date));+ // With more than one bar selected, the bar under the cursor is the one+ // that lands on the line and the rest move by the same number of days, so+ // the arrangement the user selected survives the move.+ moveIds.forEach(mid=>{+ const be=barEls[mid],orig=originals[mid],ri=items.find(d=>d.id===mid);+ if(!be||!orig||!ri)return;+ const ns=addDays(orig.start,shift), ne=addDays(orig.end,shift);+ if(ri.start===ns&&ri.end===ne)return;+ ri.start=ns;ri.end=ne;paint(be,ri);+ });+ } } else if(side==='left'){- const nl=Math.max(0,origLeft+dx),nw=origWidth-(nl-origLeft);- if(nw>=6){bar.style.left=nl+'px';bar.style.width=nw+'px';r.start=xToDate(nl);bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);}+ // The end is fixed. Step back off any boundary that would put the start on+ // or past it: one cell of the current grain is the shortest bar the visible+ // grid can express, so that is the minimum. Drop to a finer grain for less.+ let i=snapIndex(origLeft+dx);+ while(i>0&&snapDateAt(i)>=r.end)i--;+ const d=snapDateAt(i);+ if(d<r.end&&r.start!==d){ r.start=d; paint(barEls[id]||{bar},r); } } else {- const nw=Math.max(6,origWidth+dx);bar.style.width=nw+'px';r.end=xToDate(origLeft+nw);bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);+ // The start is fixed, same rule from the other side.+ let i=snapIndex(origLeft+origWidth+dx);+ while(i<UNITS&&snapDateAt(i)<=r.start)i++;+ const d=snapDateAt(i);+ if(d>r.start&&r.end!==d){ r.end=d; paint(barEls[id]||{bar},r); } } drawDeps(); }
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.17.0",+ "version": "0.18.0", "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+2−2@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.17.0",+ "version": "0.18.0", "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": {@@ -63,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}