← Commit history

Publish 0.64.0

Drew Owens ·e15d32d810 · ·parent 3a10d54
7 files changed +243−12
README.md+1−1
@@ -64,7 +64,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Dependency arrows.** Teal routes with rounded elbows and arrowheads show task relationships: out of the source bar's end sideways, down, and into the next bar's start sideways again, steering round any bar in the way rather than through it. Click any bar to see incoming ("needs") and outgoing ("blocks") dependencies. Add or remove dependencies inline. -**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh.+**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh. Since 0.64.0 the legend also leads with an **Owner** pick (every owner with their row count, plus **Unassigned**, shared with the table's owner filter) and an **Overdue** chip: rows whose end date or milestone date has passed without being marked complete or cancelled get a red edge and ring, and the chip shows only them. Overdue is worked out from the dates every time the page draws and at midnight; it is never written into the plan.  **How long, and how long left.** The header says **Project Duration** and **Time Remaining**, worked out from the plan's own first and last dates and today, never from the cell size, leaving cancelled work out, and updated on every edit. They stay whole at every window width: the header sheds button words, then wraps, before it lets them be cut. A printed chart names the plan in a title line of its own. Hover either for the exact dates and the calendar and working-day counts. The plan itself is named once, in the brand block at the far left; its title and description are on that block's tooltip. 
SKILL.md+1
@@ -240,6 +240,7 @@ rather than a category. - **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and observed US federal holidays out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest - **Click legend** to filter phases, milestones, dependency arrows, or the today line. The legend WRAPS onto as many lines as it needs (0.57.0), so every phase chip on a 20-section plan is visible and clickable; until 0.56.0 it was one line and clipped everything past the pane edge. Below 1150px the chips drop their words and keep the colour dot, as before - **Tag chips in the legend** (0.59.0; rows since 0.60.0). When the plan carries tags, on sections or on rows, the legend leads with a chip per tag (`# Tech 32`: the tag and how many rows choosing it would show, counting the rows in its sections and the rows tagged on their own) and the section chips stand BEHIND them: with no tag chosen they fold away behind one "20 sections" chip (click it to open them all; that is remembered as `prefs.legendSecs`), and with a tag chosen the chips of exactly the sections on screen come back after the tags. A section chip whose name prefix is a chosen tag drops the repeat ("Laser workcells" behind "# Tech"). Choosing tags filters by EFFECTIVE tags: a row's effective tags are its own plus its section's, and a row is shown when ANY of them is chosen. So every row of a chosen section is shown, tagged or not; a row tagged "long-lead" in an untagged section is shown by "long-lead", with its section header above it and the section's other rows hidden; and a row whose own tags do not match, in a section that does not match, is hidden. A section header is shown when the section matches or any of its rows does, and while the filter hides some of its rows its count reads "2 of 3". A delete or a tag edit that takes the last carrier of a chosen tag out of the plan drops it from the filter, and its Ctrl+Z puts the filter back with the tag. This holds in the chart and the table alike; the readouts, Analytics and everything else stay whole-plan. It composes with the section chips (which still switch a shown section off) and with Collapse all (which then counts only the shown sections). **Clear tags** shows everything again. The filter is view state, per person, in `prefs.tagFilter`, so it survives a refresh; a tag the plan no longer has drops out of it. A plan with no tags has no tag chips and no fold, and its legend is what it always was+- **Owner and Overdue in the legend** (0.64.0), before the tag chips. **Owner** is a select listing every owner on the plan with their row count, then **Unassigned (n)** for the rows nobody owns, because on a young plan that is most of it and it has to be reachable. It is ONE choice shared with the table's owner select (`prefs.ownerFilter`, which replaces the table-only `prefs.tableOwner` and carries an old pick over), so the person picked on the chart is the person the table shows; the chart's copy hides in the table view, where the table's own sits in its toolbar. **Overdue** is a chip with a red dot and a count: a row is overdue when its deadline (a task's end, a milestone's date) is before today and its status does not settle it, complete or cancelled, read through `statusOf` so "completed", "done" and "canceled" settle it too; a blank status is still owed. It is the same rule as the Analytics Overdue card. Every overdue row carries a red edge on its label and a red ring on its bar (a red diamond on a milestone) whether or not the filter is on; clicking the chip shows only those rows (`prefs.overdueOnly`). The chip counts **tasks and milestones apart** (`Overdue 3 | ◆ 2`, also as `data-tasks` / `data-ms`), because milestones usually carry no status and would otherwise pile up as their dates pass and drown the late tasks; a milestone with no status counts as not reached until it is marked complete. **Overdue is derived, never stored:** it is worked out from the dates and statuses on every render, re-read after every edit, at midnight on a tab left open and when the tab comes back into view, and nothing is ever written to the plan, so no `Overdue` tag appears in the data, an export or the changelog, and computing it never makes another open tab stale. Both filters AND with the tags and narrow ROWS, never sections: under either, a section header shows only over rows it still has, its count reads "2 of 5", and Collapse all acts on the sections on screen. Readouts, Analytics and the exports stay whole-plan. An owner who no longer has a row drops out of the filter the way a vanished tag does - **Collapse all / Expand all** leads the legend (0.57.0), in the chart and the table. One press collapses every section to its header; when every section with rows is already collapsed it expands them all. A mixed state collapses. It is the same view state as the per-lane chevrons (`prefs.collapsed`), so it persists across a refresh, follows the chevrons (open the last lane by hand and it reads Collapse all again), and the table agrees with the chart. With everything collapsed the table says so rather than "no items match" - **Click a bar** to open the date editor with start/end pickers and dependency management. It is a compact 256px square (`--ed-sq`, one token for both dimensions): the row's name across the top, the two dates, the note, the dependency list and the buttons, at desktop density, so it covers as little of the chart as it can. The dependency list is the only part that flexes and the only part that scrolls; the buttons are pinned to the bottom edge. The milestone panel and the batch panel take the same compact metrics; the full **Edit item** panel, which is filled in rather than glanced at, keeps the full ones - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded
docs/BUILD-NOTES.md+42
@@ -3,6 +3,48 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-02 (0.64.0, Owner and Overdue in the legend)++Drew: "add filter by owner for gantt page. Let's also add an Overdue tag/filter that is auto+added on refresh (maybe also a manual Scan for Overdue button?) to tasks that should be+complete per dates but are not marked complete yet." Built for the instaPCB launch plan:+273 rows, 146 with no owner, 13 named owners.++- **Owner is one choice for both lenses.** The table already filtered by owner, from its own+  `prefs.tableOwner`. Two owner choices that disagree would put the chart and the table on+  different people with nothing saying so, so the pick moved into `rowShown` as+  `prefs.ownerFilter`, the table's select is now a view of it, and an old table pick carries+  over. The chart's copy is a select in the legend, hidden in the table view.+- **Unassigned is an option, with counts.** On this plan 146 of 273 rows have no owner; an+  owner list of named people only would hide the majority with no way back to it.+- **"Overdue tag" is a derived chip, not a tag.** Writing an `Overdue` tag into rows on load+  would rewrite the plan on every refresh from every open tab, churn the changelog, make+  every other tab stale (the 0.54.0 banner) and leave stale tags behind when a date moves.+  So overdue is computed from the dates and statuses on every render and never stored. It+  does look like a tag where it matters: a chip in the legend with a count, a filter, and a+  red mark on each row.+- **No Scan button.** The chip IS the scan, re-done on every render, after every edit, when+  the tab regains focus and every five minutes for a date turning at midnight, so a button+  would only repeat what already happened. Easy to add if wanted.+- **Settled = complete or cancelled, through `statusOf`.** The same rule as the Analytics+  Overdue card, and `statusOf` already folds "completed", "done" and "canceled" in, so the+  vocabulary split in older data cannot leak rows. A blank status is still owed.+- **Rows, not sections.** Owner and Overdue choose rows; a section header shows only over+  rows it still has. The tag rule (a matching section shows all its rows) is unchanged when+  only tags are on.+- **Tasks and milestones are counted apart** on the chip ("Overdue 0 | ◆ 1"). Found in+  review against the live plan (another session's check): 14 of its 18 milestones carry no+  status at all, against 233 tasks explicitly not-started, so as milestone dates pass a single+  count would climb with no work having slipped (12 of 14 by mid-December) and drown the late+  tasks. Suppressing status-less milestones would hide genuinely missed ones, so both are+  kept and shown apart, and the tooltip says a milestone with no status counts as not+  reached until it is marked complete.+- On the launch plan today exactly one row is overdue: the "Nameplate proof-of-concept run+  complete" milestone (Sep 23), which has no status.+- Tests: new `owner and overdue` flow (chart owner filter, Unassigned, table follows the+  chart, chip count and marks against a test-side oracle, Overdue only, nothing written to+  the plan); `#ganttOwner` and `#overdueChip` declared in the manifest.+ ## Build notes, 2026-10-02 (0.63.0, Effort: hours are estimates, not bars)  Drew: "in analytics, hours should be calculated as 8 hours per day M-F only, not 24/7.
gantt.html+151−9
@@ -1379,6 +1379,22 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .legend-item.sec-toggle { border-style: dashed; } .legend-item.sec-toggle svg { width: calc(11px * var(--fs)); height: calc(11px * var(--fs)); flex-shrink: 0; transition: transform 0.15s; } .legend-item.sec-toggle.open svg { transform: rotate(180deg); }+.legend-item.owner-pick, .legend-item.overdue-chip { background: none; border: 1px solid var(--border); font-family: 'Satoshi', sans-serif; color: var(--text2); padding: 2px 8px; gap: 6px; }+.legend-item.owner-pick select { background: transparent; color: var(--text); border: none; font: inherit; padding: 0; cursor: pointer; max-width: 16em; }+.legend-item.owner-pick select option { background: var(--surface); color: var(--text); }+.legend-item.owner-pick.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }+.legend-item.overdue-chip .od-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--alarm); flex-shrink: 0; }+.legend-item.overdue-chip .od-n { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; color: var(--alarm); }+.legend-item.overdue-chip:hover, .legend-item.owner-pick:hover { color: var(--text); border-color: var(--accent); background: var(--row-hover); }+.legend-item.overdue-chip .od-ms { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; color: var(--alarm); padding-left: 6px; border-left: 1px solid var(--border); }+.legend-item.overdue-chip.on { color: var(--text); border-color: var(--alarm-edge); background: var(--alarm-halo); }+.legend-item.overdue-chip.none .od-dot { background: var(--text3); }+.legend-item.overdue-chip.none .od-n { color: var(--text3); }+/* An overdue row (0.64.0): a red edge on its label and a red ring on its bar, so it shows+   without the filter on. Derived each render, never stored. */+.label-row.is-overdue { box-shadow: inset 3px 0 0 var(--alarm); }+.bar-row.is-overdue .bar { outline: 2px solid var(--alarm); outline-offset: 1px; }+.bar-row.is-overdue .ms-diamond { background: var(--alarm); } .legend-item.tag-clear { border-color: transparent; color: var(--text2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; } /* A phone: the two numbers drop under the name, indented to line up with it, and the    row actions follow them on the same line, so a 20-section plan is still one short@@ -2223,7 +2239,7 @@ function applyCollapse(){         const all=laneRows(r.phase), n=all.length;         // With a tag filter hiding some of this lane's rows, the count says so ("2 of 3"),         // or a header over two rows reading 3 looks like a row went missing (0.60.0 review).-        const shown=tagFilterOn()?all.filter(x=>rowShown(x,ks)).length:n;+        const shown=viewFilterOn()?all.filter(x=>rowShown(x,ks)).length:n;         r._laneBtn.querySelector('.lane-count').textContent=shown<n?shown+' of '+n:String(n);         r._laneBtn.title=(on?'Show':'Hide')+' the '+(shown<n?shown+' row(s) the tag filter shows, of '+n+',':n+' row(s)')+' in this lane. This is your view only, it does not change the plan.';         r._laneBtn.setAttribute('aria-expanded',on?'false':'true');@@ -2232,6 +2248,9 @@ function applyCollapse(){     }     const hide=isCollapsed(r.phase)||!rowShown(r,ks);     r._collapsed=hide;+    const od=isOverdue(r);+    if(r._lDiv)r._lDiv.classList.toggle('is-overdue',od);+    if(r._bDiv)r._bDiv.classList.toggle('is-overdue',od);     if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',hide);     if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',hide);     // the dashed rule belongs to the milestone, so it goes with it@@ -2271,7 +2290,7 @@ function setAllCollapsed(on){   // With a tag filter on, "all" is the sections on screen: the hidden ones keep whatever   // they were, so clearing the filter does not reveal lanes folded by a press that never   // showed them.-  if(tagFilterOn())phases.forEach((p,i)=>{ if(phaseShown(i)){ if(on)collapsed.add(i); else collapsed.delete(i); } });+  if(viewFilterOn())phases.forEach((p,i)=>{ if(phaseShown(i)){ if(on)collapsed.add(i); else collapsed.delete(i); } });   else { collapsed.clear(); if(on)phases.forEach((p,i)=>collapsed.add(i)); }   prefs.collapsed=[...collapsed]; savePrefs();   applyCollapse();@@ -4317,7 +4336,17 @@ function flushLogQueue(){ // from the plan leaves it), the suggestions. Cheap: a plan has tens of tags at most. let tagSig=''; function tagSigNow(){ return JSON.stringify(planTags().map(e=>[e.tag,e.n,e.items,e.color])); }-function scheduleSave(via){if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}+// An edit can make a row overdue or settle it (a date, a status) and can move the owner+// counts, so the two chips and the row marks are re-read after each one. Only re-read:+// a row that stops matching stays on screen until the filter is next touched, rather+// than vanishing from under the hand that just edited it.+function refreshViewChips(){+  if(!overdueChip.isConnected)return;+  syncOverdueChip(); syncOwnerPick();+  items.forEach(r=>{ if(isGroup(r))return; const od=isOverdue(r);+    if(r._lDiv)r._lDiv.classList.toggle('is-overdue',od); if(r._bDiv)r._bDiv.classList.toggle('is-overdue',od); });+}+function scheduleSave(via){refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);} // Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog // line for the save it belongs to. A tab closed inside that window would leave the edit in // neither, so anything still pending goes out synchronously when the page is being put@@ -4651,6 +4680,12 @@ function clampTagFilterPref(){ if(!Array.isArray(prefs.tagFilter))prefs.tagFilter=[]; prefs.tagFilter=prefs.tagFilter.filter(t=>typeof t==='string'&&t.trim()); prefs.legendSecs=prefs.legendSecs===true;+// Owner and Overdue (0.64.0) are view state like the tags. The owner used to be the+// table's own (prefs.tableOwner); it is one choice for both lenses now, so a person+// picked on the chart is the person the table shows, and an old table pick carries over.+if(typeof prefs.ownerFilter!=='string')prefs.ownerFilter=typeof prefs.tableOwner==='string'?prefs.tableOwner:'';+delete prefs.tableOwner;+prefs.overdueOnly=prefs.overdueOnly===true; } let tagsReady=false;             // set once the sidecar is in: before that the data file's tags are not the plan's function tagFilterKeys(){ return new Set(prefs.tagFilter.map(tagKey)); }@@ -4668,12 +4703,49 @@ function tagFilterOn(){ return prefs.tagFilter.length>0; } // // With no tag chosen both are true for everything. `ks` may be passed in by a caller // that asks for many rows at once, so the chosen set is built once.+// OWNER AND OVERDUE (0.64.0) narrow the same two predicates, ANDed with the tags:+//+//   ownerPass(r)   with an owner chosen, a row shows only when it is theirs; the+//                  Unassigned choice shows the rows nobody owns, which on a young plan+//                  is most of it, so it has to be reachable rather than hidden.+//   isOverdue(r)   its deadline (a task's end, a milestone's date) is before today and+//                  its status does not settle it: complete means it happened, cancelled+//                  means it was called off. A blank status is still owed. The same rule+//                  as the Analytics card, through the same statusOf, so "completed",+//                  "done" and "canceled" all count as settled.+//+// Overdue is DERIVED, never stored: it is re-read from the dates and statuses on every+// render, so it cannot go stale, cannot be edited away by hand, and computing it never+// writes to the plan (a write would make every other open tab stop saving).+const OWNER_NONE='__unassigned__';   // the same sentinel the table's owner select uses+function ownerFilterOn(){ return !!prefs.ownerFilter; }+function ownerPass(r){+  if(!prefs.ownerFilter)return true;+  const o=(r.owner||'').trim();+  return prefs.ownerFilter===OWNER_NONE?!o:o===prefs.ownerFilter;+}+function isOverdue(r){+  if(!r||r.type==='group')return false;+  const dl=r.type==='milestone'?(r.date||''):(r.end||'');+  if(!dl)return false;+  const st=statusOf(r);+  return st!=='complete'&&st!=='cancelled'&&dayNum(dl)<dayNum(anToday());+}+function overdueRows(){ return items.filter(isOverdue); }+// Is ANY view filter hiding rows? Callers that used to ask only about tags ("2 of 5" on+// a lane, Collapse all acting on what is on screen) ask this now.+function viewFilterOn(){ return tagFilterOn()||ownerFilterOn()||prefs.overdueOnly; } function rowShown(r,ks){+  if(!ownerPass(r))return false;+  if(prefs.overdueOnly&&!isOverdue(r))return false;   if(!prefs.tagFilter.length)return true;   ks=ks||tagFilterKeys();   return effTags(r).some(t=>ks.has(tagKey(t))); } function phaseShown(i,ks){+  // Owner and Overdue choose ROWS, never a section: under either, a header shows only+  // over rows it still has on screen.+  if(ownerFilterOn()||prefs.overdueOnly)return items.some(r=>r.phase===i&&!isGroup(r)&&rowShown(r,ks));   if(!prefs.tagFilter.length)return true;   const p=phases[i]; if(!p)return true;   ks=ks||tagFilterKeys();@@ -4711,6 +4783,69 @@ function toggleTagFilter(tag){ // sections on screen come back, a handful, straight after the tags that chose them. // A plan with no tags has no tag chips and no fold: its legend is what it always was. const secToggle=document.createElement('button'), tagClear=document.createElement('button');+// Owner and Overdue lead the legend, before the tags (0.64.0). The owner pick is a select+// because a plan has a dozen people, not four; it is the chart's copy of the table's own+// select, so it hides in the table view, where that one sits in the toolbar.+const ownerPick=document.createElement('div'), ownerPickSel=document.createElement('select'), overdueChip=document.createElement('button');+function wireLegendOwner(){+ownerPick.className='legend-item owner-pick gantt-only';+ownerPick.innerHTML='<span class="op-label">Owner</span>';+ownerPickSel.id='ganttOwner'; ownerPickSel.setAttribute('aria-label','Owner filter');+ownerPickSel.title="Show only one person's rows, or the ones nobody owns yet. Your view only, it does not change the plan.";+ownerPick.appendChild(ownerPickSel);+ownerPickSel.addEventListener('change',()=>setOwnerFilter(ownerPickSel.value));+overdueChip.type='button'; overdueChip.id='overdueChip'; overdueChip.className='legend-item overdue-chip';+overdueChip.addEventListener('click',()=>{ prefs.overdueOnly=!prefs.overdueOnly; savePrefs(); applyTagView(); });+}+function setOwnerFilter(v){+  prefs.ownerFilter=v||''; savePrefs();+  applyTagView();+}+// Options carry counts, so "Unassigned (146)" says how much of the plan nobody owns.+function ownerOptionsHTML(){+  const n=new Map(); let none=0;+  items.forEach(r=>{ if(isGroup(r))return; const o=(r.owner||'').trim(); if(o)n.set(o,(n.get(o)||0)+1); else none++; });+  const names=[...n.keys()].sort((a,b)=>a.localeCompare(b));+  return {names,html:'<option value="">All owners</option>'+    +names.map(o=>`<option value="${esc(o)}">${esc(o)} (${n.get(o)})</option>`).join('')+    +`<option value="${OWNER_NONE}">Unassigned (${none})</option>`};+}+function syncOwnerPick(){+  const {names,html}=ownerOptionsHTML();+  // An owner who no longer has a row drops out of the filter, the way a vanished tag+  // does, so nobody is left looking at an empty chart for a reason they cannot see.+  if(prefs.ownerFilter&&prefs.ownerFilter!==OWNER_NONE&&!names.includes(prefs.ownerFilter)){ prefs.ownerFilter=''; savePrefs(); }+  ownerPickSel.innerHTML=html; ownerPickSel.value=prefs.ownerFilter;+  ownerPick.classList.toggle('on',ownerFilterOn());+}+// Tasks and milestones are counted apart. On a real plan most milestones carry no status+// at all, so as their dates pass they pile up here with no work having slipped; one count+// would let them drown the late tasks. Two counts keep both signals, and say which is which.+function syncOverdueChip(){+  const od=overdueRows(), ms=od.filter(r=>r.type==='milestone').length, tk=od.length-ms, on=prefs.overdueOnly;+  overdueChip.classList.toggle('on',on); overdueChip.classList.toggle('none',!od.length);+  overdueChip.setAttribute('aria-pressed',on?'true':'false');+  overdueChip.dataset.tasks=tk; overdueChip.dataset.ms=ms;+  overdueChip.innerHTML='<span class="od-dot" aria-hidden="true"></span><span class="od-label">Overdue</span><span class="od-n">'+tk+'</span>'+    +(ms?'<span class="od-ms" aria-label="'+plural(ms,'milestone')+'">\u25C6 '+ms+'</span>':'');+  overdueChip.title=(on?'Show every row again. ':'Show only what is overdue: ')+    +plural(tk,'task')+(ms?' and '+plural(ms,'milestone'):'')+' with a deadline before today ('+fmt(anToday())+') that is not complete or cancelled. '+    +(ms?'A milestone with no status counts as not reached: mark it complete once it is. ':'')+    +'Worked out from the dates and statuses each time the page draws, and again at midnight; nothing is written to the plan.';+}+// The page can be left open overnight. When the date turns, what is overdue changes with+// nobody touching the plan, so the chip and the marks are re-read then, and whenever the+// tab comes back into view.+let overdueDay='';+function rescanOverdue(){+  const t=anToday(); if(t===overdueDay)return; overdueDay=t;+  applyTagView();+}+function wireOverdueClock(){+overdueDay=anToday();+setInterval(rescanOverdue,5*60*1000);+document.addEventListener('visibilitychange',()=>{ if(!document.hidden)rescanOverdue(); });+} function wireLegendTags(){ secToggle.type='button'; secToggle.id='legendSecs'; secToggle.className='legend-item sec-toggle'; secToggle.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg><span class="st-label"></span>';@@ -4747,9 +4882,11 @@ function syncSecToggle(){ } function rebuildLegend(){   legendEl.querySelectorAll('.phase-chip,.tag-chip').forEach(el=>el.remove());-  [secToggle,tagClear].forEach(el=>{ if(el.isConnected)el.remove(); });+  [secToggle,tagClear,ownerPick,overdueChip].forEach(el=>{ if(el.isConnected)el.remove(); });   const first=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild;   const put=el=>legendEl.insertBefore(el,first);+  syncOwnerPick(); put(ownerPick);+  syncOverdueChip(); put(overdueChip);   const tags=usedTags(), ks=tagFilterKeys(), filtering=tags.length>0&&tagFilterOn();   tags.forEach(e=>put(tagChip(e,ks.has(e.key))));   if(filtering)put(tagClear);@@ -10430,7 +10567,7 @@ function syncOwnerControls(){   const owners=distinctOwners();   // On the first render there are no options yet, so a value set from prefs would   // not have stuck. Fall back to the saved filter rather than the empty select.-  const cur=ownerSel.options.length?ownerSel.value:(prefs.tableOwner||'');+  const cur=prefs.ownerFilter||'';   ownerSel.innerHTML='<option value="">All owners</option>'     +owners.map(o=>`<option value="${esc(o)}">${esc(o)}</option>`).join('')     +`<option value="${UNASSIGNED}">Unassigned</option>`;@@ -10459,7 +10596,7 @@ function sortValueOf(r,key){ } function tableRows(){   const q=(searchInp.value||'').trim().toLowerCase();-  const own=ownerSel.value, st=statusSel.value, ks=tagFilterKeys();+  const st=statusSel.value, ks=tagFilterKeys();   const rows=[];   items.forEach(r=>{     if(isGroup(r))return;@@ -10469,11 +10606,12 @@ function tableRows(){     // rather than the per-row _collapsed flag, so it is right even before a reflow.     if(r.phase!==undefined&&isCollapsed(r.phase))return;     if(r.phase!==undefined&&!phaseVis[r.phase])return;-    // The tag filter is a ROW rule since 0.60.0: own tags plus the section's.+    // The tag filter is a ROW rule since 0.60.0: own tags plus the section's. Since+    // 0.64.0 rowShown also carries the owner (one choice shared with the chart's legend)+    // and the Overdue chip, so the table no longer filters owners on its own.     if(!rowShown(r,ks))return;     if(isMs(r)&&!msVis)return;     const o=ownerOf(r);-    if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return;     if(st&&statusOf(r)!==st)return;     if(q){       const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${effTags(r).join(' ')} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();@@ -10567,6 +10705,8 @@ function renderTable(){     // Collapse all empties the table as surely as a filter does, and "no items match"     // would send the reader looking at the filters for a cause that is not there.     const why=allLanesCollapsed()?'Every section is collapsed. Expand all, at the start of the legend, to see the rows.'+      :prefs.overdueOnly&&!items.some(r=>!isGroup(r)&&rowShown(r))?'Nothing is overdue'+(ownerFilterOn()||tagFilterOn()?' for this owner and these tags.':'.')+' Click Overdue in the legend to show everything again.'+      :ownerFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'This owner has no rows'+(tagFilterOn()?' with these tags':'')+'. Pick another owner, or All owners.'       :tagFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'No row carries these tags, on itself or through its section. Pick other tags in the legend, or clear them.'       :'No items match these filters.';     tableBody.innerHTML='<tr><td colspan="12" class="table-empty">'+why+'</td></tr>';return;}@@ -11687,7 +11827,7 @@ const b=$('btnCopyIds'); if(b)b.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();}); } function wireTableFilters(){-ownerSel.addEventListener('change',()=>{prefs.tableOwner=ownerSel.value;savePrefs();renderTable();});+ownerSel.addEventListener('change',()=>setOwnerFilter(ownerSel.value)); statusSel.addEventListener('change',renderTable); searchInp.addEventListener('keydown',e=>e.stopPropagation()); searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTimer=setTimeout(renderTable,120);});@@ -12151,6 +12291,8 @@ async function init(){   wireSaveFlush();   buildLegendCollapseAll();   wireLegendTags();+  wireLegendOwner();+  wireOverdueClock();   buildLegendPhases();   buildLegendMilestone();   buildLegendDeps();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.63.0",+  "version": "0.64.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.63.0",+  "version": "0.64.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+46
@@ -1124,6 +1124,11 @@ const MANIFEST = [   { sel: '.bt-x', label: 'batch: remove a tag from all', terminal: true, by: 'row tags', key: c => c.ds.tag, pin: ['long-lead'] },   { sel: '#legendSecs', label: 'legend: fold or unfold the section chips', safe: true, twice: true },   { sel: '#tagClear', label: 'legend: clear the tag filter', safe: true },+  // Owner and Overdue (0.64.0): view filters in the legend. The owner select is the chart's+  // copy of the table's and is proven by its flow; the Overdue chip toggles, so it is clicked twice.+  { sel: '#ganttOwner', label: 'legend owner filter', terminal: true, by: 'owner and overdue' },+  { sel: '.owner-pick', label: 'legend owner filter (its wrapper)', terminal: true, by: 'owner and overdue' },+  { sel: '#overdueChip', label: 'legend: overdue only', safe: true, twice: true },   { sel: '.sec-tag-in', label: 'section tag: type to add', terminal: true, by: 'tags' },   { sel: '.sec-tag-x', label: 'section tag: remove one', terminal: true, by: 'tags', key: c => c.ds.tag, pin: ['Launch', 'Tech', 'Process'] },   { sel: '#tagPrefixBtn', label: 'tags from name prefixes', terminal: true, by: 'tags' },@@ -1617,6 +1622,47 @@ async function editorFlows(browser, S) {   check('"Back to plan order" puts the rows in the plan\'s own order and hides itself', firstDiff === -1 && restored.length === planOrder.length && !(await page.$('#toPlanOrder').then(b => b && b.evaluate(e => window.__uiVis(e)))),     `first difference at row ${firstDiff}: "${(restored[firstDiff] || '').slice(0, 50)}" vs "${planOrder[firstDiff]}"`);   await view(page, 'gantt');++  // 0.64.0: the owner filter on the chart, shared with the table, and the Overdue chip.+  // The oracle is the plan itself: overdue = a deadline before the pinned today that is+  // not complete or cancelled, worked out here rather than asked of the page.+  section = 'owner and overdue';+  const ITEM_RE = /Lane \d+ (task \d+|gate)|Kickoff|Final gate prep|Launch finale|Dropped export/;+  const chartNames = () => page.$$eval('#labels .label-row:not(.row-collapsed)', (ls, re) => ls.map(l => l.textContent.trim()).filter(t => new RegExp(re).test(t)), ITEM_RE.source);+  const plan1 = await savedPlan(S.url, { minWait: 200 });+  const live = plan1.items.filter(r => r.type !== 'group');+  const chartAll = await chartNames();+  check('every row is on the chart before any owner or overdue filter', chartAll.length === live.length, `${chartAll.length} of ${live.length}`);+  const opts = await page.$$eval('#ganttOwner option', os => os.map(o => o.value));+  check('the legend owner pick lists every owner, All owners first and Unassigned last', opts[0] === '' && opts[opts.length - 1] === '__unassigned__' && ['Ana', 'Drew', 'John'].every(o => opts.includes(o)), opts.join(','));+  const johnN = live.filter(r => r.owner === 'John').length;+  await page.select('#ganttOwner', 'John'); await settle(page, 250);+  let shown = await chartNames();+  check(`owner John on the chart shows exactly his ${johnN} rows`, shown.length === johnN && shown.every(t => live.find(r => t.includes(r.name))?.owner === 'John'), `${shown.length} rows`);+  await view(page, 'table');+  check('the table owner select follows the chart\'s pick (one choice for both lenses)', await page.$eval('#tblOwner', e => e.value) === 'John');+  check('and the table shows the same rows', (await tableRows()).length === johnN);+  await view(page, 'gantt');+  const noneN = live.filter(r => !(r.owner || '').trim()).length;+  await page.select('#ganttOwner', '__unassigned__'); await settle(page, 250);+  check(`Unassigned shows the ${noneN} rows nobody owns`, (await chartNames()).length === noneN, `${(await chartNames()).length}`);+  await page.select('#ganttOwner', ''); await settle(page, 250);+  check('All owners puts every row back', (await chartNames()).length === live.length);+  const today = TODAY;+  const od = live.filter(r => { const dl = r.type === 'milestone' ? r.date : r.end; return dl && dayNum(dl) < dayNum(today) && !['complete', 'cancelled'].includes(r.status); });+  const chip = await page.$eval('#overdueChip', e => ({ tk: Number(e.dataset.tasks), ms: Number(e.dataset.ms), text: e.textContent }));+  const odMs = od.filter(r => r.type === 'milestone').length;+  check(`the Overdue chip counts tasks and milestones apart (${od.length - odMs} + ${odMs}: ${od.map(r => r.name).join(', ')})`, od.length > 0 && chip.tk === od.length - odMs && chip.ms === odMs && (odMs === 0 || chip.text.includes('\u25C6 ' + odMs)), JSON.stringify(chip));+  const marked = await page.$$eval('#labels .label-row.is-overdue', ls => ls.map(l => l.textContent.trim()));+  check('exactly the overdue rows carry the red mark without any filter on', marked.length === od.length && od.every(r => marked.some(t => t.includes(r.name))), marked.join(' | '));+  await page.click('#overdueChip'); await settle(page, 250);+  shown = await chartNames();+  check('Overdue on shows only the overdue rows', shown.length === od.length && od.every(r => shown.some(t => t.includes(r.name))), shown.join(' | '));+  await page.click('#overdueChip'); await settle(page, 250);+  check('Overdue off shows every row again', (await chartNames()).length === live.length);+  // Nothing about either filter is written to the plan: they are one viewer's lens.+  const plan2 = await savedPlan(S.url, { minWait: 300 });+  check('neither filter writes to the plan (no Overdue tag, rows unchanged)', JSON.stringify(plan2.items) === JSON.stringify(plan1.items) && !plan2.items.some(r => (r.tags || []).some(t => /overdue/i.test(t))));   await page.close(); }