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.62.0
7 files changed
+229−35
README.md+1−1@@ -46,7 +46,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Esc gets you out of a field.** Every text box in the app hands the keyboard back when you press Escape: the table's filter box, the panel fields, the section editors, the type-in on the owner list, the date fields. The text you typed stays exactly where it is, so a filter you built is never wiped by a stray key, and the shortcuts are live again immediately rather than after a trip to the mouse. One Escape is one action: it never blurs a field AND closes something behind it in the same press. -**Owner is editable from the chart too.** Right of the status pill, every task row carries an owner pill, showing who has it or a quiet "unassigned" when nobody does. Click it for a list of everyone already on the plan, plus a field for a name that is not on it yet; a new name is offered to every other row the moment it commits. Same path as the table's Owner cell, so it is one undo, one changelog event, and both lenses agree. The list scrolls when a plan has more people than it can show, by the wheel, the trackpad or the scrollbar; Escape closes it, so does a click anywhere outside it, and so does scrolling the plan out from under it.+**Owner is editable from the chart too.** Right of the status pill, every task row carries an owner pill, showing who has it, or NO OWNER in dim capitals when nobody does (the Table and a section with no owner on Setup say the same). Click it for a list of everyone already on the plan, plus a field for a name that is not on it yet; a new name is offered to every other row the moment it commits. Same path as the table's Owner cell, so it is one undo, one changelog event, and both lenses agree. The list scrolls when a plan has more people than it can show, by the wheel, the trackpad or the scrollbar; Escape closes it, so does a click anywhere outside it, and so does scrolling the plan out from under it. **Rename a row in place.** Click the name text in the label column to edit it. Enter commits the new name, closes the field and leaves the row selected, so the rename hands straight over to the keyboard. Escape cancels.
SKILL.md+5−3@@ -187,7 +187,7 @@ items. | name | string | yes | Milestone label | | date | YYYY-MM-DD | yes | Milestone date, and the Deadline column in the table | | phase | number | yes | Index into phases[] |-| owner | string | no | Usually absent. The table shows "unassigned" and lets you set one |+| owner | string | no | Usually absent. The table shows NO OWNER and lets you set one | | status | string | no | Usually absent. Same values as a task | | description | string | no | Tooltip body text, and the Notes column in the table | | tags | string[] | no | Same as a task's |@@ -298,7 +298,7 @@ named in its own column. |--------|------|-----------| | Task | `name` | `name`, marked with a diamond | | Phase | `phases[phase].name`, with the phase colour | same |-| Owner | `owner`, or "unassigned" | usually "unassigned" |+| Owner | `owner`, or NO OWNER in dim capitals (click to set one) | usually NO OWNER | | Start | `start` | blank, a milestone is a point | | Deadline | `end` | `date` | | Status | `status`, or "none" | `status`, or "none" |@@ -434,7 +434,7 @@ drag. A mouse drags from anywhere on the row, a finger from the grip. With a row are gone. The **name and the aside are edited by clicking them**: the text becomes a box in the same spot, selected; Enter or clicking away commits, Escape cancels, and a name another section has (any case) or an empty one is refused with a message and the box stays-open. An empty aside shows a quiet "add an aside" on hover. The Edit button and its form+open. A section with no owner or aside reads NO OWNER in dim capitals, at rest (0.62.0), and a click on it opens the field. The Edit button and its form are gone; a name and an aside are now one Ctrl+Z each. It replaced the **Sections** button that sat in the header through 0.57.1 and opened the@@ -474,6 +474,8 @@ every section, still as one Ctrl+Z and one changelog batch (a `tags` line per ro open row editor on the page (a name or aside being edited, Remove, a palette, a tag rename) wherever the focus is. +**Tag order (0.62.0).** Drag a row of the Tags card by its grip to move it, or press Alt+Up / Alt+Down on the focused row; one undo per move. The order is the order of `project.tags`, saved with the plan, and the legend chips, the suggestions and the Analytics per-tag card follow it. Any edit to the registry first lists every tag the plan has, in the order shown, so colouring or creating one tag never moves the others; a tag typed onto a section later follows the listed ones. The up and down arrows are gone from both cards.+ **New tags and tag colours (0.61.0).** Tags are first-class now, in a registry, `project.tags`. The Tags card has a **New tag** box (Enter or Add tag): the tag is made with nothing carrying it, listed as "0 sections", and offered as a suggestion in every tag
docs/BUILD-NOTES.md+39@@ -3,6 +3,45 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-02 (0.62.0, NO OWNER, and tags you can drag)++Two asks from Drew, looking at the Setup page.++**NO OWNER.** Where nobody owns a thing, the app now says so in dim capitals, at rest, and+the words are the place to click to fix it. On the chart a task with no owner had an italic+"unassigned" chip; it reads NO OWNER and still opens the owner list. The Table's Owner cell+says the same. On Setup a section with no owner or aside showed nothing until the row was+hovered, then an "add an aside" placeholder; it reads NO OWNER all the time now, and a click+on it opens the field. The owner list's empty choice is "no owner". Analytics keeps its+"unassigned" bucket: that is a row in a table of people, not a prompt.++**Tags are dragged into order.** The Tags card's rows have the same grip and the same+drag as the section rows (a press that moves more than a few pixels is a drag, one that+does not is still a click on the name to rename it), and Alt+Up and Alt+Down move the+focused row. One gesture, one undo. The order is the order of the tag registry+(`project.tags`), so it is saved with the plan, and the legend chips, the suggestions and+the Analytics card follow it, because they all read `planTags()`.++That needed the registry to be able to say where EVERY tag sits, and through 0.61.0 it+listed only the tags that had a colour or that nothing carried. So any registry edit now+first lists every tag the plan has, in the order shown (`regMaterialize`). Doing it on+every edit and not only on a drag is what stops the first colour you give a tag from+shuffling it to the front. A tag the registry does not list yet (typed onto a section+later) follows the listed ones, in the order it first appears.++Two consequences, both handled. The changelog's registry diff treats an entry for a tag+the plan already carries, with no colour, as bookkeeping and logs nothing for it coming or+going; a carried tag's entry going while it held a colour is logged as the colour being+cleared. And an export of a plan whose registry has been edited lists every tag in+`project.tags`, where 0.61.0 listed a few. A plan that never touched the registry still+exports no key.++Tests: the `tags` flow drags the last tag row to the top with real mouse events and reads+the order back from the page, `/export/json`, the legend, after a reload and after an+undo, and checks Alt+Down, that a click on a name is still a rename, and NO OWNER on the+chart, in the Table and on Setup (visible without hovering, and clickable). Lean gate for+this release: the targeted flows only.+ ## Build notes, 2026-10-01 (0.61.0, tag colours, New tag, drag to reorder, click to edit) Three asks from Drew on the Setup page: make tags on the fly, give them colours, and lose
gantt.html+114−21@@ -500,7 +500,9 @@ body.col-resizing { cursor: col-resize; user-select: none; } with nobody on it is a thing worth seeing and worth being able to click. */ .label-row .owner-pill { cursor: pointer; background: rgba(139,148,158,0.10); color: var(--text2); max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .label-row .owner-pill:hover { box-shadow: inset 0 0 0 1px currentColor; filter: brightness(1.12); }-.label-row .owner-pill.none { font-style: italic; opacity: 0.62; }+/* "NO OWNER" (0.62.0): dim capitals where the name would be, since a row nobody owns is+ the state most worth seeing, and the chip is the place to click to fix it. */+.label-row .owner-pill.none { font-style: normal; opacity: 0.55; letter-spacing: 0.04em; font-size: 0.86em; } /* The name text is its own hit target now: only the glyphs open the editor, and the empty space after them selects the row. The margin cancels the padding so the box hugs the text without nudging the row sideways; the hover outline is the affordance@@ -1258,13 +1260,13 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .sec-grip { width: 12px; height: 15px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--text3); opacity: 0.6; touch-action: none; } .sec-row:hover .sec-grip, .sec-row:focus-visible .sec-grip { opacity: 1; color: var(--text2); } .sec-list.sec-drag-on, .sec-list.sec-drag-on * { cursor: grabbing !important; user-select: none; }-.sec-row.sec-dragging { opacity: 0.4; }+.sec-row.sec-dragging, .tag-row.sec-dragging { opacity: 0.4; } .sec-drop { position: absolute; left: 6px; right: 6px; height: 2px; margin-top: -1px; background: var(--accent); border-radius: 2px; pointer-events: none; z-index: 2; } .sec-drop::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } .sec-row + .sec-row { border-top-color: rgba(48,54,61,0.15); border-top-left-radius: 0; border-top-right-radius: 0; } .sec-row:hover { background: var(--row-hover); } .sec-row.sec-editing { border-color: var(--border); background: var(--elevated); border-radius: 7px; }-.sec-row.sec-flash { animation: secFlash 1.4s ease-out; }+.sec-row.sec-flash, .tag-row.sec-flash { animation: secFlash 1.4s ease-out; } @keyframes secFlash { from { background: rgba(0,184,177,0.18); } to { background: transparent; } } .sec-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; } .sec-name { flex: 1 1 200px; min-width: 0; font-size: 12.5px; color: var(--text); overflow: hidden; white-space: nowrap; display: flex; align-items: baseline; gap: 4px; }@@ -1275,9 +1277,10 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .sec-ld-t { color: var(--text2); font-size: 0.86em; flex-shrink: 2; } .sec-nm-t:hover, .sec-ld-t:hover { border-color: var(--border); background: var(--surface); } .sec-nm-t:focus-visible, .sec-ld-t:focus-visible { outline: none; border-color: var(--accent); }-.sec-ld-t.empty { color: var(--text3); font-style: italic; opacity: 0; }+/* A section with no owner or aside says so, always, in dim capitals (0.62.0). It was a+ placeholder that only showed on hover, and a missing owner is worth seeing at rest. */+.sec-ld-t.empty { color: var(--text3); font-style: normal; letter-spacing: 0.04em; opacity: 0.75; } .sec-row:hover .sec-ld-t.empty, .sec-row:focus-within .sec-ld-t.empty, .sec-row:focus .sec-ld-t.empty { opacity: 1; }-@media (hover: none) { .sec-ld-t.empty { opacity: 0.55; } } .setup-view .sec-name input[type="text"] { flex: 1 1 140px; min-width: 0; font-size: 12px; padding: 1px 6px; } .setup-view .sec-name input.sec-nm { margin-left: -5px; } .setup-view input[type="text"].bad { border-color: #f85149; }@@ -1345,7 +1348,10 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .tag-prefix { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 10px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 7px; font-size: 11.5px; color: var(--text2); } .tag-prefix .tp-say { flex: 1 1 240px; min-width: 0; } .tag-prefix .de-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-.tag-list { padding: 2px 0 0; }+.tag-list { padding: 2px 0 0; position: relative; }+.tag-list.sec-drag-on, .tag-list.sec-drag-on * { cursor: grabbing !important; user-select: none; }+.tag-row:hover .sec-grip, .tag-row:focus-visible .sec-grip { opacity: 1; color: var(--text2); }+.tag-row:focus-visible { outline: none; border-color: var(--accent); } .tag-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; padding: 5px 8px; border-radius: 7px; border: 1px solid transparent; } .tag-row + .tag-row { border-top-color: rgba(48,54,61,0.15); border-top-left-radius: 0; border-top-right-radius: 0; } .tag-row:hover { background: var(--row-hover); }@@ -4161,7 +4167,13 @@ function diffAndLog(via){ // only to hold a colour for a tag the plan already carries is a colour, not a new tag. const curR=cur.reg||{},oldR=lastSnap.reg||{}; const carried=new Set(usedTags().map(e=>e.key));- Object.keys(oldR).forEach(k=>{ if(!curR[k])evs.push({action:'tag.delete',tag:oldR[k].name,from:{color:oldR[k].color}}); });+ // Since 0.62.0 any registry edit lists EVERY tag (regMaterialize), so that the order can+ // be dragged. An entry for a tag the plan carries, with no colour, is bookkeeping: it+ // coming or going is not something anybody did, and it is not logged. A carried tag's+ // entry going while it held a colour is the colour being cleared.+ Object.keys(oldR).forEach(k=>{ if(curR[k])return; const o=oldR[k];+ if(!carried.has(tagKey(o.name)))evs.push({action:'tag.delete',tag:o.name,from:{color:o.color}});+ else if(o.color)evs.push({action:'tag.color',tag:o.name,from:{color:o.color},to:{color:''}}); }); Object.keys(curR).forEach(k=>{ const a=oldR[k],b=curR[k]; if(!a){@@ -4564,10 +4576,20 @@ function planTags(){ // made with "New tag" is listed and suggested before anything wears it. `used` is what the // legend, the filter and the Analytics card go by: an unused tag would filter to nothing. tagReg.forEach(r=>{ const k=tagKey(r.name); if(!m.has(k))m.set(k,{tag:r.name,key:k,n:0,idx:[],rows:0,items:0}); });- const out=[...m.values()];+ // ORDER (0.62.0). A tag the registry lists sits where the registry puts it, which is+ // where the reader dragged it on Setup; tags it does not list follow, in the order they+ // first appear. Any edit to the registry first lists every tag in the order shown+ // (regMaterialize), so making or colouring one tag never shuffles the rest.+ const at=new Map(); tagReg.forEach((r,i)=>{ const k=tagKey(r.name); if(!at.has(k))at.set(k,i); });+ const out=[...m.values()].map((e,i)=>({e,i})).sort((a,b)=>{+ const x=at.has(a.e.key)?at.get(a.e.key):Infinity, y=at.has(b.e.key)?at.get(b.e.key):Infinity;+ return x!==y?x-y:a.i-b.i;+ }).map(o=>o.e); out.forEach(e=>{ e.color=tagColor(e.key); e.used=!!(e.n||e.rows); }); return out; }+// Every tag the plan has, written into the registry in the order they are shown now.+function regMaterialize(){ planTags().forEach(e=>{ if(!regFind(e.tag))tagReg.push(regEntry({name:e.tag})); }); } function usedTags(){ return planTags().filter(e=>e.used); } // "2 sections, 3 rows", leaving out a zero, for the Setup card and the chip titles. function tagCountText(e){@@ -4884,7 +4906,7 @@ function nameInner(r){ // with nobody on it still gets a chip, quieter, because "unassigned" is the state // most worth seeing at a glance and it has to be clickable to be fixable. const ow=(r.owner||'').trim();- const op=`<span class="tag owner-pill${ow?'':' none'}" title="Click to change owner">${esc(ow||'unassigned')}</span>`;+ const op=`<span class="tag owner-pill${ow?'':' none'}" title="${ow?'Click to change owner':'Nobody owns this yet. Click to set an owner'}">${esc(ow||'NO OWNER')}</span>`; return nt+tg+op+rowTagsHtml(r); } @@ -7060,7 +7082,7 @@ function renderSections(keep){ row.innerHTML=`<span class="sec-grip" aria-hidden="true"><svg viewBox="0 0 8 14" width="8" height="14"><g fill="currentColor"><circle cx="2" cy="2" r="1.2"/><circle cx="6" cy="2" r="1.2"/><circle cx="2" cy="7" r="1.2"/><circle cx="6" cy="7" r="1.2"/><circle cx="2" cy="12" r="1.2"/><circle cx="6" cy="12" r="1.2"/></g></svg></span>`+ `<button class="sec-sw" title="Colour" style="background:${esc(phaseColor(i))}"></button>`+ `<span class="sec-name"><span class="sec-nm-t" role="button" tabindex="0" data-f="name" title="${esc(p.name)}. Click to rename">${esc(p.name)}</span>`+- `<span class="sec-ld-t${p.lead?'':' empty'}" role="button" tabindex="0" data-f="lead" title="${p.lead?esc(p.lead)+'. Click to edit the aside':'Click to add an aside beside the name'}">${p.lead?esc(p.lead):'add an aside'}</span></span>`++ `<span class="sec-ld-t${p.lead?'':' empty'}" role="button" tabindex="0" data-f="lead" title="${p.lead?esc(p.lead)+'. Click to edit the owner or aside':'No owner or aside yet. Click to add one'}">${p.lead?esc(p.lead):'NO OWNER'}</span></span>`+ `<span class="sec-count">${n} row${n===1?'':'s'}</span>`+ (sp.from?`<span class="sec-dates">${esc(fmt(sp.from))}${sp.to!==sp.from?' to '+esc(fmt(sp.to)):''}</span>` :`<span class="sec-dates none">no dates</span>`)+@@ -7189,9 +7211,11 @@ function removeTagEverywhere(tag){ // The registry's own edits (0.61.0): a new tag, and a colour. One gesture, one undo entry // ('tag.reg', the registry as it was), one save; the changelog lines come from the diff. function editReg(fn,via){- const before=regSnap(), sig=regSig();+ const before=regSnap();+ regMaterialize();+ const sig=regSig(); fn();- if(regSig()===sig)return false;+ if(regSig()===sig){ tagReg=before; return false; } regInSidecar=true; pushUndo({type:'tag.reg',reg:before}); afterTagsChange();@@ -7313,17 +7337,85 @@ function openTagRename(k){ } // A click on a tag's name opens its rename box, from the pointerdown's record, so a blur // commit in another box repainting the card between press and release cannot eat it.+// The order of the tags, which is the order of the registry (0.62.0). One gesture, one+// undo; the legend, the suggestions and the Analytics card follow, since they all read+// planTags().+function reorderTag(key,to){+ return editReg(()=>{+ const i=tagReg.findIndex(e=>tagKey(e.name)===key); if(i<0)return;+ to=Math.max(0,Math.min(tagReg.length-1,to)); if(to===i)return;+ const [e]=tagReg.splice(i,1); tagReg.splice(to,0,e);+ });+}+// A press on a tag's name opens its rename box; a press anywhere else on the row that is+// not a control, moved more than a few pixels, DRAGS the row, exactly as a section row is+// dragged (0.62.0). Alt+Up and Alt+Down move the focused row. function wireTagList(list){- let press=null;+ let press=null, drop=null;+ const MOVE=5;+ const rows=()=>[...list.querySelectorAll('.tag-row')];+ const stopDrag=()=>{+ if(drop){ drop.remove(); drop=null; }+ list.classList.remove('sec-drag-on');+ list.querySelectorAll('.sec-dragging').forEach(r=>r.classList.remove('sec-dragging'));+ };+ const place=y=>{+ const rs=rows(); let slot=rs.length;+ for(let j=0;j<rs.length;j++){ const b=rs[j].getBoundingClientRect(); if(y<b.top+b.height/2){ slot=j; break; } }+ const to=slot>press.from?slot-1:slot;+ press.to=to;+ if(to===press.from){ drop.style.display='none'; return; }+ drop.style.display='';+ const lr=list.getBoundingClientRect();+ const edge=slot<rs.length?rs[slot].getBoundingClientRect().top:rs[rs.length-1].getBoundingClientRect().bottom;+ drop.style.top=(edge-lr.top-1)+'px';+ const sc=$('suScroll'); if(sc){ const sr=sc.getBoundingClientRect();+ if(y<sr.top+36)sc.scrollTop-=14; else if(y>sr.bottom-36)sc.scrollTop+=14; }+ };+ const focusRow=k=>{ const r=rows().find(x=>x.dataset.tag===k); if(r){ r.classList.add('sec-flash'); try{ r.focus({preventScroll:true}); }catch(_){ r.focus(); } } }; list.addEventListener('pointerdown',e=>{ if(e.button!==0)return;- const t=e.target.closest('.tag-nm-t'), row=t&&t.closest('.tag-row');- press=row?{k:row.dataset.tag,x:e.clientX,y:e.clientY,id:e.pointerId}:null;+ const row=e.target.closest('.tag-row'); if(!row||row.parentElement!==list){ press=null; return; }+ if(e.target.closest('.tag-nm-t')){ press={kind:'name',k:row.dataset.tag,x:e.clientX,y:e.clientY,id:e.pointerId}; return; }+ if(e.target.closest('button,input,select,textarea,a,.tag-pal,.sec-msg')){ press=null; return; }+ if(e.pointerType!=='mouse'&&!e.target.closest('.sec-grip')){ press=null; return; }+ const from=rows().indexOf(row);+ press={kind:'drag',k:row.dataset.tag,from,to:from,x:e.clientX,y:e.clientY,id:e.pointerId,on:false,row}; });- document.addEventListener('pointerup',e=>{+ document.addEventListener('pointermove',e=>{+ if(!press||press.kind!=='drag'||e.pointerId!==press.id)return;+ if(!press.on){+ if(Math.hypot(e.clientX-press.x,e.clientY-press.y)<MOVE)return;+ if(!press.row.isConnected){ press=null; return; }+ press.on=true;+ try{ press.row.setPointerCapture(e.pointerId); }catch(_){}+ try{ window.getSelection().removeAllRanges(); }catch(_){}+ list.classList.add('sec-drag-on'); press.row.classList.add('sec-dragging');+ drop=document.createElement('div'); drop.className='sec-drop'; drop.setAttribute('aria-hidden','true'); list.appendChild(drop);+ }+ e.preventDefault();+ place(e.clientY);+ });+ const end=(e,commit)=>{ if(!press||e.pointerId!==press.id)return; const p=press; press=null;- if(Math.hypot(e.clientX-p.x,e.clientY-p.y)<5)openTagRename(p.k);+ if(p.kind==='name'){ if(commit&&Math.hypot(e.clientX-p.x,e.clientY-p.y)<MOVE)openTagRename(p.k); return; }+ if(!p.on)return;+ stopDrag();+ if(commit&&p.to!==p.from&&reorderTag(p.k,p.to)){ renderTagsCard(); focusRow(p.k); }+ };+ document.addEventListener('pointerup',e=>end(e,true));+ document.addEventListener('pointercancel',e=>end(e,false));+ list.addEventListener('click',e=>{ if(list.classList.contains('sec-drag-on'))e.stopPropagation(); },true);+ list.addEventListener('keydown',e=>{+ const row=e.target.closest&&e.target.closest('.tag-row'); if(!row||row.parentElement!==list)return;+ if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')&&!/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)){+ e.preventDefault(); e.stopPropagation();+ const i=rows().indexOf(row), to=e.key==='ArrowUp'?i-1:i+1;+ if(to<0||to>=rows().length)return;+ const k=row.dataset.tag;+ if(reorderTag(k,to)){ renderTagsCard(); focusRow(k); }+ } }); } function renderTagsCard(){@@ -7350,11 +7442,12 @@ function renderTagsCard(){ if(tagPalOpen&&!tags.some(e=>e.key===tagPalOpen))tagPalOpen=null; list.innerHTML=tags.length?'':`<div class="tag-empty">No tags yet. Make one with New tag above, type one into the box under any section below, or give a row tags in its editor or the table's Tags column${cand.length?', or tag them all from their name prefixes with the button above':''}.</div>`; tags.forEach(e=>{- const row=document.createElement('div'); row.className='tag-row'; row.dataset.tag=e.key; row.setAttribute('role','listitem');+ const row=document.createElement('div'); row.className='tag-row'; row.dataset.tag=e.key; row.setAttribute('role','listitem'); row.tabIndex=0; const secs=e.idx.map(i=>phases[i].name); const rowNames=e.rows?items.filter(r=>!isGroup(r)&&ownTags(r).some(t=>tagKey(t)===e.key)).map(r=>r.name||''):[]; const tip=[secs.length?'Sections: '+secs.join(', '):'',rowNames.length?'Rows: '+rowNames.slice(0,12).join(', ')+(rowNames.length>12?', ...':''):''].filter(Boolean).join('\n')||'Nothing carries this tag yet. Add it to a section below, or to a row in its editor or the Table.';- row.innerHTML=`<button type="button" class="tag-sw${e.color?'':' none'}" title="${e.color?'Colour '+e.color+'. Click to change it':'No colour. Click to give this tag one'}" aria-label="Colour of the tag ${esc(e.tag)}"${e.color?` style="background:${e.color}"`:''}></button>`++ row.innerHTML=`<span class="sec-grip" aria-hidden="true" title="Drag to move this tag, or Alt+Up / Alt+Down while the row has the focus"><svg viewBox="0 0 8 14" width="8" height="14"><g fill="currentColor"><circle cx="2" cy="2" r="1.2"/><circle cx="6" cy="2" r="1.2"/><circle cx="2" cy="7" r="1.2"/><circle cx="6" cy="7" r="1.2"/><circle cx="2" cy="12" r="1.2"/><circle cx="6" cy="12" r="1.2"/></g></svg></span>`++ `<button type="button" class="tag-sw${e.color?'':' none'}" title="${e.color?'Colour '+e.color+'. Click to change it':'No colour. Click to give this tag one'}" aria-label="Colour of the tag ${esc(e.tag)}"${e.color?` style="background:${e.color}"`:''}></button>`+ `<span class="tag-name"><span class="sec-tag tag-nm-t" role="button" tabindex="0" title="Click to rename ${esc(e.tag)} on every section and row that carries it"${tagAttrs(e.tag)}>${esc(e.tag)}</span></span>`+ `<span class="tag-n${e.used?'':' unused'}" title="${esc(tip)}">${esc(tagCountText(e))}</span>`+ `<span class="tag-acts"><button type="button" class="tag-btn danger" data-a="del" title="${e.used?'Remove this tag from '+esc(tagCountText(e))+'. One Ctrl+Z puts it back.':'Remove this tag from the plan. One Ctrl+Z puts it back.'}">Remove</button></span>`;@@ -10424,7 +10517,7 @@ function renderTable(){ +`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">` +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>` +`<td class="c-phase${ed}" data-field="phase"${phaseTagsOf(r).length?` title="Tags: ${esc(phaseTagsOf(r).join(', '))}"`:''}>${dot}${esc(phaseNameOf(r))}</td>`- +`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'unassigned'}</td>`+ +`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'NO OWNER'}</td>` +(ms?'<td class="c-date empty"></td>' :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(fmt(sd)):'not set'}</td>`) +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(fmt(dl)):'not set'}</td>`@@ -11035,7 +11128,7 @@ function openOwnerPill(r,pillEl){ const el=document.createElement('div'); el.className='cell-list owner-list'; el.innerHTML=- `<div class="cl-opt${cur?'':' on'}" data-value="">unassigned</div>`+ `<div class="cl-opt${cur?'':' on'}" data-value="">no owner</div>` +owners.map(o=>`<div class="cl-opt${o===cur?' on':''}" data-value="${esc(o)}">${esc(o)}</div>`).join('') +`<div class="cl-new"><input type="text" class="cl-new-inp" placeholder="new name" ` +`aria-label="Assign someone not on this list" maxlength="60"></div>`;
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.61.0",+ "version": "0.62.0", "description": "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, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. 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.61.0",+ "version": "0.62.0", "description": "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, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. 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": {
test/ui_test.mjs+68−8@@ -1,5 +1,5 @@ #!/usr/bin/env node-// adom-gantt browser suite (0.57.1). Real Chrome, real server, seeded temp data.+// adom-gantt browser suite. Real Chrome, real server, seeded temp data. // // node test/ui_test.mjs everything: flows at 1440, viewport matrix, coverage gate, leak watch // node test/ui_test.mjs --keep also leave screenshots and the temp dir (test/out/ + printed path)@@ -1826,8 +1826,9 @@ async function sectionsFlow(browser) { N = N.map(n => n === 'Gamma' ? 'Gamma renamed' : n); await page.hover('#suSections h3'); await page.evaluate(() => document.activeElement && document.activeElement.blur()); await settle(page, 80); const aside = await page.$eval(`${G} .sec-ld-t`, e => ({ empty: e.classList.contains('empty'), t: e.textContent, o: getComputedStyle(e).opacity }));- // Hidden (opacity 0) where the pointer can hover; faint (0.55) where it cannot, so a finger can find it.- check('an empty aside is a quiet "add an aside" placeholder, hidden or faint until the row is hovered', aside.empty && aside.t === 'add an aside' && +aside.o < 0.6, JSON.stringify(aside));+ // 0.62.0: a section with no owner or aside says NO OWNER, dim, at rest (it used to be a+ // placeholder that only showed on hover), and brightens on hover.+ check('an empty aside reads NO OWNER, dim but visible without hovering', aside.empty && aside.t === 'NO OWNER' && +aside.o >= 0.5 && +aside.o < 1, JSON.stringify(aside)); await page.hover(`${G} .sec-count`); await settle(page, 80); check('and shown on hover', await page.$eval(`${G} .sec-ld-t`, e => getComputedStyle(e).opacity) === '1'); await fieldClick('Gamma renamed', 'lead');@@ -2218,17 +2219,24 @@ async function tagsFlow(browser) { await view(page, 'setup'); const reg = async () => (await savedPlan(X.url)).project.tags || []; const regOf = async name => (await reg()).find(t => t.name === name);+ // Since 0.62.0 a registry edit lists every tag the plan has, so the order can be dragged.+ // What a tag edit MEANS is in the entries that say something: a colour, or a tag that+ // nothing carries. The plain entries of carried tags are bookkeeping.+ const regOwn = async () => { const p = await savedPlan(X.url); const car = new Set();+ (p.phases || []).forEach(ph => (ph.tags || []).forEach(t => car.add(t.toLowerCase())));+ (p.items || []).forEach(r => (r.tags || []).forEach(t => car.add(t.toLowerCase())));+ return (p.project.tags || []).filter(t => t.color || !car.has(t.name.toLowerCase())); }; const regLog = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => /^tag\./.test(e.action)); check('a plan that never used the registry exports no project.tags key', !('tags' in (await exportPlan(X.url)).project)); const rl0 = (await regLog()).length; await page.click('#tagNew'); await page.keyboard.type('someday'); await page.keyboard.press('Enter'); await settle(page, 300); check('New tag (Enter) adds a tag nothing carries yet, listed as "0 sections"', await page.$eval('#suTags .tag-row[data-tag="someday"] .tag-n', e => e.textContent).catch(() => '') === '0 sections');- check('it is saved in project.tags', JSON.stringify(await reg()) === '[{"name":"someday"}]', JSON.stringify(await reg()));+ check('it is saved in project.tags', JSON.stringify(await regOwn()) === '[{"name":"someday"}]', JSON.stringify(await reg())); check('it is offered as a suggestion wherever tags are typed', (await page.$$eval('#tagList option', o => o.map(x => x.value))).includes('someday')); check('the box is empty and keeps the focus for the next one', await page.evaluate(() => document.activeElement.id === 'tagNew' && document.activeElement.value === '')); await page.keyboard.type('ENGINEERING'); await page.keyboard.press('Enter'); await settle(page, 200); check('New tag refuses a tag the plan already has (any case), and says why', /already a tag called/.test(await page.$eval('#setupView .tag-err', e => window.__uiVis(e) ? e.textContent : ''))- && JSON.stringify(await reg()) === '[{"name":"someday"}]');+ && JSON.stringify(await regOwn()) === '[{"name":"someday"}]'); await page.$eval('#tagNew', e => { e.value = ''; e.dispatchEvent(new Event('input')); e.blur(); }); await view(page, 'gantt'); check('an unused tag gets no legend chip (it would filter to nothing)', !(await page.$('.tag-chip[data-tag="someday"]')) && !!(await page.$('.tag-chip[data-tag="engineering"]')));@@ -2237,7 +2245,7 @@ async function tagsFlow(browser) { check('Ctrl+Z takes the new tag back', !(await page.$('#suTags .tag-row[data-tag="someday"]')) && (await reg()).length === 0); check('and an emptied registry exports no project.tags key', !('tags' in (await exportPlan(X.url)).project)); await redo();- check('redo makes it again', !!(await page.$('#suTags .tag-row[data-tag="someday"]')) && JSON.stringify(await reg()) === '[{"name":"someday"}]');+ check('redo makes it again', !!(await page.$('#suTags .tag-row[data-tag="someday"]')) && JSON.stringify(await regOwn()) === '[{"name":"someday"}]'); // Colour from the palette. await page.click('#suTags .tag-row[data-tag="engineering"] .tag-sw'); await settle(page, 150); const PAL = ['#00b8b1', '#8c6bf7', '#64abff', '#ff8a7a', '#e8b64a', '#5fd7a3', '#ec7ab8', '#6f7cff', '#8b9bb4'];@@ -2249,7 +2257,7 @@ async function tagsFlow(browser) { // Colour from the hex box, and its refusal. await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150); await page.keyboard.type('zzz'); await page.keyboard.press('Enter'); await settle(page, 150);- check('a hex that is not a colour is refused, and says why', /not a colour/.test(await page.$eval('#suTags .tag-pal .sec-msg', e => e.textContent).catch(() => '')) && !(await regOf('Ops')));+ check('a hex that is not a colour is refused, and says why', /not a colour/.test(await page.$eval('#suTags .tag-pal .sec-msg', e => e.textContent).catch(() => '')) && !((await regOf('Ops')) || {}).color); await page.$eval('#suTags .tag-hex', e => { e.value = ''; }); await page.type('#suTags .tag-hex', 'E8B'); await page.keyboard.press('Enter'); await settle(page, 300); check('a hex colour is saved, as lowercase #rrggbb', (await regOf('Ops'))?.color === '#ee88bb', JSON.stringify(await reg())); await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150);@@ -2293,7 +2301,7 @@ async function tagsFlow(browser) { await undo(page); check('and Ctrl+Z puts it back with its colour', (await regOf('Ops'))?.color === '#ee88bb' && JSON.stringify(tagsOf(await savedPlan(X.url))) === JSON.stringify(W)); await renameTag(page, 'someday', 'later');- check('an unused tag renames in the registry', JSON.stringify((await reg()).map(t => t.name)) === JSON.stringify(['later', 'Engineering', 'Ops']), JSON.stringify(await reg()));+ check('an unused tag renames in the registry', JSON.stringify((await regOwn()).map(t => t.name).sort()) === JSON.stringify(['Engineering', 'Ops', 'later']), JSON.stringify(await reg())); // The changelog. const RL = (await regLog()).slice(rl0).map(e => `${e.action}:${e.tag}:${e.via}`); const want = ['tag.create:someday:setup', 'tag.delete:someday:undo', 'tag.create:someday:redo', 'tag.color:Engineering:setup', 'tag.color:Ops:setup', 'tag.color:Ops:setup', 'tag.color:Ops:undo', 'tag.rename:Eng:setup', 'tag.rename:Engineering:undo', 'tag.delete:Ops:setup', 'tag.color:Ops:undo', 'tag.rename:later:setup'];@@ -2330,6 +2338,58 @@ async function tagsFlow(browser) { await assertNoSidewaysScroll(m, '393, setup with tags'); await shot(m, 'tags-393-setup'); await m.close();++ // ── 0.62.0: drag a tag row to reorder; NO OWNER where nobody owns a row ──+ await view(page, 'setup'); await settle(page, 250);+ const tagOrder = () => page.$$eval('#suTags .tag-row', rs => rs.map(r => r.dataset.tag));+ const legendOrder = () => page.$$eval('#legend .tag-chip', cs => cs.map(c => c.dataset.tag));+ const o0 = await tagOrder();+ check('every tag row has a drag grip and no arrow or Rename button', o0.length >= 3 && await page.$$eval('#suTags .tag-row', rs => rs.every(r => r.querySelector('.sec-grip') && !r.querySelector('[data-a="up"],[data-a="down"],[data-a="ren"]'))), JSON.stringify(o0));+ const lastKey = o0[o0.length - 1];+ const from = await page.$eval(`#suTags .tag-row[data-tag="${lastKey}"] .tag-n`, e => { const r = e.getBoundingClientRect(); return { x: r.left + 4, y: r.top + r.height / 2 }; });+ const to = await page.$eval('#suTags .tag-row', e => { const r = e.getBoundingClientRect(); return { x: r.left + 60, y: r.top + 2 }; });+ await page.mouse.move(from.x, from.y); await page.mouse.down();+ await page.mouse.move(from.x, from.y - 12, { steps: 3 });+ check('a drop line shows while a tag row is dragged', !!(await page.$('#suTags .tag-list .sec-drop')));+ await page.mouse.move(to.x, to.y, { steps: 10 }); await page.mouse.up(); await settle(page, 500);+ const o1 = await tagOrder();+ check('dragging the last tag row to the top puts it first and keeps the rest in order', o1[0] === lastKey && JSON.stringify(o1.slice(1)) === JSON.stringify(o0.slice(0, -1)), JSON.stringify({ o0, o1 }));+ check('the new order is saved as the registry order', JSON.stringify((await reg()).map(t => t.name.toLowerCase())) === JSON.stringify(o1), JSON.stringify(await reg()));+ await view(page, 'gantt'); await settle(page, 200);+ const used = new Set(await legendOrder());+ check('the legend chips follow the new order', JSON.stringify(await legendOrder()) === JSON.stringify(o1.filter(k => used.has(k))), JSON.stringify(await legendOrder()));+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page, 300);+ await view(page, 'setup'); await settle(page, 200);+ check('the order survives a reload', JSON.stringify(await tagOrder()) === JSON.stringify(o1), JSON.stringify(await tagOrder()));+ // Alt+Down on the focused first row moves it one place; one Ctrl+Z takes that back.+ await page.focus(`#suTags .tag-row[data-tag="${lastKey}"]`);+ await page.keyboard.down('Alt'); await page.keyboard.press('ArrowDown'); await page.keyboard.up('Alt'); await settle(page, 400);+ const o2 = await tagOrder();+ check('Alt+Down moves the focused tag row one place', o2[1] === lastKey && o2[0] === o1[1], JSON.stringify(o2));+ await page.evaluate(() => document.activeElement && document.activeElement.blur());+ await undo(page); await settle(page, 400);+ check('one undo takes the move back', JSON.stringify(await tagOrder()) === JSON.stringify(o1), JSON.stringify(await tagOrder()));+ // A press that does not move is a click, not a drag: the name still opens its rename box.+ await page.click(`#suTags .tag-row[data-tag="${o1[1]}"] .tag-nm-t`); await settle(page, 200);+ check('a click on a tag name still opens the rename box (not a drag)', !!(await page.$('#suTags .tag-nm')));+ await page.keyboard.press('Escape'); await settle(page, 150);+ // NO OWNER: the chart's owner chip, the Table's Owner cell and a section with no aside.+ const noLead = await page.$$eval('#suSections .sec-ld-t.empty', es => es.map(e => ({ t: e.textContent, o: +getComputedStyle(e).opacity })));+ check('a section with no owner or aside reads NO OWNER, visible at rest', noLead.length > 0 && noLead.every(x => x.t === 'NO OWNER' && x.o >= 0.5), JSON.stringify(noLead.slice(0, 3)));+ await page.click('#suSections .sec-ld-t.empty'); await settle(page, 200);+ check('and a click on it opens the field to type one', await page.evaluate(() => { const a = document.activeElement; return !!a && a.tagName === 'INPUT' && !!a.closest('#suSections'); }));+ await page.keyboard.press('Escape'); await settle(page, 150);+ await view(page, 'gantt'); await settle(page, 200);+ const pills = await page.$$eval('.label-row .owner-pill.none', es => es.filter(e => e.offsetParent).map(e => e.textContent));+ check('a task nobody owns shows NO OWNER on the chart', pills.length > 0 && pills.every(t => t === 'NO OWNER'), JSON.stringify(pills.slice(0, 3)));+ const pillAt = await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row .owner-pill.none')].find(x => x.offsetParent && x.getBoundingClientRect().top > 120 && x.getBoundingClientRect().bottom < innerHeight - 20);+ if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });+ if (pillAt) await page.mouse.click(pillAt.x, pillAt.y); await settle(page, 250);+ check('and a click on it opens the owner list', await page.evaluate(() => !!document.querySelector('.cl-opt')));+ await page.keyboard.press('Escape'); await settle(page, 150);+ await view(page, 'table'); await settle(page, 250);+ const cells = await page.$$eval('td.c-owner.empty', es => es.map(e => e.textContent));+ check('and in the Table', cells.length > 0 && cells.every(t => t === 'NO OWNER'), JSON.stringify(cells.slice(0, 3))); await page.close(); // ── The snapshot: tags shown, filterable, and editable with no server ──