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.0
6 files changed
+385−82
README.md+5−1@@ -32,7 +32,11 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Brand phase colors.** A phase with no explicit `color` follows the Adom ramp: teal, purple, blue, then a second step of each. The ramp is theme-aware, so the same plan is on-brand on screen and legible on paper. -**Settable start date.** The timeline start is computed from the data and shown in the header. Override it to extend or trim the range; reset returns it to the data.+**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.++**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+9−2@@ -113,6 +113,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | status | string | no | One of: "live", "planned", "not-started" | | description | string | no | Tooltip body text | | deps | string[] | no | Array of task IDs this depends on |+| note | string | no | Short label drawn on the bar itself, e.g. `6m` or `Y1`. Keep it to a few characters | | striped | boolean | no | Striped bar pattern (for ongoing work) | **type: "milestone"**: diamond marker, or a dashed vertical rule if the user has switched milestone style@@ -157,7 +158,10 @@ rather than a category. - **Ctrl+Z** to undo (50-level stack) - **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce) - **Auto-load** saved state on page refresh-- **Dynamic date range** computed from data, overridable with the start-date control in the header+- **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+- **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 - **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@@ -181,7 +185,10 @@ curl -s localhost:8901/changelog # JSON: {rev, count, touch Each event records the action, the item, the before and after dates, the day shift, and `via` (which gesture made it: `editor`, `drag-move`, `drag-resize`, `drag-reorder`,-`batch-editor`, `undo`).+`batch-editor`, `bar-note`, `start-picker`, `start-revert`, `undo`).++Moving the project start collapses into a single `plan.shift` event rather than one row+per task, so the log says what happened instead of burying it in 26 identical lines. **The workflow.** Note the `rev` you saw. Next time the user asks you to change the plan, ask for `?since=<that rev>` first. Anything in `touched[]` is a deliberate human decision:
gantt.html+359−77@@ -23,6 +23,7 @@ --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc; --today: #00b8b1; --on-accent: #05221f; --grid-line: rgba(48,54,61,0.18);+ --weekend: rgba(255,255,255,0.022); --row-hover: rgba(255,255,255,0.04); --phase-bg-alpha: 0.05; /* Phase ramp: teal -> purple -> blue, then a lighter step of each.@@ -38,6 +39,7 @@ --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: #00857f; --on-accent: #ffffff; --grid-line: rgba(31,35,40,0.10);+ --weekend: rgba(31,35,40,0.04); --row-hover: rgba(31,35,40,0.04); --phase-bg-alpha: 0.10; /* Deepened for paper: each clears 3:1 on white while holding its hue. */@@ -50,7 +52,7 @@ :root { --bg:#ffffff; --surface:#ffffff; --elevated:#ffffff; --overlay:#ffffff; --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); --phase-bg-alpha:0.10;+ --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; } html, body { overflow: visible !important; height: auto !important; } .labels-col, .timeline-col { overflow: visible !important; }@@ -71,14 +73,38 @@ header .subject { display: flex; align-items: center; gap: 8px; min-width: 0; fl header .subject-name { color: var(--text); font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; } header .subject-meta .sep { color: var(--text3); }-header .start-ctl { display: flex; align-items: center; gap: 6px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; }+header .start-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; } header .start-ctl label { color: var(--text2); font-size: 11px; }-header .start-ctl input[type="month"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; color-scheme: dark; }-:root[data-theme="light"] header .start-ctl input[type="month"] { color-scheme: light; }-header .start-ctl input[type="month"]:focus { border-color: var(--accent); }-header .start-ctl .link-btn { background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 10px; cursor: pointer; padding: 2px 4px; text-decoration: underline; }-header .start-ctl .link-btn:hover { color: var(--accent); }-header .start-ctl.overridden label, header .start-ctl.overridden input { color: var(--accent); border-color: var(--accent); }+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 .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); }+header .days-toggle input:checked::after { content: ''; position: absolute; left: 3.5px; top: 0.5px; width: 4px; height: 8px; border: solid var(--on-accent); border-width: 0 2px 2px 0; transform: rotate(45deg); }+header .days-toggle span { color: var(--text2); font-size: 11px; }+header .days-toggle input:checked ~ span { color: var(--accent); }++/* Custom date picker: the native <input type=month> popup is browser chrome, so it+ cannot be themed or relabelled. This replaces it. */+.datepop { position: fixed; z-index: 99999; background: var(--overlay); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,0.45); padding: 10px; display: none; font-family: 'Satoshi', sans-serif; min-width: 244px; }+.datepop.show { display: block; }+.datepop .dp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }+.datepop .dp-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--text); }+.datepop .dp-nav { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); cursor: pointer; width: 24px; height: 24px; line-height: 1; font-size: 13px; }+.datepop .dp-nav:hover { color: var(--accent); border-color: var(--accent); }+.datepop .dp-grid { display: grid; gap: 4px; }+.datepop .dp-grid.months { grid-template-columns: repeat(4, 1fr); }+.datepop .dp-grid.days { grid-template-columns: repeat(7, 1fr); }+.datepop .dp-cell { background: none; border: 1px solid transparent; border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 6px 0; cursor: pointer; text-align: center; }+.datepop .dp-cell:hover { border-color: var(--accent); color: var(--text); }+.datepop .dp-cell.sel { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; }+.datepop .dp-cell.muted { color: var(--text3); }+.datepop .dp-dow { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); padding-bottom: 2px; }+.datepop .dp-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }+.datepop .dp-link { background: none; border: none; color: var(--accent); font-family: 'Satoshi', sans-serif; font-size: 11px; cursor: pointer; padding: 2px 4px; }+.datepop .dp-link:hover { text-decoration: underline; }+.datepop .dp-link.muted { color: var(--text2); } header .actions { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0; } header .act-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; display: flex; align-items: center; gap: 4px; } header .act-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }@@ -105,6 +131,7 @@ header .act-btn svg { width: 14px; height: 14px; } .q-row, .m-row { display: flex; } .q-row { height: 22px; } .m-row { height: 24px; } .q-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border-muted); border-right: 1px solid var(--border-muted); }+.m-cell.weekend { color: var(--text3); } .m-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border); border-right: 1px solid var(--border-muted); } .label-h-q { height: 22px; border-bottom: 1px solid var(--border-muted); } .label-h-m { height: 24px; border-bottom: 1px solid var(--border); }@@ -127,6 +154,9 @@ 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; }+: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); } .bar .drag-handle { position: absolute; top: 0; width: 8px; height: 100%; cursor: ew-resize; z-index: 2; } .bar .drag-handle.left { left: -2px; border-radius: 6px 0 0 6px; }@@ -169,6 +199,10 @@ header .act-btn svg { width: 14px; height: 14px; } .editor input[type="date"], .editor input[type="number"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; width: 130px; outline: none; } .editor input:focus { border-color: var(--accent); } .editor .de-row { display: flex; gap: 10px; margin-bottom: 8px; }+.editor .de-note-row { margin-top: 10px; }+.editor .de-note-row input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; }+.editor .de-note-row input:focus { border-color: var(--accent); }+.editor .de-hint { color: var(--text2); font-size: 10px; font-family: 'Satoshi', sans-serif; } .editor .de-actions { display: flex; gap: 8px; margin-top: 10px; } .editor .de-dep-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); } .editor select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; }@@ -232,13 +266,14 @@ input[type="file"] { display: none; } <div class="undo-toast" id="undoToast">Undid change · <kbd>Ctrl+Z</kbd></div> <div class="save-indicator" id="saveInd">saved</div> <div class="rev-indicator" id="revInd">rev 0</div>+<div class="datepop" id="datePop"></div> <div class="sel-count" id="selCount"></div> <input type="file" id="importFile" accept=".json,.csv,.xml"> <script> (async function() { -const MIN_COL = 34; // below this the month labels stop being readable, so we scroll instead+const MIN_COL_MONTH = 34, MIN_COL_DAY = 16; // below these the labels stop being readable, so we scroll let COL = 66; // month column width, recomputed from the viewport in fitCOL() const $ = id => document.getElementById(id); const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip');@@ -255,30 +290,55 @@ document.title = `${project.title} — adom-gantt`; // ── View preferences (theme, milestone style, start-date override) ── const PREF_KEY = 'adom-gantt:prefs'; const prefs = Object.assign(- { theme: 'dark', msStyle: 'diamond', startOverride: null },+ { theme: 'dark', msStyle: 'diamond', granularity: 'month' }, (() => { try { return JSON.parse(localStorage.getItem(PREF_KEY)) || {}; } catch { return {}; } })() ); function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} } function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); } applyTheme(); -// ── Compute date range ──-// dataStart is what the data implies; START is what we draw from, which the user-// can push earlier or later with the start-date control in the header.-let minD = '9999-12-31', maxD = '0000-01-01';-items.forEach(r => { if (r.start && r.start < minD) minD = r.start; if (r.end && r.end > maxD) maxD = r.end; if (r.date && r.date < minD) minD = r.date; if (r.date && r.date > maxD) maxD = r.date; });-phases.forEach(p => { const ps = p.start+'-01'; const [ey,em]=p.end.split('-').map(Number); const pe=`${ey}-${String(em).padStart(2,'0')}-28`; if (ps<minD) minD=ps; if (pe>maxD) maxD=pe; });-const [minY,minM] = minD.split('-').map(Number), [maxY,maxM] = maxD.split('-').map(Number);-const dataStart = new Date(minY, minM-1, 1), dataEnd = new Date(maxY, maxM-1, 1);-let START = new Date(dataStart), MONTHS = 0;+// ── Date range ──+// The timeline is derived from the data every time, so shifting the plan reshapes+// the axis for free. ORIGINAL_START is the untouched data-file start, which is what+// Revert goes back to.+function isoDay(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function isoMonth(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; }+function parseISO(ds){ const[y,m,d]=ds.split('-').map(Number); return new Date(y,(m||1)-1,d||1); }++function scanRange(){+ let minD='9999-12-31', maxD='0000-01-01';+ items.forEach(r=>{ if(r.start&&r.start<minD)minD=r.start; if(r.end&&r.end>maxD)maxD=r.end;+ if(r.date&&r.date<minD)minD=r.date; if(r.date&&r.date>maxD)maxD=r.date; });+ phases.forEach(p=>{ const ps=p.start+'-01'; const[ey,em]=p.end.split('-').map(Number);+ const pe=`${ey}-${String(em).padStart(2,'0')}-28`;+ if(ps<minD)minD=ps; if(pe>maxD)maxD=pe; });+ return {minD,maxD};+}+// Earliest real task/milestone date, which is what the start control edits.+function planStart(){+ let m=null;+ items.forEach(r=>{ const d=r.start||r.date; if(d&&(!m||d<m))m=d; });+ return m;+}+const ORIGINAL_START = (()=>{ let m=null;+ (projData.items||[]).forEach(r=>{ const d=r.start||r.date; if(d&&(!m||d<m))m=d; });+ return m; })();++let START=new Date(), UNITS=0; // UNITS = months, or days when granularity is 'day' function computeRange(){- START = prefs.startOverride ? new Date(+prefs.startOverride.split('-')[0], +prefs.startOverride.split('-')[1]-1, 1)- : new Date(dataStart);- MONTHS = Math.max(2, (dataEnd.getFullYear()-START.getFullYear())*12 + (dataEnd.getMonth()-START.getMonth()) + 2);+ 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);+ } } computeRange();-let W = MONTHS*COL;+let W = UNITS*COL; // Fit the timeline to whatever width the pane actually has. Wide window: months // stretch so the chart fills it. Narrow window or a very long plan: months stop@@ -286,13 +346,31 @@ let W = MONTHS*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(MIN_COL, Math.floor((avail / MONTHS) * 100) / 100);- W = MONTHS * COL;+ COL = Math.max(isDayMode()?MIN_COL_DAY:MIN_COL_MONTH, Math.floor((avail / UNITS) * 100) / 100);+ W = UNITS * COL; } function mDiff(a,b){return(b.getFullYear()-a.getFullYear())*12+b.getMonth()-a.getMonth();}-function toX(ds){const[y,mo,d]=ds.split('-').map(Number);return(mDiff(START,new Date(y,mo-1,d||1))+((d?(d-1)/30:0)))*COL;}-function xToDate(x){const tm=x/COL,mo=Math.floor(tm),df=tm-mo,d=new Date(START.getFullYear(),START.getMonth()+mo,1),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')}`;}+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 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;+}+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 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')}`;+} 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')}`;} @@ -301,11 +379,12 @@ $('hdr').innerHTML = ` <div class="brand"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg> <span class="app-name">${project.appName||'adom-gantt'}</span><span class="sep">·</span><span class="app-subtitle">${project.subtitle||'Gantt chart'}</span></div> <div class="vsep"></div>- <div class="subject"><span class="subject-name">${project.title}</span><span class="subject-meta"><span class="sep">·</span><span id="monthCount">${MONTHS} months</span></span></div>- <div class="start-ctl" title="First month drawn on the timeline. Clear to follow the data.">- <label for="startInput">starts</label>- <input type="month" id="startInput" value="${isoMonth(START)}">- <button class="link-btn" id="startReset" title="Follow the data (${isoMonth(dataStart)})">reset</button>+ <div class="subject"><span class="subject-name">${project.title}</span><span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span></span></div>+ <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> </div> <div class="actions"> <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line">@@ -334,8 +413,16 @@ let depSvg; // ── Undo ── const undoStack = []; let undoTimer = null;-function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[]});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);} function snapshot(id){snapshotMany([id]);}+// One undo entry covering many rows, for bulk operations like a plan shift.+function snapshotGroup(ids){+ const entries=[];+ ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)entries.push({id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});});+ const ms=items.filter(r=>r.type==='milestone');+ undoStack.push({type:'group',entries,ms:ms.map(r=>({r,date:r.date})),phases:phases.map(p=>({p,start:p.start,end:p.end}))});+ if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+} function snapshotOrder(){undoStack.push({type:'order',order:items.map(r=>r.id||r.label||r.name)});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);} function applyOrder(order){ const byKey=new Map();items.forEach(r=>byKey.set(r.id||r.label||r.name, r));@@ -347,15 +434,22 @@ function undo(){ if(!undoStack.length)return; const sn=undoStack.pop(); if(sn.type==='order'){applyOrder(sn.order);}- else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;updateBar(sn.id);}+ 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;});+ (sn.ms||[]).forEach(m=>{m.r.date=m.date;});+ (sn.phases||[]).forEach(x=>{x.p.start=x.start;x.p.end=x.end;});+ rebuildTimeline();Object.keys(barEls).forEach(id=>updateBar(id));+ }+ else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;if(sn.note!==undefined)r.note=sn.note;updateBar(sn.id);} clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo'); } document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}if(e.key==='Escape'){clearSelection();closeEditor();closeMenus();}}); // ── Persistence ── let saveTimer=null;-function getState(){return items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[]}));}-function applyState(state){if(!Array.isArray(state))return;state.forEach(sv=>{const r=items.find(d=>d.id===sv.id);if(!r)return;if(sv.s)r.start=sv.s;if(sv.e)r.end=sv.e;if(sv.deps)r.deps=sv.deps;});}+function getState(){return items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||''}));}+function applyState(state){if(!Array.isArray(state))return;state.forEach(sv=>{const r=items.find(d=>d.id===sv.id);if(!r)return;if(sv.s)r.start=sv.s;if(sv.e)r.end=sv.e;if(sv.deps)r.deps=sv.deps;if(sv.note!==undefined)r.note=sv.note;});} // ── Changelog ── // // Rather than instrument each edit path by hand (which drifts the moment a new@@ -364,9 +458,10 @@ function applyState(state){if(!Array.isArray(state))return;state.forEach(sv=>{co // was made, for readability in the log. function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;}+function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;} function snapSemantic(){ const byId={};- items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice()};});+ 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)}; } let lastSnap=snapSemantic();@@ -379,9 +474,22 @@ function diffAndLog(via){ from:{start:a.start,end:a.end},to:{start:b.start,end:b.end}, shift:{start:daysBetween(a.start,b.start),end:daysBetween(a.end,b.end)}}); }+ if((a.note||'')!==(b.note||'')) evs.push({action:'note',id,name:nameOf(id),from:{note:a.note||''},to:{note:b.note||''}}); b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)})); a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)})); });+ // A plan shift moves every task by the same delta. Logging 26 identical rows+ // buries the one fact a reader wants, so collapse it into a single event.+ const dateEvs=evs.filter(e=>e.action==='dates');+ if(dateEvs.length>3){+ const d0=dateEvs[0].shift;+ if(dateEvs.every(e=>e.shift.start===d0.start&&e.shift.end===d0.end)){+ const kept=evs.filter(e=>e.action!=='dates');+ evs.length=0;+ evs.push({action:'plan.shift',count:dateEvs.length,shift:d0,+ from:{start:planStartOf(lastSnap)},to:{start:planStart()}},...kept);+ }+ } const before=lastSnap.order,after=cur.order; if(JSON.stringify(before)!==JSON.stringify(after)){ const moved=after.filter((v,i)=>before[i]!==v);@@ -421,27 +529,78 @@ const bars=document.createElement('div');bars.style.cssText=`position:relative;w const bgLayer=document.createElement('div');bgLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(bgLayer); const gridLayer=document.createElement('div');gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(gridLayer); -// Quarter and month header cells, rebuilt whenever START or MONTHS changes.+// 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;+ } let cm=new Date(START),cq=null;- for(let i=0;i<MONTHS;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);}+ 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<MONTHS;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 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);} } 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);- const l=mDiff(START,new Date(sy,sm-1,1))*COL;bg.style.left=l+'px';bg.style.width=((mDiff(START,new Date(ey,em-1,1))+1)*COL-l)+'px';- bg.style.background=phaseColor(i);bgLayer.appendChild(bg);phaseBgEls.push(bg);});+ 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';+ bg.style.background=phaseColor(i);bgLayer.appendChild(bg);phaseBgEls.push(bg);+ }); } function buildGridLines(){ gridLayer.innerHTML='';gridEls.length=0;- for(let i=0;i<=MONTHS;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);}+ if(isDayMode()){+ // a rule every day is noise: draw month boundaries, and shade weekends+ const cm=new Date(START);+ for(let i=0;i<=UNITS;i++){+ const d=new Date(START.getFullYear(),START.getMonth(),START.getDate()+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;`;+ gridLayer.appendChild(l);gridEls.push(l);+ } else if(d.getDay()===0||d.getDay()===6){+ const l=document.createElement('div');+ l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:${COL}px;background:var(--weekend);pointer-events:none;`;+ gridLayer.appendChild(l);gridEls.push(l);+ }+ }+ 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);} } 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);@@ -467,7 +626,9 @@ items.forEach((r,idx)=>{ ln.appendChild(cap);msLineEls.push(ln); r._msEl=d;r._msHit=hit;r._msLine=ln; }- else{lDiv.className='label-row';lDiv.dataset.id=r.id;const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${statusLabel[r.status]||r.status}</span>`:'';lDiv.innerHTML=r.name+tg;bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.id=r.id;b.dataset.idx=idx;b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}+ else{lDiv.className='label-row';lDiv.dataset.id=r.id;const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${statusLabel[r.status]||r.status}</span>`:'';lDiv.innerHTML=r.name+tg;bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.id=r.id;b.dataset.idx=idx;+ const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt;+ b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};} r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv); }); @@ -495,14 +656,10 @@ timelineEl.addEventListener('scroll',()=>{labelsEl.scrollTop=timelineEl.scrollTo // means recomputing COL and repositioning, not rebuilding (handlers stay bound). function relayout(){ fitCOL();- qRow.style.width=W+'px';- Array.from(qRow.children).forEach((c,i)=>{if(qs[i])c.style.width=(qs[i].n*COL)+'px';});- mRow.style.width=W+'px';- Array.from(mRow.children).forEach(c=>{c.style.width=COL+'px';});+ buildTimeHeaders();+ buildPhaseBands();+ buildGridLines(); bars.style.width=W+'px';- phaseBgEls.forEach((bg,i)=>{const p=phases[i];const[sy,sm]=p.start.split('-').map(Number),[ey,em]=p.end.split('-').map(Number);- const l=mDiff(START,new Date(sy,sm-1,1))*COL;bg.style.left=l+'px';bg.style.width=((mDiff(START,new Date(ey,em-1,1))+1)*COL-l)+'px';});- gridEls.forEach((l,i)=>{l.style.left=(i*COL)+'px';}); if(depSvg)depSvg.setAttribute('width',W); items.forEach(r=>{ if(r.type==='milestone'&&r._msEl){@@ -639,7 +796,7 @@ document.addEventListener('click',e=>{if(!e.target.closest('.editor')&&!e.target function openSingleEditor(id){ const r=items.find(d=>d.id===id);if(!r)return; const allTasks=items.filter(d=>d.id&&d.id!==id),deps=r.deps||[];- editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${t.id}">${t.name}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`;+ editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${(r.note||'').replace(/"/g,'"')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${t.id}">${t.name}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`; renderDepList(id); const rect=barEls[id].bar.getBoundingClientRect(); const edW=300, edH=300;@@ -650,7 +807,7 @@ function openSingleEditor(id){ if(edY<0) edY=8; editorEl.style.left=edX+'px';editorEl.style.top=edY+'px';editorEl.classList.add('show'); $('deAddDep').addEventListener('change',function(){if(this.value){snapshot(id);if(!r.deps)r.deps=[];r.deps.push(this.value);renderDepList(id);drawDeps();scheduleSave('editor');const o=this.querySelector(`option[value="${r.deps[r.deps.length-1]}"]`);if(o)o.remove();this.value='';}});- $('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;updateBar(id);closeEditor();scheduleSave('editor');});+ $('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;r.note=$('deNote').value;updateBar(id);closeEditor();scheduleSave('editor');}); $('deClose').addEventListener('click',closeEditor); } @@ -710,7 +867,7 @@ function openBatchEditor(){ $('bClose').addEventListener('click',closeEditor); } -function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;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 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');} @@ -835,6 +992,36 @@ document.addEventListener('mouseup',()=>{ } }); +// ── Bar notes ──+// Double-click the bar body to type a short label straight onto it, the way the+// NSF chart shows durations. Single-click still opens the full editor.+function editNoteInline(id){+ const r=items.find(d=>d.id===id),be=barEls[id];+ if(!r||!be)return;+ if(be.bar.querySelector('.bar-note-edit'))return;+ const inp=document.createElement('input');+ inp.className='bar-note-edit';inp.value=r.note||'';inp.maxLength=24;+ inp.addEventListener('mousedown',e=>e.stopPropagation());+ inp.addEventListener('click',e=>e.stopPropagation());+ const commit=(save)=>{+ if(!inp.parentNode)return;+ if(save&&(r.note||'')!==inp.value){snapshot(id);r.note=inp.value;updateBar(id);scheduleSave('bar-note');}+ inp.remove();+ };+ inp.addEventListener('keydown',e=>{e.stopPropagation();+ if(e.key==='Enter'){e.preventDefault();commit(true);}+ if(e.key==='Escape'){e.preventDefault();commit(false);}});+ inp.addEventListener('blur',()=>commit(true));+ be.bar.appendChild(inp);inp.focus();inp.select();+}+document.addEventListener('dblclick',e=>{+ const bar=e.target.closest('.bar');+ if(!bar||!bar.dataset.id)return;+ e.preventDefault();e.stopPropagation();+ closeEditor();+ editNoteInline(bar.dataset.id);+});+ // ── Tooltip ── let tipTimer=null; function showTip(el,e){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{const t=el.dataset.title,d=el.dataset.dates,b=el.dataset.body;if(!t)return;tipEl.innerHTML=`<div class="tip-title">${t}</div><div class="tip-dates">${d}</div>${b?'<div class="tip-body">'+b+'</div>':''}`;tipEl.classList.add('show');posTip(e);},500);}@@ -920,19 +1107,49 @@ $('btnHelp').addEventListener('click',e=>{ e.stopPropagation(); }); -// ── View controls: theme, milestone style, timeline start ──+// ── View controls: theme, milestone style, granularity, project start ── function rebuildTimeline(){ computeRange(); fitCOL(); buildTimeHeaders();buildPhaseBands();buildGridLines();- $('monthCount').textContent=`${MONTHS} months`;- const ctl=document.querySelector('.start-ctl');- if(ctl)ctl.classList.toggle('overridden',!!prefs.startOverride);- $('startInput').value=isoMonth(START);+ syncStartBtn(); relayout(); } +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`;+}++// Moving the project start moves the whole plan: every task and milestone shifts by+// the same number of days, so durations and the gaps between them are preserved.+function shiftPlanTo(newStartISO, via){+ const cur=planStart();+ if(!cur||!newStartISO||newStartISO===cur) return false;+ const delta=daysBetween(cur,newStartISO);+ if(!delta) return false;+ snapshotGroup(taskIds());+ items.forEach(r=>{+ if(r.start)r.start=addDays(r.start,delta);+ if(r.end)r.end=addDays(r.end,delta);+ if(r.date)r.date=addDays(r.date,delta);+ });+ // 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.+ phases.forEach(p=>{+ p.start=addDays(p.start+'-01',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);+ });+ rebuildTimeline();+ Object.keys(barEls).forEach(id=>updateBar(id));+ scheduleSave(via||'start-date');+ return true;+}+ function syncThemeBtn(){ $('themeLabel').textContent = prefs.theme==='dark' ? 'Light' : 'Dark'; } $('btnTheme').addEventListener('click',()=>{ prefs.theme = prefs.theme==='dark' ? 'light' : 'dark';@@ -952,25 +1169,91 @@ $('btnMsStyle').addEventListener('click',()=>{ }); syncMsBtn(); -$('startInput').addEventListener('change',function(){- const v=this.value;- if(!v){ prefs.startOverride=null; }- else if(v===isoMonth(dataStart)){ prefs.startOverride=null; }- else { prefs.startOverride=v; }+$('daysChk').checked = isDayMode();+$('daysChk').addEventListener('change',function(){+ prefs.granularity = this.checked ? 'day' : 'month'; savePrefs();- const was=isoMonth(START); rebuildTimeline();- if(was!==isoMonth(START)){- fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},- body:JSON.stringify({actor:'user',events:[{action:'timeline.start',- from:{start:was},to:{start:isoMonth(START)},via:'start-control'}]})})- .then(r=>r.json()).then(d=>{if(d&&d.rev)revEl.textContent='rev '+d.rev;}).catch(()=>{});+});++// ── Date picker ──+const datePop=$('datePop');+let dpYear=0, dpMonth=0;++function openDatePicker(){+ const ps=parseISO(planStart());+ dpYear=ps.getFullYear(); dpMonth=ps.getMonth();+ renderDatePicker();+ const r=$('startBtn').getBoundingClientRect();+ datePop.style.left=Math.min(r.left,window.innerWidth-260)+'px';+ datePop.style.top=(r.bottom+6)+'px';+ datePop.classList.add('show');+}+function closeDatePicker(){ datePop.classList.remove('show'); }++function renderDatePicker(){+ const sel=parseISO(planStart());+ const mN=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];+ let head, grid;+ if(isDayMode()){+ head=`${mN[dpMonth]} ${dpYear}`;+ const first=new Date(dpYear,dpMonth,1), lead=first.getDay();+ const nDays=new Date(dpYear,dpMonth+1,0).getDate();+ const dow=['S','M','T','W','T','F','S'].map(d=>`<div class="dp-dow">${d}</div>`).join('');+ let cells='';+ for(let i=0;i<lead;i++) cells+='<div></div>';+ for(let d=1;d<=nDays;d++){+ const isSel=sel.getFullYear()===dpYear&&sel.getMonth()===dpMonth&&sel.getDate()===d;+ cells+=`<button class="dp-cell${isSel?' sel':''}" data-day="${d}">${d}</button>`;+ }+ grid=`<div class="dp-grid days">${dow}${cells}</div>`;+ } else {+ head=`${dpYear}`;+ grid=`<div class="dp-grid months">`+mN.map((m,i)=>{+ const isSel=sel.getFullYear()===dpYear&&sel.getMonth()===i;+ return `<button class="dp-cell${isSel?' sel':''}" data-month="${i}">${m}</button>`;+ }).join('')+`</div>`; }+ const shifted=planStart()!==ORIGINAL_START;+ datePop.innerHTML=`+ <div class="dp-head">+ <button class="dp-nav" data-nav="-1">‹</button>+ <span class="dp-title">${head}</span>+ <button class="dp-nav" data-nav="1">›</button>+ </div>+ ${grid}+ <div class="dp-foot">+ <button class="dp-link${shifted?'':' muted'}" data-act="revert" ${shifted?'':'disabled'}+ title="Put the plan back to its original start (${fmt(isDayMode()?ORIGINAL_START:ORIGINAL_START.slice(0,7))})">Revert</button>+ <button class="dp-link" data-act="today">Today</button>+ </div>`;+}++datePop.addEventListener('click',e=>{+ const nav=e.target.closest('[data-nav]');+ if(nav){ const d=+nav.dataset.nav;+ if(isDayMode()){ const n=new Date(dpYear,dpMonth+d,1); dpYear=n.getFullYear(); dpMonth=n.getMonth(); }+ else dpYear+=d;+ renderDatePicker(); return; }+ const act=e.target.closest('[data-act]');+ if(act){+ if(act.dataset.act==='revert') shiftPlanTo(ORIGINAL_START,'start-revert');+ else { const t=new Date(); shiftPlanTo(isDayMode()?isoDay(t):isoDay(new Date(t.getFullYear(),t.getMonth(),1)),'start-today'); }+ closeDatePicker(); return;+ }+ const cell=e.target.closest('.dp-cell');+ if(!cell) return;+ let iso;+ if(cell.dataset.day!==undefined) iso=isoDay(new Date(dpYear,dpMonth,+cell.dataset.day));+ else iso=isoDay(new Date(dpYear,+cell.dataset.month,parseISO(planStart()).getDate()));+ shiftPlanTo(iso,'start-picker');+ closeDatePicker(); });-$('startReset').addEventListener('click',()=>{- if(!prefs.startOverride)return;- prefs.startOverride=null;savePrefs();rebuildTimeline();+$('startBtn').addEventListener('click',e=>{+ e.stopPropagation();+ datePop.classList.contains('show') ? closeDatePicker() : openDatePicker(); });+document.addEventListener('click',e=>{ if(!e.target.closest('.datepop')&&!e.target.closest('#startBtn')) closeDatePicker(); }); // Close menus on outside click document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();});@@ -996,9 +1279,8 @@ try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){ // not mistaken for a fresh round of user changes. lastSnap=snapSemantic(); try{const r=await fetch('changelog');if(r.ok){const cl=await r.json();revEl.textContent='rev '+(cl.rev||0);revEl.title=(cl.count||0)+' recorded change(s). GET /changelog?format=md for the readable log.';}}catch(e){}-// A saved start-date override has to reshape the timeline before the first paint.-if(prefs.startOverride) rebuildTimeline();-else { const ctl=document.querySelector('.start-ctl'); if(ctl)ctl.classList.remove('overridden'); }+// The sidecar may carry shifted dates, so reshape the axis before the first paint.+rebuildTimeline(); sizeTailroom(); drawDeps();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.2.0",+ "version": "0.3.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.2.0",+ "version": "0.3.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": {
server.js+10@@ -104,10 +104,20 @@ function describeEvent(e) { : `start ${ds > 0 ? '+' : ''}${ds}d, end ${de > 0 ? '+' : ''}${de}d`; return `${who} ${move} on ${name}: ${e.from.start}..${e.from.end} -> ${e.to.start}..${e.to.end}${via}`; }+ if (e.action === 'plan.shift') {+ const d = e.shift ? e.shift.start : 0;+ return `${who} moved the whole plan ${d > 0 ? '+' : ''}${d}d (${e.count} items): starts ${e.from.start} -> ${e.to.start}${via}`;+ } if (e.action === 'timeline.start') { 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 === 'note') {+ const f = e.from && e.from.note, t = e.to && e.to.note;+ if (!f) return `${who} labelled ${name} "${t}"${via}`;+ if (!t) return `${who} cleared the label on ${name} (was "${f}")${via}`;+ return `${who} relabelled ${name} "${f}" -> "${t}"${via}`;+ } if (e.action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep}"${via}`; if (e.action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep}"${via}`; if (e.action === 'reorder') return `${who} reordered rows: ${(e.moved || []).join(', ') || 'row order changed'}${via}`;