← Commit history
README.md+1−1
@@ -10,7 +10,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependency that crosses sections is not an edge and never lights up, and a section with no dependencies among its own rows lights its longest single row. Ties are broken deterministically: more working days, then a greater calendar span, then more rows, then whichever starts earlier in the plan. Everything else stays exactly as it was, selection and dependency mode included, the glow recomputes as you edit, and the state is remembered across reloads. -**Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their five status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, per-tag totals when the plan has tags (since 0.60.0: items, hours and share, by each row's own tags plus its section's, with a "no tag" line), milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it.+**Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours, summed from each task's **Effort** estimate (since 0.63.0; a task nobody has estimated adds nothing and is counted as not estimated, because a bar is the window the work happens in, not what it costs), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their five status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, per-tag totals when the plan has tags (since 0.60.0: items, hours and share, by each row's own tags plus its section's, with a "no tag" line), milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it.  **Setup.** A fourth lens, since 0.58.0, for the plan's structure rather than its work. It is where the sections are managed: add one, rename it and set its aside, recolour it, move it, or remove it, each row showing how many rows the section holds and the dates they span. Since 0.61.0 a section moves by dragging its row (or Alt+Up / Alt+Down on a focused row), and its name and aside are edited by clicking them. Since 0.59.0 it is also where sections are tagged, one by one or from their name prefixes in one click, and where a tag is renamed or removed everywhere (since 0.60.0, on rows as well as sections). Since 0.61.0 it is where a tag is made before anything carries it (New tag) and where a tag gets a colour. It replaces the Sections button that used to sit in the header and open a modal. Every change is still one Ctrl+Z and one changelog line. 
SKILL.md+18−12
@@ -178,6 +178,7 @@ items. | 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) | | tags | string[] | no | The row's OWN tags, e.g. `["long-lead"]`. Same rules as a section's tags. A row's effective tags are these plus its section's |+| effort | number | no | Estimated work in HOURS (0.63.0), e.g. `16`. 0 to 100000, kept to one decimal; a numeric string is read as a number, anything else is dropped. Absent means not estimated, and the task adds nothing to the Analytics hours. Independent of the dates: the bar is the window, this is the work |  **type: "milestone"**: diamond marker, or a dashed vertical rule if the user has switched milestone style @@ -194,7 +195,7 @@ items.  **Tags ride every export.** `/export/json`, `/import/json` and the HTML snapshot carry `tags` on phases and items, and every import normalises them. `/export/csv` has a-`tags` column after `deps`, joined with `;` the same way, and `/import/csv` splits it+`tags` column after `deps`, joined with `;` the same way (and an `effort` column after it, hours, since 0.63.0), and `/import/csv` splits it back; a CSV without the column imports as before. A CSV is rows, so section tags are not in it. **So a CSV export and re-import loses the section tags, and with them every tag a row only inherits**; JSON and the HTML snapshot are the lossless formats. A Jira CSV's `Labels` column becomes each row's tags; MS Project carries none.@@ -302,6 +303,7 @@ named in its own column. | Start | `start` | blank, a milestone is a point | | Deadline | `end` | `date` | | Status | `status`, or "none" | `status`, or "none" |+| Effort | `effort` in hours, or "not est." (click to type `16`, `2d`, `1w`; empty clears it; sorts by hours, blanks last) | blank, a milestone carries no effort | | Notes | `description`, truncated | same | | Tags | `tags`, the row's own, as pills, then its section's, dashed (0.60.0) | same | | Provenance | `provenance` as a chip, linked when the url is http(s) | same |@@ -355,21 +357,25 @@ The third lens, and the one to reach for when the question is "how much" rather same `items` every time the lens is entered or an edit lands, so it can never disagree with the other two. -**The hour rule.** A task's effort is the WORKING days in its span, counting both ends,-times eight. A forty-hour week, so Monday to Friday is 40h and the same task dragged-across a weekend to the Tuesday is 56h. A US federal holiday is not a working day, so a-task spanning Labor Day is 8h lighter than its calendar span suggests. A milestone is a-date rather than work and carries no hours; the owner table counts milestones separately-as `+nM`.+**The hour rule (0.63.0).** A task's hours are its **Effort**: the work it takes, in+hours, entered in the Table's Effort column or the task editor (`2d` is two 8-hour days,+`1w` a 40-hour week). A task with no Effort is **not estimated** and adds nothing to any+total; every hours figure says how many tasks it is missing. The bar is NOT the effort:+it is the window the work happens in, and through 0.62 Analytics counted every bar as+one person full time for each working day of it, which put a real plan of 154 eight-week+windows at 38,000 hours. 0 is an estimate (no work), not the same as blank. A milestone+is a date rather than work and carries no hours; the owner table counts milestones+separately as `+nM`. Working hours LEFT is still calendar capacity: working days from+today to the plan's end, Monday to Friday, never a US federal holiday, at 8h a day.  Seven cards:  | Card | What it answers | |------|-----------------|-| Project total | Total effort, WORKING HOURS LEFT to the project end, AVERAGE per person assigned, working days in the plan's span, dated tasks, milestones, people |-| Per person | One row per owner, unassigned included: hours, share of the total and item counts FIRST, then the five status counts. Sorted by hours |+| Project total | Total effort (the sum of the estimates), how many live tasks are estimated, WORKING HOURS LEFT to the project end, AVERAGE per person assigned, working days in the plan's span, dated tasks, milestones, people |+| Per person | One row per owner, unassigned included: hours, share of the total, item counts and how many of their tasks are not estimated FIRST, then the five status counts. Sorted by hours | | Status | The five status counts over every task and milestone, with shares |-| Per section | Items, hours and the date range each section actually covers, derived from its rows |+| Per section | Items, hours, tasks not estimated, and the date range each section actually covers, derived from its rows | | Per tag | Since 0.60.0, when the plan has tags: per tag, the items (tasks, +nM for milestones), hours and share of the total hours, counted by EFFECTIVE tags (own plus section, the legend filter's rule), then a "no tag" line for rows with none. A row with two tags counts under both, so the shares can pass 100%. Cancelled work counts as an item and not in the hours | | Milestones | Every dated milestone with its date, days from today and owner | | Dependencies | Link count, rows in the graph, rows outside it, and a cycle warning naming the rows |@@ -591,7 +597,7 @@ Each event records the action, the item, the before and after dates, the day shi `start-revert`, `auto-schedule`, `undo`).  Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rename`,-`note`, `owner`, `status`, `desc`, `tags`, `phase.tags`, `tag.create`, `tag.rename`,+`note`, `owner`, `status`, `desc`, `effort`, `tags`, `phase.tags`, `tag.create`, `tag.rename`, `tag.color`, `tag.delete`, `dep.add`, `dep.remove` and `reorder`.  The `tag.*` actions (0.61.0) are the tag registry's own: `tag.create` (New tag; `to.color`@@ -738,7 +744,7 @@ The sidecar is at `v: 4` and everything in it is keyed by `uid`:  | Field | Shape | |-------|-------|-| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase, tags}`, one entry per changed row. Tasks carry `s`/`e`, milestones carry `date` |+| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase, tags, effort}`, one entry per changed row. Tasks carry `s`/`e` and `effort` (a number, or `null` for cleared), milestones carry `date` | | order | a list of uids, the plan's row order | | deleted | a list of uids the user removed. Those uids are retired and never reissued | | added | rows the user created, each carrying its own `uid` |
docs/BUILD-NOTES.md+41
@@ -3,6 +3,47 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## 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.+Currently total effort of 38k hours is a joke." The arithmetic was already Monday to+Friday at 8h a day with federal holidays out; 24/7 on the same plan would have been+176,000 hours. The joke was the MODEL: every bar counted as one person full time for+every working day of it. The plan has 154 tasks with a median bar of eight weeks, many+sharing one ten-week window, so "Ventilation to outdoors" was 376 hours of work because+its window was ten weeks wide. A bar is when the work happens, not what it costs, and+the app had no way to know the second from the first.++So a task now carries an optional **Effort** in hours, and Analytics counts only that.+Drew chose blank = 0 over the alternatives (blank = the full bar, a plan-wide allocation+percentage, hiding the hours): a task nobody has estimated adds nothing to any total and+is counted as "not estimated", so a total is never an invented number and every one of+them says how complete it is. 0 is an estimate (no work) and is not the same as blank.++* **Where it is typed.** The Table's new Effort column (click, type, Enter), the task+  editor and the New panel, beside the dates. `16`, `16h`, `2d` (two 8-hour days) and+  `1w` (a 40-hour week) all read; anything else is refused with a reason and leaves no+  undo entry. A milestone has no effort field, and an effort on a milestone in a data+  file is dropped, by the page and the server alike.+* **Where it goes.** `items[].effort`, hours, 0 to 100000, one decimal. The sidecar+  carries it on every task, `null` for cleared, the way the owner is always written;+  absent means the data file's stands, so an older client cannot wipe it. JSON export+  and import, the HTML snapshot, and a CSV `effort` column after `tags`. Copy and paste+  carry it. The New panel remembers it like every field but the name.+* **The changelog** has an `effort` action, written by the save-time diff (so undo and+  redo are logged too) and read as "set the effort on X to 16h (was not estimated)".+* **Analytics.** Total effort is the sum of the estimates, and a stat beside it says+  "N of M tasks estimated". Per person and per section gain a "Not est." column. The per+  tag card sums the same estimates. Working hours LEFT is unchanged: it was always the+  calendar capacity from today to the end, which is a different question.++Tests: a new `effort` flow (29 checks: the analytics figures against hand-worked+numbers, the Table cell with 2d, a junk value refused, undo, redo, a cleared data-file+estimate surviving a reload, the sort, the task editor with 1.5w and a bad value, the+milestone editor without the field, New with memory, paste, CSV), the row-tags flow's+per-tag hours now seeded as estimates, and server_test cases for normalisation, the+sidecar's null, created rows, CSV in and out, and the changelog sentences.+ ## Build notes, 2026-10-02 (0.62.0, NO OWNER, and tags you can drag)  Two asks from Drew, looking at the Setup page.
gantt.html+106−28
@@ -841,6 +841,7 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .editor label { display: block; font-size: 11px; color: var(--text2); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; } .editor input, .editor textarea, .editor select { 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; outline: none; } .editor input[type="date"], .editor input[type="number"] { width: 130px; }+.editor input.eff-in { width: 92px; } .editor input[type="text"] { width: 100%; } .editor input:focus, .editor textarea:focus, .editor select:focus { border-color: var(--accent); } .editor input:focus { border-color: var(--accent); }@@ -1625,6 +1626,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va           <th class="sortable" data-sort="start">Start</th>           <th class="sortable" data-sort="deadline">Deadline</th>           <th class="sortable" data-sort="status">Status</th>+          <th class="sortable" data-sort="effort" title="Estimated work in hours. 2d is two 8-hour days, 1w a 40-hour week. Empty means not estimated, and it adds nothing to the Analytics totals">Effort</th>           <th class="sortable" data-sort="deps" title="Dependencies: what this row needs, and what waits on it. Click a cell to edit them">Deps</th>           <th class="sortable" data-sort="notes">Notes</th>           <th class="sortable" data-sort="tags" title="The row's own tags, then its section's (dashed). The legend's tag filter reads both. Click a cell to edit the row's own">Tags</th>@@ -1742,6 +1744,38 @@ function tidyTags(o){ } phases.forEach(tidyTags); items.forEach(tidyTags);+// Effort (0.63.0): a task's estimated work in HOURS, the server's normalizeEffort line for+// line. A bar is the window the work happens in, not what it costs: through 0.62 Analytics+// counted every bar as one person full time for each working day of it, and a plan of 154+// eight-week windows came out at 38,000 hours. A task with no effort is "not estimated"+// and adds nothing to any total. 0 is an estimate (no work), not the same as blank.+const EFFORT_MAX=100000;+function normalizeEffort(v){+  if(typeof v==='string'){ if(!v.trim())return undefined; v=Number(v.trim()); }+  if(typeof v!=='number'||!isFinite(v)||v<0||v>EFFORT_MAX)return undefined;+  return Math.round(v*10)/10;+}+function tidyEffort(o){+  if(!o||typeof o!=='object'||!('effort' in o))return;+  const e=normalizeEffort(o.effort);+  if(e===undefined||o.type==='milestone')delete o.effort; else o.effort=e;+}+items.forEach(tidyEffort);+function effortOf(r){ return r&&r.type!=='milestone'&&typeof r.effort==='number'?r.effort:null; }+function setEffort(r,v){ if(v===null||v===undefined)delete r.effort; else r.effort=v; }+// What a person types: "16", "16h", "2d" (two 8-hour days), "1.5w" (a 40-hour week).+// '' is null (not estimated); anything unreadable is NaN, and the caller refuses it.+function parseEffort(str){+  const t=String(str==null?'':str).trim().toLowerCase();+  if(!t)return null;+  const m=t.match(/^(\d+(?:\.\d+)?|\.\d+)\s*(h|hr|hrs|hours?|d|days?|w|wks?|weeks?)?$/);+  if(!m)return NaN;+  const u=m[2]||'h', k=u[0]==='d'?8:u[0]==='w'?40:1;+  const v=normalizeEffort(parseFloat(m[1])*k);+  return v===undefined?NaN:v;+}+function fmtEffort(v){ return v===null||v===undefined?'':(Math.round(v*10)/10).toLocaleString()+'h'; }+const EFFORT_HELP='Hours of work, like 16, or 2d for two 8-hour days, or 1w for a 40-hour week. Leave it empty if nobody has estimated it.';  // ── The tag registry (0.61.0) ── //@@ -1855,7 +1889,11 @@ const statusLabel={'not-started':'not started','in-progress':'in progress',block // rule lives in one place: a cancelled row keeps its dates and its own hours and is // simply not counted into anybody's total. function isCancelled(r){ return statusOf(r)==='cancelled'; }-function effortHoursOf(r){ return isCancelled(r)?0:taskHours(r); }+// The hours a task adds to the Analytics totals: its Effort estimate (0.63.0), or nothing+// when it has none or is cancelled. Through 0.62 this was the bar's working days x 8.+function effortHoursOf(r){ return isCancelled(r)?0:(effortOf(r)||0); }+// A live task nobody has estimated yet: counted, so the totals say how complete they are.+function unestimated(r){ return !isMs(r)&&!isCancelled(r)&&!!r.start&&!!r.end&&effortOf(r)===null; } function statusTagFor(k){ return statusTag[k]||'tag-other'; } function statusTextFor(k){ return statusLabel[k]||k; } // Rows the user created. The app never rewrites the data file, so a new row has@@ -2843,7 +2881,7 @@ let undoTimer = null; // `tags` (0.60.0) is the row's own list as it stands, absent or [] included, so the item // editor's Apply over name, owner AND tags is still one entry. tagsSnap copies it. const tagsSnap=r=>Array.isArray(r.tags)?r.tags.slice():undefined;-function editSnapshot(r){ return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r)}; }+function editSnapshot(r){ return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r),effort:effortOf(r)}; } function snapshotMany(ids){   const steps=ids.map(id=>items.find(d=>d.id===id)).filter(Boolean).map(editSnapshot);   if(steps.length)pushUndo(steps.length===1?steps[0]:{type:'multi',steps});@@ -3484,9 +3522,10 @@ function applySnapshot(sn){   const r=items.find(d=>d.id===sn.id);   if(!r)return null;   const inv={type:'edit',id:sn.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',-             owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r)};+             owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r),effort:effortOf(r)};   const tagsMoved='tags' in sn&&!sameTags(ownTags(r),sn.tags);   if('tags' in sn)setRowTags(r,sn.tags);+  if('effort' in sn)setEffort(r,sn.effort);   r.start=sn.s;r.end=sn.e;r.deps=Array.isArray(sn.deps)?[...sn.deps]:sn.deps;   if(sn.note!==undefined)r.note=sn.note;   if('owner' in sn)setOpt(r,'owner',sn.owner);@@ -3895,6 +3934,9 @@ function getState(){       // row that never had one says nothing and the data file's value stands; an edit       // that clears a row's tags leaves `[]` on it, which is the sidecar's "cleared".       if(Array.isArray(r.tags))o.tags=r.tags.slice();+      // Effort (0.63.0) on every task, null when it has none: this page holds the whole+      // state, and null is the sidecar's "cleared", as '' is for the owner.+      if(r.type!=='milestone')o.effort=effortOf(r);       return o;     }),     // The whole section list, not just date patches by index. Sections are data@@ -3919,7 +3961,7 @@ function getState(){       // does not survive the next save: leaving uid out would have quietly       // stripped the durable id off every row the user ever created, and in v4 it       // is also the key every other part of the file uses to name that row.-      ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});+      ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags','effort'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});       return o;}),   }; }@@ -3968,6 +4010,8 @@ function applyState(state){     if(sv.prov!==undefined)setProv(r,sv.prov);     // Absent: the data file's tags stand. [] : the user cleared them. As the server merges.     if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); r.tags=t; }+    // Effort: absent, the data file's stands; null, cleared; a number, set. As the server.+    if('effort' in sv&&r.type!=='milestone'){ if(sv.effort===null)delete r.effort; else { const ef=normalizeEffort(sv.effort); if(ef!==undefined)r.effort=ef; } }     if(sv.name)r.name=sv.name;     if(Number.isInteger(sv.phase)&&sv.phase>=0&&sv.phase<phaseN)r.phase=sv.phase;   });@@ -4054,7 +4098,7 @@ function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} 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(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice()};});+  items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice(),eff:effortOf(r)};});   // Keyed by the stable row key, not by the name. Keying by name made a rename   // look like one milestone vanishing and another appearing, so it logged nothing   // (both sides early-return on an unseen name) and instead surfaced as a phantom@@ -4094,6 +4138,7 @@ function diffAndLog(via){     // A row's own tags (0.60.0), the row side of the server's `tags` action. Absent and []     // both diff as none: the sidecar keeps the difference, the changelog need not.     if(!sameTags(a.tags,b.tags)) evs.push({action:'tags',id,name:nameOf(id),from:{tags:a.tags||[]},to:{tags:b.tags}});+    if((a.eff===undefined?null:a.eff)!==b.eff) evs.push({action:'effort',id,name:nameOf(id),from:{effort:a.eff===undefined?null:a.eff},to:{effort:b.eff}});     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)}));   });@@ -4114,6 +4159,8 @@ function diffAndLog(via){     // reader of the log sees where a tag came from. A paste is left as its one summary line.     if(born&&via!=='paste'&&cur.byId[id].tags.length)       evs.push({action:'tags',id,name:cur.byId[id].name||id,from:{tags:[]},to:{tags:cur.byId[id].tags}});+    if(born&&via!=='paste'&&cur.byId[id].eff!==null)+      evs.push({action:'effort',id,name:cur.byId[id].name||id,from:{effort:null},to:{effort:cur.byId[id].eff}});   });   // 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.@@ -5942,7 +5989,7 @@ function clipOf(r){     owner:r.owner, status:r.status, description:r.description, note:r.note,     // The row's OWN tags travel with it (0.60.0). Its section's do not need to: a copy     // pasted into another section takes that section's, which is what "effective" means.-    tags:ownTags(r).slice(),+    tags:ownTags(r).slice(), effort:effortOf(r),     deps:Array.isArray(r.deps)?r.deps.slice():[]}; } // Ctrl+C. The selection is already the app's one selection model, so this works the@@ -6108,6 +6155,7 @@ async function pasteRows(){     if(c.description)r.description=c.description;     if(c.note)r.note=c.note;     if(c.tags&&c.tags.length)r.tags=c.tags.slice();+    if(c.type!=='milestone'&&c.effort!==null&&c.effort!==undefined)r.effort=c.effort;     // Provenance is the CREATED-row convention, not the original's. These rows were     // not produced by whatever the original cites; they were made here, now, by the     // person pasting, and that is what the field is for.@@ -9565,6 +9613,7 @@ function openItemModal(r,carriedName){     <div class="de-row">       <div id="imStartWrap"><label>start</label><input type="date" id="imStart"></div>       <div><label id="imEndLabel">deadline</label><input type="date" id="imEnd"></div>+      <div id="imEffWrap"><label>effort <span class="de-hint">hours</span></label><input type="text" id="imEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}"></div>     </div>     <div class="de-note-row"><label>notes</label><input type="text" id="imDesc" placeholder="Shown on hover and in the table"></div>     <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="imProv"></div>@@ -9586,6 +9635,8 @@ function openItemModal(r,carriedName){   $('imStart').value=ms?'':(r.start||'');   $('imEnd').value=(ms?r.date:r.end)||'';   $('imOwner').value=r.owner||'';+  $('imEffWrap').style.display=ms?'none':'';+  $('imEffort').value=effortOf(r)===null?'':String(effortOf(r));   $('imStatus').value=statusOf(r);   $('imDesc').value=r.description||'';   $('imProv').value=p.label||'';@@ -9604,6 +9655,9 @@ function openItemModal(r,carriedName){   $('imApply').addEventListener('click',()=>{     const name=$('imName').value.trim();     if(!name){const er=$('imErr');er.textContent='A name is required.';er.style.display='';$('imName').focus();return;}+    // Read and refused BEFORE the snapshot, so a typo leaves no undo entry behind.+    const eff=ms?null:parseEffort($('imEffort').value);+    if(Number.isNaN(eff)){const er=$('imErr');er.textContent='Effort is hours, like 16, 2d or 1w, or empty for not estimated.';er.style.display='';$('imEffort').focus();return;}     const np=parseInt($('imPhase').value,10);     const moves=!!phases[np]&&np!==r.phase;     if(ms)snapshotMs(r); else snapshot(r.id);@@ -9628,6 +9682,7 @@ function openItemModal(r,carriedName){     setOpt(r,'owner',$('imOwner').value.trim());     setOpt(r,'status',STATUS_ORDER.includes($('imStatus').value)?$('imStatus').value:'');     setOpt(r,'description',$('imDesc').value.trim());+    if(!ms)setEffort(r,eff);     const pl=$('imProv').value.trim();     if(!pl)delete r.provenance;     else r.provenance=p.url?{label:pl,url:p.url}:pl;@@ -9705,6 +9760,7 @@ function openCreateEditor(){     <div class="de-row">       <div id="ncStartWrap"><label>start</label><input type="date" id="ncStart" value="${startVal}"></div>       <div><label id="ncEndLabel">deadline</label><input type="date" id="ncEnd" value="${endVal}"></div>+      <div id="ncEffWrap"><label>effort <span class="de-hint">hours</span></label><input type="text" id="ncEffort" class="eff-in" inputmode="decimal" autocomplete="off" placeholder="not estimated" title="${esc(EFFORT_HELP)}" value="${memo.effort===null||memo.effort===undefined?'':esc(String(memo.effort))}"></div>     </div>     <div class="de-note-row"><label>notes</label><input type="text" id="ncDesc" placeholder="Shown on hover and in the table" value="${esc(descVal)}"></div>     <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="ncProv" value="${esc(provVal)}"></div>@@ -9725,6 +9781,7 @@ function openCreateEditor(){   const syncType=()=>{     const ms=$('ncType').value==='milestone';     $('ncStartWrap').style.display=ms?'none':'';+    $('ncEffWrap').style.display=ms?'none':'';     $('ncEndLabel').textContent=ms?'date':'deadline';   };   $('ncType').addEventListener('change',syncType); syncType();@@ -9754,6 +9811,8 @@ function openCreateEditor(){     const desc=$('ncDesc').value.trim();     const prov=$('ncProv').value.trim();     const tags=tagBox.get();+    const eff=isMs?null:parseEffort($('ncEffort').value);+    if(Number.isNaN(eff)){const er=$('ncErr');er.textContent='Effort is hours, like 16, 2d or 1w, or empty for not estimated.';er.style.display='';$('ncEffort').focus();return;}     creating=true;     const btn=$('ncCreate'); if(btn)btn.disabled=true;     let uid=null;@@ -9776,6 +9835,7 @@ function openCreateEditor(){     if(desc)r.description=desc;     if(prov)r.provenance=prov;     if(tags.length)r.tags=tags.slice();+    if(!isMs&&eff!==null)r.effort=eff;     // Only now, past validation and past the uid allocation, with a row actually     // being made. A milestone hides the start field but the input keeps its value,     // so `start` is whatever was carried in and the remembered start comes through@@ -9785,7 +9845,7 @@ function openCreateEditor(){     // recorded as absent so the next panel recomputes the default from the     // signed-in identity instead of freezing today's name into the session.     lastCreated={type:isMs?'milestone':'task', phase, owner:owner||'', start, end,-                 status:st||'', desc:desc||'', tags:tags.slice(),+                 status:st||'', desc:desc||'', tags:tags.slice(), effort:isMs?(lastCreated&&lastCreated.effort):eff,                  prov:(prov===provDefault)?undefined:prov};     addedItems.push(r);     insertRow(r);@@ -10393,6 +10453,8 @@ function sortValueOf(r,key){   // Own tags first, then the section's, the order the cell shows them in; untagged last.   if(key==='tags')return effTags(r).join(' ').toLowerCase();   if(key==='prov')return provLabel(r).toLowerCase();+  // Numeric, as a fixed-width string so the string compare below orders it; blank last.+  if(key==='effort'){ const e=effortOf(r); return e===null?'':String(Math.round(e*10)).padStart(8,'0'); }   return deadlineOf(r); } function tableRows(){@@ -10507,7 +10569,7 @@ function renderTable(){     const why=allLanesCollapsed()?'Every section is collapsed. Expand all, at the start of the legend, to see the rows.'       :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="11" class="table-empty">'+why+'</td></tr>';return;}+    tableBody.innerHTML='<tr><td colspan="12" class="table-empty">'+why+'</td></tr>';return;}   tableBody.innerHTML=rows.map(r=>{     const ms=isMs(r), editable=ms||!!r.id;     const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');@@ -10524,6 +10586,8 @@ function renderTable(){           :`<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>`       +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`+      +(ms?'<td class="c-effort empty"></td>'+          :`<td class="c-effort${ed}${effortOf(r)===null?' empty':''}" data-field="effort">${effortOf(r)===null?'not est.':esc(fmtEffort(effortOf(r)))}</td>`)       +`<td class="c-deps${(()=>{const d=depsOf(r);return (d.needs.length||d.blocks.length)?'':' empty';})()}">${depCell(r)}</td>`       +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`       +tagsCell(r,ed)@@ -10669,7 +10733,7 @@ function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).lengt // width, and the table is pixel-driven from then on. That is also what Excel does: // before you touch anything the columns are whatever the sheet says, and the moment // you drag one they are numbers you own.-const COL_DEFAULTS={name:'21%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',deps:'5%',notes:'15%',tags:'9%',prov:'11%',del:'30px'};+const COL_DEFAULTS={name:'20%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',effort:'5%',deps:'5%',notes:'13%',tags:'8%',prov:'10%',del:'30px'}; function applyColWidths(){   const table=tableHead.closest('table');   if(!table)return;@@ -11250,6 +11314,10 @@ function startCellEdit(td,caret){     // you retype in a grid cell, so it is preserved as-is underneath.     el=document.createElement('input');el.type='text';     el.placeholder='where this came from';el.value=provLabel(r);+  } else if(field==='effort'){+    el=document.createElement('input');el.type='text';el.inputMode='decimal';el.autocomplete='off';+    el.placeholder='hours, 2d, 1w';el.title=EFFORT_HELP;+    el.value=effortOf(r)===null?'':String(effortOf(r));   } else if(field==='owner'){     el=document.createElement('input');el.type='text';el.setAttribute('list','ownerList');     el.placeholder='one name';el.value=r.owner||'';@@ -11406,6 +11474,12 @@ function commitCell(r,field,v){     v=v.trim();     if((r.description||'')===v)return;     snap();setOpt(r,'description',v);+  } else if(field==='effort'){+    if(ms)return;+    const e=parseEffort(v);+    if(Number.isNaN(e)){ flash(saveInd,'effort is hours, like 16, 2d or 1w',3500); return; }+    if(e===effortOf(r))return;+    snap();setEffort(r,e);   } else if(field==='deadline'){     if(!v)return;     if(ms){ if(r.date===v)return; snap(); r.date=v; }@@ -11638,7 +11712,6 @@ searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTi // weekend to the following Tuesday is 56h and not 64h. A milestone is a date rather // than work and carries no hours at all. const HOURS_PER_DAY=8;-function taskHours(r){ return isMs(r)?0:(r.start&&r.end?bizBetween(r.start,r.end)*HOURS_PER_DAY:0); } // Hours, always in hours. A column that says 80h on one row and 23d on the next is a // column the reader has to convert in their head before they can compare two numbers // in it, which is the one thing a totals table exists to save them. The separator is@@ -11661,15 +11734,16 @@ function analyticsModel(){   const rows=items.filter(r=>!isGroup(r));   const tasks=rows.filter(r=>!isMs(r)&&r.start&&r.end);   const ms=rows.filter(r=>isMs(r)&&r.date);-  // effortHoursOf, not taskHours: cancelled work is not effort anybody owes, so it is-  // out of every hours figure on this lens. It is gated at the three ACCUMULATORS-  // (here, the owner bucket and the section reduce) rather than inside taskHours,-  // because the row's own hours are still its hours: the bar, the tooltip, the undo-  // snapshot and the export all go on saying what they always said, and it is only the-  // sums that stop including it. This total is also the divisor for the per-person-  // share bars, so cancelling work makes everyone else's percentage go up, which is-  // the true reading: their share of what is left to do really did grow.+  // effortHoursOf: a task's Effort estimate (0.63.0), nothing when it has none, and+  // nothing when it is cancelled, because cancelled work is not effort anybody owes. The+  // row keeps its estimate (the Table, the undo snapshot and the export still say it);+  // only the sums stop including it. This total is also the divisor for the per-person+  // share bars, so cancelling work makes everyone else's percentage go up, which is the+  // true reading: their share of what is left to do really did grow.   const totalHours=tasks.reduce((a,r)=>a+effortHoursOf(r),0);+  // How complete those totals are: live tasks, and how many of them nobody has estimated.+  const liveTasks=tasks.filter(r=>!isCancelled(r)).length;+  const unest=tasks.filter(unestimated).length;   const dates=[];   tasks.forEach(r=>{dates.push(r.start,r.end);});   ms.forEach(r=>dates.push(r.date));@@ -11681,7 +11755,7 @@ function analyticsModel(){   const byOwner=new Map();   const bucket=o=>{     const k=o||'';-    if(!byOwner.has(k))byOwner.set(k,{owner:k,hours:0,tasks:0,ms:0,status:{}});+    if(!byOwner.has(k))byOwner.set(k,{owner:k,hours:0,tasks:0,ms:0,unest:0,status:{}});     return byOwner.get(k);   };   rows.forEach(r=>{@@ -11693,7 +11767,7 @@ function analyticsModel(){     // The task COUNT still counts it and the status column still counts it: a     // cancelled row is a real decision somebody made and the card has to show it.     // Only the hours stop.-    b.tasks++; b.hours+=effortHoursOf(r);+    b.tasks++; b.hours+=effortHoursOf(r); if(unestimated(r))b.unest++;   });   const people=[...byOwner.values()]     .filter(b=>b.tasks||b.ms)@@ -11711,7 +11785,7 @@ function analyticsModel(){     mt.forEach(r=>{ds.push(r.start,r.end);});     mine.filter(r=>isMs(r)&&r.date).forEach(r=>ds.push(r.date));     return {i,name:ph.name,color:phaseColor(i),items:mine.length,-            hours:mt.reduce((a,r)=>a+effortHoursOf(r),0),+            hours:mt.reduce((a,r)=>a+effortHoursOf(r),0), unest:mt.filter(unestimated).length,             from:ds.length?ds.reduce((m,d)=>dayNum(d)<dayNum(m)?d:m):'',             to:ds.length?ds.reduce((m,d)=>dayNum(d)>dayNum(m)?d:m):''};   });@@ -11757,7 +11831,7 @@ function analyticsModel(){   const loaded=people.filter(b=>b.owner&&b.hours>0);   const assignedHours=loaded.reduce((a,b)=>a+b.hours,0);   const avgHours=loaded.length?Math.round(assignedHours/loaded.length):0;-  return {rows,tasks,ms:ms.sort((a,b)=>dayNum(a.date)-dayNum(b.date)),totalHours,+  return {rows,tasks,ms:ms.sort((a,b)=>dayNum(a.date)-dayNum(b.date)),totalHours,liveTasks,unest,           first,last,spanBiz,people,status,lanes,overdue,           remainBiz,remainingHours,loaded:loaded.length,assignedHours,avgHours,tagRows,untagged,           deps:{edges:g.edges,isolated:noDeps,cycle:cyc},today};@@ -11773,11 +11847,13 @@ function renderAnalytics(){    // 1. the plan in seven numbers: what it costs, what is left, and how it is spread   out.push(card('wide','Project total',-    'Working days only, Monday to Friday and never a US federal holiday, at '+HOURS_PER_DAY+'h a day, so a five day task is '+(HOURS_PER_DAY*5)+'h. '+    'Effort is each task\'s estimate in hours, set in the Table\'s Effort column or the task editor (2d is two 8-hour days). '+    +'A bar is the window the work happens in, not what it costs, so a task nobody has estimated adds nothing to any total here and is counted as not estimated. '     +'Hours left is the calendar capacity from today to the last dated row, counting today when today is a working day. '     +'The average divides the hours that have an owner by the '+m.loaded+' '+(m.loaded===1?'person':'people')+' carrying them, so the unassigned bucket is in neither half of it.',     '<div class="an-hero">'-    +stat(m.totalHours.toLocaleString(),'h','total effort')+    +stat(m.totalHours.toLocaleString(),'h','total effort, from estimates')+    +stat((m.liveTasks-m.unest)+' of '+m.liveTasks,'','tasks estimated'+(m.unest?', '+m.unest+' not yet':''))     +stat(m.remainingHours.toLocaleString(),'h','working hours left to project end'+(m.last?', '+fmt(m.last):''))     +stat(m.avgHours.toLocaleString(),'h','average per person assigned')     +stat(String(m.spanBiz),'working days','calendar span'+(m.first?', '+fmt(m.first)+' to '+fmt(m.last):''))@@ -11801,14 +11877,15 @@ function renderAnalytics(){       +'<td class="n">'+esc(hrs(b.hours))+'</td>'       +'<td class="n muted">'+pct(b.hours,m.totalHours)+'%</td>'       +'<td class="n muted">'+b.tasks+(b.ms?' +'+b.ms+'M':'')+'</td>'+      +'<td class="n'+(b.unest?'':' muted')+'">'+b.unest+'</td>'       +STATUS_ORDER.map(k=>'<td class="n'+((b.status[k]||0)?'':' muted')+'">'+(b.status[k]||0)+'</td>').join('')       +'</tr>').join('');   out.push(card('wide','Per person',     'Every owner in the plan, unassigned included, sorted by hours. Milestones are dates rather than work, so they carry no hours and are counted separately as +nM, but they do carry a status and are counted in the '+STATUS_ORDER.length+' status columns on the right. Cancelled work still counts as an item and still shows in its column; its hours do not, so a cancelled task leaves the count alone and takes its hours out of the share.',-    m.people.length?('<table class="an-table people"><thead><tr><th>Owner</th><th>Share</th><th class="n">Hours</th><th class="n">%</th><th class="n">Items</th>'+    m.people.length?('<table class="an-table people"><thead><tr><th>Owner</th><th>Share</th><th class="n">Hours</th><th class="n">%</th><th class="n">Items</th><th class="n" title="Live tasks with no Effort estimate. They add nothing to the hours">Not est.</th>'       +STATUS_ORDER.map(k=>'<th class="n">'+esc(statusTextFor(k))+'</th>').join('')       +'</tr></thead><tbody>'-      +ph+'<tr class="total"><td>Total</td><td></td><td class="n">'+esc(hrs(m.totalHours))+'</td><td class="n">100%</td><td class="n">'+m.tasks.length+(m.ms.length?' +'+m.ms.length+'M':'')+'</td>'+      +ph+'<tr class="total"><td>Total</td><td></td><td class="n">'+esc(hrs(m.totalHours))+'</td><td class="n">100%</td><td class="n">'+m.tasks.length+(m.ms.length?' +'+m.ms.length+'M':'')+'</td><td class="n">'+m.unest+'</td>'       +STATUS_ORDER.map(k=>'<td class="n">'+stTot[k]+'</td>').join('')       +'</tr></tbody></table>'):'<div class="an-empty">Nothing in this plan carries dates yet.</div>')); @@ -11823,10 +11900,11 @@ function renderAnalytics(){   // 4. phases   const lr=m.lanes.map(l=>'<tr><td class="name"><span class="an-chip" style="background:'+esc(l.color)+'"></span>'+esc(l.name)+'</td>'       +'<td class="n">'+l.items+'</td><td class="n">'+esc(hrs(l.hours))+'</td>'+      +'<td class="n'+(l.unest?'':' muted')+'">'+l.unest+'</td>'       +'<td class="n muted">'+(l.from?esc(fmt(l.from))+' to '+esc(fmt(l.to)):'no dates')+'</td></tr>').join('');   out.push(card('wide','Per section','Items, effort and the range the section actually covers, which is derived from its rows and not declared.',-    m.lanes.length?('<table class="an-table"><thead><tr><th>Section</th><th class="n">Items</th><th class="n">Hours</th><th class="n">Range</th></tr></thead><tbody>'-      +lr+'<tr class="total"><td>Total</td><td class="n">'+m.rows.length+'</td><td class="n">'+esc(hrs(m.totalHours))+'</td>'+    m.lanes.length?('<table class="an-table"><thead><tr><th>Section</th><th class="n">Items</th><th class="n">Hours</th><th class="n" title="Live tasks with no Effort estimate">Not est.</th><th class="n">Range</th></tr></thead><tbody>'+      +lr+'<tr class="total"><td>Total</td><td class="n">'+m.rows.length+'</td><td class="n">'+esc(hrs(m.totalHours))+'</td><td class="n">'+m.unest+'</td>'       +'<td class="n">'+(m.first?esc(fmt(m.first))+' to '+esc(fmt(m.last)):'')+'</td></tr></tbody></table>'):'<div class="an-empty">This plan has no sections.</div>'));    // 4b. tags (0.60.0), only when the plan has any: a card that says "no tags" on every
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.62.1",+  "version": "0.63.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.62.1",+  "version": "0.63.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": {
server.js+44−3
@@ -656,6 +656,15 @@ function describeEvent(e) {     if (!t) return `${who} left ${name} unassigned (was ${f})${via}`;     return `${who} reassigned ${name} from ${f} to ${t}${via}`;   }+  // Effort (0.63.0). Read defensively like every other line: a side that is not a+  // number reads as "not estimated".+  if (action === 'effort') {+    const side = s2 => { const o = e[s2]; const v = o && typeof o === 'object' ? o.effort : undefined; return typeof v === 'number' && isFinite(v) ? v : null; };+    const f = side('from'), t = side('to');+    const h = v => (v === null ? 'not estimated' : `${v}h`);+    if (t === null) return `${who} cleared the effort on ${name} (was ${h(f)})${via}`;+    return `${who} set the effort on ${name} to ${h(t)} (was ${h(f)})${via}`;+  }   if (action === 'status') {     return `${who} set ${name} status ${statusText(fld('from', 'status', '')) || 'none'} -> ${statusText(fld('to', 'status', '')) || 'none'}${via}`;   }@@ -901,6 +910,26 @@ function setOpt(r, k, v) { // different things from an empty result: the data file stores no `tags` key at // all (absent, not []), so a plan with no tags round-trips byte for byte, while // the sidecar keeps an explicit [] because there it means "the user cleared it".+// Effort (0.63.0): a task's estimated work in HOURS, separate from its dates. A bar is+// the window the work happens in, and through 0.62 Analytics read the bar as one person+// full time for every working day of it, which put a plan of 154 eight-week windows at+// 38,000 hours. Effort is what the work costs; a task without it is "not estimated" and+// adds nothing to any total. A number, or a numeric string, from 0 to 100000, kept to+// one decimal; anything else is no effort at all. 0 is an estimate (no work), not blank.+const EFFORT_MAX = 100000;+function normalizeEffort(v) {+  if (typeof v === 'string') { if (!v.trim()) return undefined; v = Number(v.trim()); }+  if (typeof v !== 'number' || !isFinite(v) || v < 0 || v > EFFORT_MAX) return undefined;+  return Math.round(v * 10) / 10;+}+// Data-file rule: a valid number or no key.+function tidyEffort(o) {+  if (!o || typeof o !== 'object' || !('effort' in o)) return o;+  const e = normalizeEffort(o.effort);+  // A milestone is a date, not work, so it carries none.+  if (e === undefined || o.type === 'milestone') delete o.effort; else o.effort = e;+  return o;+} const TAG_MAX_LEN = 32, TAG_MAX_COUNT = 12; function normalizeTags(v) {   if (!Array.isArray(v)) return undefined;@@ -969,7 +998,7 @@ function tidyTags(o) { function tidyPlanTags(data) {   if (!data || typeof data !== 'object') return data;   if (Array.isArray(data.phases)) data.phases.forEach(tidyTags);-  if (Array.isArray(data.items)) data.items.forEach(tidyTags);+  if (Array.isArray(data.items)) data.items.forEach(o => { tidyTags(o); tidyEffort(o); });   tidyTagRegistry(data.project);   return data; }@@ -984,7 +1013,14 @@ function tidyStateTags(st) {   };   if (Array.isArray(st.items)) st.items.forEach(side);   if (Array.isArray(st.phases)) st.phases.forEach(side);-  if (Array.isArray(st.added)) st.added.forEach(tidyTags);+  if (Array.isArray(st.added)) st.added.forEach(o => { tidyTags(o); tidyEffort(o); });+  // A row override's effort: null is "cleared" and is kept; a number is normalised; any+  // other value is dropped, so the data file's effort stands.+  if (Array.isArray(st.items)) st.items.forEach(o => {+    if (!o || typeof o !== 'object' || !('effort' in o) || o.effort === null) return;+    const e = normalizeEffort(o.effort);+    if (e === undefined) delete o.effort; else o.effort = e;+  });   // The sidecar's registry: absent means the data file's stands, [] means it was emptied.   if ('tags' in st) { const t = normalizeTagRegistry(st.tags); if (t) st.tags = t; else delete st.tags; }   return st;@@ -1475,6 +1511,8 @@ function mergedData() {     setProv(r, sv.prov);     // Absent: the data file's tags stand. [] : the user cleared them.     if (Array.isArray(sv.tags)) { if (sv.tags.length) r.tags = sv.tags.slice(); else delete r.tags; }+    // Effort (0.63.0). Absent: the data file's stands. null: cleared. A number: set.+    if ('effort' in sv) { if (sv.effort === null) delete r.effort; else { const ef = normalizeEffort(sv.effort); if (ef !== undefined) r.effort = ef; } }     if (sv.name) r.name = sv.name;     if (Number.isInteger(sv.phase) && sv.phase >= 0 && sv.phase < phaseCount) r.phase = sv.phase;   });@@ -1599,7 +1637,7 @@ function csvUnguard(s) { return (typeof s === 'string' && s.length > 1 && s[0] = function itemsToCSV(items, phases) {   // uid leads, because it is the column that survives everything else in the row   // being edited in the spreadsheet, and /import/csv reads it back.-  const headers = ['uid', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'tags', 'type', 'label', 'date', 'striped'];+  const headers = ['uid', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'tags', 'effort', 'type', 'label', 'date', 'striped'];   const escape = v => {     if (v == null) return '';     const s = csvGuard(String(v));@@ -1617,6 +1655,7 @@ function itemsToCSV(items, phases) {     const row = headers.map(h => {       if (h === 'deps') return escape((item.deps || []).join(';'));       if (h === 'tags') return escape((normalizeTags(item.tags) || []).join(';'));+      if (h === 'effort') { const ef = normalizeEffort(item.effort); return ef === undefined ? '' : String(ef); }       if (h === 'phase') return escape(phaseName(item.phase));       // Two columns rather than one packed cell, so an export re-imported keeps       // the label and the link as separate things.@@ -1723,6 +1762,8 @@ function importCSV(body, queryString) {     if (deps.length) { item.deps = deps; }     const tags = normalizeTags(row.tags ? row.tags.split(';') : []);     if (tags.length) { item.tags = tags; }+    const ef = normalizeEffort(row.effort);+    if (ef !== undefined && item.type !== 'milestone') { item.effort = ef; }     if (row.label) { item.label = row.label; }     if (row.striped === 'true') { item.striped = true; } 
test/server_test.mjs+40
@@ -379,6 +379,46 @@ async function main() {   const missingD = linesD.filter(l => !mdD.includes(l));   ok(missingD.length === 0, 'the markdown changelog reads each tag.* event as a sentence, junk fields included', missingD);   stop(D);++  // ── Effort (0.63.0): estimated hours per task ──+  const ep = basePlan(false);+  ep.items[0].effort = '12.25'; ep.items[1].effort = -3; ep.items[2].effort = 40;+  const E = await start('effort', ep, null, 8946);+  let ee = await getJ(E, '/export/json');+  ok(byId(ee, 'etch').effort === 12.3, 'a data-file effort is normalised: a numeric string, to one decimal', byId(ee, 'etch'));+  ok(!('effort' in byId(ee, 'ship')), 'a negative effort is dropped', byId(ee, 'ship'));+  ok(!('effort' in byName(ee, 'Launch')), 'a milestone carries no effort', byName(ee, 'Launch'));+  const N0 = await start('noeffort', basePlan(false), null, 8947);+  ok(!JSON.stringify(await getJ(N0, '/export/json')).includes('"effort"'), 'a plan that never used effort exports no effort key');+  stop(N0);+  const est = await getJ(E, '/state');+  const eu = id => byId(ee, id).uid;+  const es = await post(E, '/save', { v: 4, baseRev: est.stateRev,+    items: [{ uid: eu('etch'), effort: null }, { uid: eu('ship'), effort: 'x' }], phases: [], order: [], deleted: [],+    added: [{ type: 'task', uid: '00090', id: 'new1', name: 'New one', start: '2026-10-05', end: '2026-10-06', phase: 0, effort: 6 }] }, { Authorization: 'Bearer ' + E.token });+  ok(es.status === 200, '/save with effort fields is 200 (a junk value is dropped, not a 500)', es);+  ee = await getJ(E, '/export/json');+  ok(!('effort' in byId(ee, 'etch')), 'null in the sidecar clears a data-file effort', byId(ee, 'etch'));+  ok(!('effort' in byId(ee, 'ship')), 'a junk sidecar effort leaves the data file\'s (none) standing', byId(ee, 'ship'));+  ok(byId(ee, 'new1') && byId(ee, 'new1').effort === 6, 'a created row keeps its effort', byId(ee, 'new1'));+  const ecsv = await getT(E, '/export/csv');+  const eh = ecsv.split('\n')[0].split(','), ei = eh.indexOf('effort');+  ok(ei > 0 && ecsv.split('\n').find(l => l.includes(',new1,')).split(',')[ei] === '6', 'the CSV has an effort column', eh);+  const imp2 = await post(E, '/import/csv', 'name,start,end,date,phase,effort,type\nA,2026-10-05,2026-10-09,,Tech · Fab,2.5,task\nB,2026-10-05,2026-10-09,,Tech · Fab,abc,task\nC,,,2026-10-20,Tech · Fab,8,milestone\n', { 'Content-Type': 'text/csv', Authorization: 'Bearer ' + E.token });+  ee = await getJ(E, '/export/json');+  ok(imp2.status === 200 && byName(ee, 'A').effort === 2.5 && !('effort' in byName(ee, 'B')) && !('effort' in byName(ee, 'C')), 'CSV import reads effort, drops junk, and gives a milestone none', [imp2.status, imp2.t && imp2.t.slice(0, 200), byName(ee, 'A'), byName(ee, 'B'), byName(ee, 'C')]);+  const lgE = await post(E, '/log', { actor: 'user', events: [+    { action: 'effort', id: 'a', name: 'Etch', from: { effort: null }, to: { effort: 16 }, via: 'table-edit' },+    { action: 'effort', id: 'a', name: 'Etch', from: { effort: 16 }, to: { effort: 40 }, via: 'item-editor' },+    { action: 'effort', id: 'a', name: 'Etch', from: { effort: 40 }, to: { effort: null }, via: 'undo' },+    { action: 'effort', id: 'a', name: 'Etch', from: 'junk', to: { effort: 'x' } },+  ] }, { Authorization: 'Bearer ' + E.token });+  ok(lgE.status === 200, '/log accepts effort events', lgE);+  const mdE = await getT(E, '/changelog?format=md');+  const wantE = ['set the effort on "Etch" to 16h (was not estimated) (table-edit)', 'set the effort on "Etch" to 40h (was 16h) (item-editor)', 'cleared the effort on "Etch" (was 40h) (undo)', 'cleared the effort on "Etch" (was not estimated)'];+  const missE = wantE.filter(l => !mdE.includes(l));+  ok(missE.length === 0, 'the markdown changelog reads effort events as sentences, junk included', missE);+  stop(E); }  try { await main(); }
test/ui_test.mjs+116−9
@@ -834,6 +834,7 @@ async function main() {     if (wantAny('sections')) await sectionsFlow(browser);     if (wantAny('tags')) await tagsFlow(browser);     if (wantAny('row tags')) await rowTagsFlow(browser);+    if (wantAny('effort')) await effortFlow(browser);      if (wantAny('viewport')) {       // ── Viewport matrix ──@@ -1114,6 +1115,8 @@ const MANIFEST = [   // Row tags (0.60.0): the tag box in the item editor and the New panel, its chip x, and the   // batch panel's add and remove. They change the plan; the `row tags` flow proves each.   { sel: '#imTagsIn', label: 'item editor: tags', terminal: true, by: 'row tags' },+  { sel: '#imEffort', label: 'item editor: effort', terminal: true, by: 'effort' },+  { sel: '#ncEffort', label: 'new: effort', terminal: true, by: 'effort' },   { sel: '#ncTagsIn', label: 'new: tags', terminal: true, by: 'row tags' },   { sel: '.tb-x', label: 'tag box: remove a chip', terminal: true, by: 'row tags', key: c => c.ds.tb, pin: ['im'] },   { sel: '#bTag', label: 'batch: tag to add', terminal: true, by: 'row tags' },@@ -2419,14 +2422,14 @@ async function tagsFlow(browser) { async function rowTagsFlow(browser) {   section = 'row tags';   const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, ...x });-  // Hours, by hand (working days x 8h; Mon 5-Oct to Fri 9-Oct 2026 is 5 working days):-  //   Fab one 40, Fab two 40, Ops one 40, Ops two 40, Misc one (Tue 6 to Wed 7) 16,-  //   Ops dropped 0 (cancelled; it would be 10 days, 80h). Total 176h.+  // Hours (0.63.0: Effort estimates, seeded to what the bars used to be worth):+  //   Fab one 40, Fab two 40, Ops one 40, Ops two 40, Misc one 16,+  //   Ops dropped 0 (cancelled; its 80h estimate does not count). Total 176h.   const X = await startServer({ project: { title: 'Row Tags Test', appName: 'row-tags-test', view: { mode: 'gantt', grain: 'week', labelWidth: 300 } },     phases: [{ name: 'Tech · Fab', start: '2026-09', end: '2026-12', tags: ['Tech'] }, { name: 'Ops', start: '2026-09', end: '2026-12' }, { name: 'Misc', start: '2026-09', end: '2026-12' }],-    items: [T('f1', 'Fab one', 0), T('f2', 'Fab two', 0), { type: 'milestone', name: 'Fab gate', date: '2026-10-20', phase: 0 },-      T('o1', 'Ops one', 1, { tags: ['long-lead'] }), T('o2', 'Ops two', 1), T('o3', 'Ops dropped', 1, { end: '2026-10-16', status: 'cancelled', tags: ['long-lead'] }),-      T('m1', 'Misc one', 2, { start: '2026-10-06', end: '2026-10-07' })] }, 'row-tags');+    items: [T('f1', 'Fab one', 0, { effort: 40 }), T('f2', 'Fab two', 0, { effort: 40 }), { type: 'milestone', name: 'Fab gate', date: '2026-10-20', phase: 0 },+      T('o1', 'Ops one', 1, { tags: ['long-lead'], effort: 40 }), T('o2', 'Ops two', 1, { effort: 40 }), T('o3', 'Ops dropped', 1, { end: '2026-10-16', status: 'cancelled', tags: ['long-lead'], effort: 80 }),+      T('m1', 'Misc one', 2, { start: '2026-10-06', end: '2026-10-07', effort: 16 })] }, 'row-tags');   let page = await openPage(browser, X.url);   const redo = async p => { p = p || page; await p.keyboard.down('Control'); await p.keyboard.down('Shift'); await p.keyboard.press('KeyZ'); await p.keyboard.up('Shift'); await p.keyboard.up('Control'); await settle(p, 250); };   const blur = () => page.evaluate(() => document.activeElement && document.activeElement.blur());@@ -2698,10 +2701,10 @@ async function rowTagsFlow(browser) {   an = await anTags();   // Tech: Fab one, Fab two, Fab gate (80h). hot/batchy: Fab one 40h; batchy also Misc one 16h -> 56h.   // long-lead: Ops one, Ops two, Ops dropped -> 80h (dropped cancelled). urgent, late: Ops two 40h.-  // fresh: New one (Mon 5-Oct.. its dates are the New panel's defaults: today 29-Sep + 7 = 6-Oct, 29-Sep to 6-Oct, 6 working days = 48h).-  // Total 176 + 48 = 224h. No tag: Fab two? no (Tech). Nothing is untagged now: 0.+  // fresh: New one, made with no Effort estimate, so 0h (0.63.0; its bar no longer counts).+  // Total 176h. No tag: Fab two? no (Tech). Nothing is untagged now: 0.   check('the per-tag card follows the edits (hand-checked)', JSON.stringify(an) === JSON.stringify([-    'Tech|2 +1M|80h|35.7%', 'hot|1|40h|17.9%', 'batchy|2|56h|25%', 'long-lead|3|80h|35.7%', 'urgent|1|40h|17.9%', 'late|1|40h|17.9%', 'fresh|1|48h|21.4%', 'no tag|0|0h|0%']), JSON.stringify(an));+    'Tech|2 +1M|80h|45.5%', 'hot|1|40h|22.7%', 'batchy|2|56h|31.8%', 'long-lead|3|80h|45.5%', 'urgent|1|40h|22.7%', 'late|1|40h|22.7%', 'fresh|1|0h|0%', 'no tag|0|0h|0%']), JSON.stringify(an));    // ── Filter after the edits: a row tag in a tagged section, and in an untagged one ──   await page.click('.tag-chip[data-tag="batchy"]'); await settle(page, 200);@@ -2787,6 +2790,110 @@ async function rowTagsFlow(browser) { // The normal path, an external SIGINT/SIGTERM (Ctrl+C, a CI timeout) and the watchdog // all come through here, so no server, Chrome or temp dir is left behind. let BROWSER = null, cleanedUp = false, UNTESTED = null;+// ── Effort (0.63.0): estimated hours per task, and Analytics counting only those ──+// Four-week bars, so the old rule (every bar = one person full time) would have put each+// at 152h. Effort is what counts now: 16 + 8 = 24h, the cancelled 40 out, one task not+// estimated, and the milestone's 99 dropped because a milestone is a date, not work.+async function effortFlow(browser) {+  section = 'effort';+  const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-30', phase: ph, owner: 'Drew', ...x });+  const X = await startServer({ project: { title: 'Effort Test', appName: 'effort-test', view: { mode: 'gantt', grain: 'week', milestones: 'diamonds' } },+    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],+    items: [T('b1', 'Build one', 0, { effort: 16 }), T('b2', 'Build two', 0), T('s1', 'Ship one', 1, { effort: '8', owner: 'Ana' }),+      T('s2', 'Ship dropped', 1, { effort: 40, status: 'cancelled', owner: 'Ana' }), { type: 'milestone', name: 'Ship gate', date: '2026-11-02', phase: 1, effort: 99 }] }, 'effort');+  let page = await openPage(browser, X.url);+  const md = async () => (await fetch(X.url + 'changelog?format=md')).text();+  const row = async id => ((await savedPlan(X.url)).items || []).find(r => r.id === id) || {};+  const effLog = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'effort');+  const hero = async () => { await view(page, 'analytics'); const r = await page.$$eval('#anScroll .an-hero .an-stat', es => es.map(e => e.querySelector('.k').textContent + '=' + e.querySelector('.v').textContent)); return r; };+  const table = sel => page.$$eval(sel + ' tbody tr', rs => rs.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.trim()).filter(Boolean).join('|')));+  let p = await exportPlan(X.url);+  check('a data-file effort is normalised ("8" -> 8) and a milestone carries none', p.items.find(r => r.id === 's1').effort === 8 && !('effort' in p.items.find(r => r.type === 'milestone')), JSON.stringify(p.items.map(r => [r.name, r.effort])));+  let h = await hero();+  check('total effort is the sum of the ESTIMATES (16 + 8), not the bars, and the cancelled 40 is out', h.some(x => x === 'total effort, from estimates=24h'), JSON.stringify(h));+  check('the total card says how complete it is: 2 of 3 live tasks estimated, 1 not yet', h.some(x => x === 'tasks estimated, 1 not yet=2 of 3'), JSON.stringify(h));+  const people = await page.$$eval('#anScroll .an-table.people tbody tr', rs => rs.map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent.trim())).map(c => [c[0], c[2], c[5]].join('|')));+  // (the milestone has no owner, so the unassigned bucket is there with 0h and +1M)+  check('per person: Drew 16h with 1 not estimated, Ana 8h with none', JSON.stringify(people) === JSON.stringify(['Drew|16h|1', 'Ana|8h|0', 'unassigned|0h|0', 'Total|24h|1']), JSON.stringify(people));+  const sec = await page.$$eval('#anScroll .an-card', cs => { const c = cs.find(x => x.querySelector('h3').textContent === 'Per section'); return [...c.querySelectorAll('tbody tr')].map(tr => [...tr.querySelectorAll('td')].slice(0, 4).map(td => td.textContent.trim()).join('|')); });+  check('per section: Build 16h, 1 not estimated; Ship 8h, none', JSON.stringify(sec) === JSON.stringify(['Build|2|16h|1', 'Ship|3|8h|0', 'Total|5|24h|1']), JSON.stringify(sec));+  await shot(page, 'effort-analytics');++  // ── The Table's Effort column ──+  await view(page, 'table');+  const cell = id => `#tableView tr[data-key="t:${id}"] td[data-field="effort"]`;+  const cellText = id => page.$eval(cell(id), td => td.textContent.trim());+  check('the Table has an Effort column: 16h, and "not est." where there is none', await cellText('b1') === '16h' && await cellText('b2') === 'not est.', (await cellText('b1')) + ' / ' + (await cellText('b2')));+  check('a milestone has no Effort cell to edit', await page.$$eval('#tableView tr.is-ms td.c-effort', tds => tds.length > 0 && tds.every(td => !td.classList.contains('editable') && !td.dataset.field)));+  let n0 = (await effLog()).length;+  await page.click(cell('b2')); await settle(page, 100);+  await page.keyboard.type('2d'); await page.keyboard.press('Enter'); await settle(page, 300);+  check('typing 2d sets 16 hours (two 8-hour days)', (await row('b2')).effort === 16 && await cellText('b2') === '16h', JSON.stringify(await row('b2')));+  let L = (await effLog()).slice(n0);+  check('one `effort` line via table-edit, from not estimated to 16', L.length === 1 && L[0].via === 'table-edit' && L[0].from.effort === null && L[0].to.effort === 16, JSON.stringify(L));+  check('and it reads as a sentence', (await md()).includes('set the effort on "Build two" to 16h (was not estimated) (table-edit)'), (await md()).split('\n').filter(l => /effort/.test(l)).join(' | '));+  h = await hero(); await view(page, 'table');+  check('Analytics follows: 40h, 3 of 3 estimated', h.includes('total effort, from estimates=40h') && h.some(x => x.startsWith('tasks estimated=3 of 3')), JSON.stringify(h));+  await undo(page); await settle(page, 300);+  check('Ctrl+Z takes it back to not estimated', !('effort' in (await row('b2'))) && await cellText('b2') === 'not est.', JSON.stringify(await row('b2')));+  await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page, 300);+  check('redo puts the 16 back', (await row('b2')).effort === 16);+  await page.click(cell('b2')); await settle(page, 100);+  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control'); await page.keyboard.type('lots'); await page.keyboard.press('Enter'); await settle(page, 300);+  check('something that is not hours is refused, and nothing changes', (await row('b2')).effort === 16 && await cellText('b2') === '16h');+  // Cleared, on a row whose effort came from the DATA FILE: the sidecar has to say so.+  await page.click(cell('b1')); await settle(page, 100);+  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control'); await page.keyboard.press('Backspace'); await page.keyboard.press('Enter'); await settle(page, 300);+  check('emptying the cell clears a data-file estimate', !('effort' in (await row('b1'))) && await cellText('b1') === 'not est.', JSON.stringify(await row('b1')));+  await page.click('#tableHead th[data-sort="effort"]'); await settle(page, 200);+  const order = await page.$$eval('#tableView tbody tr[data-key] td[data-field="effort"]', tds => tds.map(td => td.closest('tr').dataset.key));+  check('the column sorts by hours, the not-estimated rows last', JSON.stringify(order) === JSON.stringify(['t:s1', 't:b2', 't:s2', 't:b1']), JSON.stringify(order));+  await page.click('#tableHead th[data-sort="effort"]'); await page.click('#tableHead th[data-sort="effort"]'); await settle(page, 200);++  // ── The task editor ──+  await view(page, 'gantt');+  await openItemEditor(page, 'Ship one');+  check('the task editor has an effort field holding the estimate', await page.$eval('#imEffort', e => e.value) === '8');+  await setVal(page, '#imEffort', 'eight'); await page.click('#imApply'); await settle(page, 200);+  check('a bad value is refused with a reason and the editor stays open', await editorShown(page) && /Effort is hours/.test(await page.$eval('#imErr', e => e.textContent)) && (await row('s1')).effort === 8);+  await setVal(page, '#imEffort', '1.5w'); await page.click('#imApply'); await settle(page, 300);+  check('1.5w is 60 hours, saved on Apply', (await row('s1')).effort === 60, JSON.stringify(await row('s1')));+  await undo(page); await settle(page, 300);+  check('one undo takes the editor\'s effort back', (await row('s1')).effort === 8);+  await openItemEditor(page, 'Ship gate');+  check('a milestone\'s editor has no effort field', await page.$eval('#imEffWrap', e => getComputedStyle(e).display) === 'none');+  await page.keyboard.press('Escape'); await settle(page, 150);++  // ── The New panel ──+  await page.click('#btnNew'); await settle(page, 200);+  await page.type('#ncName', 'Fresh build'); await setVal(page, '#ncEffort', '3');+  await page.click('#ncCreate'); await settle(page, 900);+  const fresh = ((await savedPlan(X.url)).items || []).find(r => r.name === 'Fresh build') || {};+  check('New saves the effort on the row it makes', fresh.effort === 3, JSON.stringify(fresh));+  check('and logs it via create', (await effLog()).some(e => e.name === 'Fresh build' && e.via === 'create' && e.to.effort === 3));+  await page.click('#btnNew'); await settle(page, 200);+  check('the next New panel remembers the effort, like every field but the name', await page.$eval('#ncEffort', e => e.value) === '3');+  await page.select('#ncType', 'milestone'); await settle(page, 100);+  check('and hides it for a milestone', await page.$eval('#ncEffWrap', e => getComputedStyle(e).display) === 'none');+  await page.click('#ncCancel'); await settle(page, 150);++  // ── Copy and paste carry it; CSV carries it; a reload keeps it ──+  await view(page, 'table');+  await page.keyboard.down('Control'); await page.click('#tableView tr[data-key="t:s1"] td.c-deps'); await page.keyboard.up('Control'); await settle(page, 100);+  await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 700);+  const clone = ((await savedPlan(X.url)).items || []).find(r => r.name === 'Ship one 2') || {};+  check('a pasted copy carries the estimate', clone.effort === 8, JSON.stringify(clone));+  const csv = await (await fetch(X.url + 'export/csv')).text();+  const hd = csv.split('\n')[0].split(','), ei = hd.indexOf('effort');+  const csvRow = csv.split('\n').find(l => l.includes(',s1,'));+  check('the CSV has an effort column', ei > 0 && csvRow && csvRow.split(',')[ei] === '8', hd.join(',') + ' :: ' + csvRow);+  await page.reload({ waitUntil: 'networkidle0' }); await settle(page, 300);+  await view(page, 'table');+  check('a reload keeps every estimate, and the cleared one stays cleared', await cellText('b2') === '16h' && await cellText('b1') === 'not est.' && await cellText('s1') === '8h');+  await shot(page, 'effort-table');+  await page.close();+}+ function cleanupSync() {   if (cleanedUp) return; cleanedUp = true;   for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }