← Commit history

Publish 0.3.2

Drew Owens ·07ac1ce5a9 · ·parent acc3fea
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,'&quot;')}"></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,'&quot;')}"></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}`;