From 61cf0ae7b03d316f0db52798fae86120b95acfe6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 16:12:13 +0000 Subject: [PATCH 01/12] Prioritize parent task size and dates over subtasks Parent tasks now keep their own editable size instead of showing a rolled-up subtask point total. Gantt spans use the task's own window as the floor and only expand outward when subtasks fall outside it. Changing a parent task's dates clips subtasks that exceed the new bounds. Projects continue to roll up across all leaves. Co-authored-by: Tanops --- CHANGELOG.md | 8 +++++ src/app/main.js | 23 ++++++------ src/lib/dates.js | 87 +++++++++++++++++++++++++++++++++++++++++++-- tests/dates.test.js | 56 +++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0fc0f..6eab394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Parent task priority over subtasks (2026-06-22) + +### Changed +- Parent tasks keep their own t-shirt size in the detail sheet; subtask sizes no longer replace it. +- Gantt bars for tasks with subtasks use the task's own dates/size as the baseline and only expand when a subtask falls outside that window. +- Editing a parent task's start or end date clips subtasks that extend past the new bounds. +- Projects still roll up dates and size points across all descendant leaves. + ## Rebase PR #14 onto main (2026-06-19) ### Fixed diff --git a/src/app/main.js b/src/app/main.js index 3281b36..22f8ea3 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -238,10 +238,10 @@ function sizeScale(){ /* chart starts at today - no dead space on the left */ let ZOOM = 2, showDone = false; let dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag; + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentSpan; function syncDateHelpers() { ({ dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag } = createDateHelpers(TODAY)); + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentSpan } = createDateHelpers(TODAY, () => DATA)); } syncDateHelpers(); @@ -275,8 +275,8 @@ function scheduleTodayRefresh() { } /* bars carry only FOUR urgency colors (owner identity is in the bubble) */ /* vivid, candy-bright status palette (like the reference) */ -/* a parent task's bar always spans its subtasks (start = earliest sub start, due = latest sub due), - so a parent can never appear to start after its own subtasks */ +/* a parent task's bar spans its own dates/size; subtasks may only extend it outward + (never shrink it). Projects still roll up across all descendant leaves. */ /* effort weight = working days (Mon–Fri) inside a leaf's bar span, so the team doesn't have to size every item — a longer subtask simply weighs more. A set size still counts, because size feeds the bar's span via LEAD, which feeds this. Min 1 so a single-day or weekend-only @@ -894,8 +894,8 @@ function updTask(id,f,v,quiet){ snap(); const n=findPath(id).pop(); if(f==="title") n.title=v.trim()||n.title; else if(f==="owner") n.owner=v; else if(f==="priority") n.priority=v; - else if(f==="due"){ n.due=v||null; syncTaskDates(n,"due"); } - else if(f==="start"){ n.start=v||null; syncTaskDates(n,"start"); } + else if(f==="due"){ n.due=v||null; syncTaskDates(n,"due"); clipLeavesToParentSpan(n); } + else if(f==="start"){ n.start=v||null; syncTaskDates(n,"start"); clipLeavesToParentSpan(n); } else if(f==="size") n.size=v?normalizeSize(v):null; else if(f==="comment") n.comment=v.trim()||null; renderAll(); if(!quiet&&f!=="title"&&f!=="comment") openDetail(id); } @@ -966,10 +966,10 @@ function openDetail(id,opts){ flat(DATA,(x,depth)=>{ if(contains(n,x.id))return; if(depth+1+heightOf(n)>2) return; // keep the 3-level hierarchy mopts.push(``); }); - // Size: leaves carry an editable t-shirt size; projects/parent tasks SHOW the rolled-up - // point total (sum of their leaves' size points) — not a field the user fills in. + // Size: subtasks and parent tasks carry an editable t-shirt size; projects show rolled-up points. let _szPts=0; flat([n],x=>{ if(x.children.length) return; _szPts+=sizePts(x.size); }); - const sizeFld=leaf + const sizeEditable=leaf||path.length===2; + const sizeFld=sizeEditable ? `
Size
` : `
Size${_szPts} pts
`; @@ -989,10 +989,7 @@ function openDetail(id,opts){
Move to
${path.length>=3?"":`
${path.length>1?"Subtasks":"Tasks — grip ⠿ to drag onto another project"}
`} ${n.children.map(ch=>{ const lp=pct(ch), lleaf=!ch.children.length; - let _cp=0; flat([ch],x=>{ if(x.children.length) return; _cp+=sizePts(x.size); }); - const szCtl=lleaf - ? `` - : `${_cp} pts`; + const szCtl=``; return `
diff --git a/src/lib/dates.js b/src/lib/dates.js index df69152..564fb85 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -6,12 +6,38 @@ import { taskDone } from "./tree.js"; export { dayN, dayIso } from "./date-core.js"; -export function createDateHelpers(today) { +export function createDateHelpers(today, getRoots = () => null) { const dayNLocal = (iso) => dayN(iso, today); const dayIsoLocal = (d) => dayIso(d, today); const barSpan = (n) => _barSpan(n, today, LEAD); + function nodeDepth(n) { + const roots = getRoots(); + if (!roots) return null; + let found = null; + const walk = (nodes, d) => { + for (const x of nodes) { + if (x === n) { + found = d; + return true; + } + if (walk(kids(x), d + 1)) return true; + } + return false; + }; + walk(roots, 0); + return found; + } + + /** True for depth-1 tasks whose children are all subtask leaves. */ + function taskWithSubtasks(n) { + const ch = kids(n); + if (!ch.length || !ch.every((c) => !kids(c).length)) return false; + const d = nodeDepth(n); + return d === null ? true : d === 1; + } + function workDays(s, e) { if (isNaN(s) || isNaN(e) || e < s) return 0; let c = 0; @@ -47,7 +73,7 @@ export function createDateHelpers(today) { return [cs, Math.min(Math.max(re, cs + 0.5), r1g)]; } - function rollupSpan(n) { + function childEnvelope(n) { let s = Infinity; let e = -Infinity; flat([n], (x) => { @@ -56,11 +82,62 @@ export function createDateHelpers(today) { if (sp.s < s) s = sp.s; if (sp.e > e) e = sp.e; }); - return e === -Infinity ? barSpan(n) : { s, e }; + return e === -Infinity ? null : { s, e }; + } + + /** Parent task span: own dates/size win; subtasks may only push the bar outward. */ + function rollupSpan(n) { + const env = childEnvelope(n); + if (!env) return barSpan(n); + if (!taskWithSubtasks(n) || !n.due) return env; + const own = barSpan(n); + return { + s: env.s < own.s ? env.s : own.s, + e: env.e > own.e ? env.e : own.e, + }; } const spanFor = (n) => (kids(n).length ? rollupSpan(n) : barSpan(n)); + function clampLeafToSpan(leaf, minS, maxE) { + const sp = barSpan(leaf); + let newS = sp.s; + let newE = sp.e; + const dur = newE - newS; + if (newS < minS) { + newS = minS; + newE = newS + dur; + } + if (newE > maxE) { + newE = maxE; + newS = newE - dur; + } + if (newS < minS) { + newS = minS; + newE = Math.min(newS + dur, maxE); + } + if (newE > maxE) { + newE = maxE; + newS = Math.max(newE - dur, minS); + } + if (newS > newE) { + newS = minS; + newE = maxE; + } + leaf.start = dayIsoLocal(newS); + leaf.due = dayIsoLocal(newE); + } + + /** Shrink subtask dates to fit when a parent task's window narrows. */ + function clipLeavesToParentSpan(n) { + if (!taskWithSubtasks(n) || !n.due) return; + const { s, e } = barSpan(n); + flat([n], (x) => { + if (kids(x).length || !x.due) return; + clampLeafToSpan(x, s, e); + }); + } + function leafWeight(n) { const { s, e } = barSpan(n); const w = workDays(s, e); @@ -140,6 +217,7 @@ export function createDateHelpers(today) { else l.due = shiftIso(l.due, dd); }); n.start = dayIsoLocal(s); + clipLeavesToParentSpan(n); return; } const dd = e - old.e; @@ -155,6 +233,7 @@ export function createDateHelpers(today) { }); n.due = dayIsoLocal(e); if (!n.start) n.start = dayIsoLocal(old.s); + clipLeavesToParentSpan(n); } return { @@ -171,5 +250,7 @@ export function createDateHelpers(today) { isUrgent, fmtD, commitBarDrag, + clipLeavesToParentSpan, + taskWithSubtasks, }; } diff --git a/tests/dates.test.js b/tests/dates.test.js index 65a3cde..f70a61e 100644 --- a/tests/dates.test.js +++ b/tests/dates.test.js @@ -48,6 +48,62 @@ describe("dates", () => { }; const span = h.rollupSpan(parent); expect(span.s).toBeLessThanOrEqual(span.e); + // parent end (Jun 30) wins over subtasks; earliest subtask still extends the left edge + expect(span.s).toBe(2); + expect(span.e).toBe(18); + }); + + it("does not shrink a parent task when subtasks fall inside its dates", () => { + const parent = { + start: "2026-06-15", + due: "2026-06-30", + size: "l", + children: [{ due: "2026-06-20", size: "s", children: [] }], + }; + const own = h.barSpan(parent); + const span = h.spanFor(parent); + expect(span.s).toBe(own.s); + expect(span.e).toBe(own.e); + }); + + it("expands a parent task when a subtask is scheduled outside its dates", () => { + const parent = { + due: "2026-06-20", + size: "m", + children: [{ due: "2026-07-05", size: "s", children: [] }], + }; + const own = h.barSpan(parent); + const span = h.spanFor(parent); + expect(span.s).toBe(own.s); + expect(span.e).toBeGreaterThan(own.e); + expect(span.e).toBe(h.barSpan(parent.children[0]).e); + }); + + it("clips subtasks when a parent task's end date moves earlier", () => { + const parent = { + due: "2026-06-30", + size: "m", + children: [{ due: "2026-07-05", size: "s", children: [] }], + }; + parent.due = "2026-06-22"; + h.clipLeavesToParentSpan(parent); + expect(h.dayN(parent.children[0].due)).toBeLessThanOrEqual(h.dayN(parent.due)); + expect(h.barSpan(parent.children[0]).e).toBeLessThanOrEqual(h.barSpan(parent).e); + }); + + it("still rolls up projects across all descendant leaves", () => { + const project = { + due: "2026-07-15", + size: "m", + children: [ + { due: "2026-06-14", size: "s", children: [] }, + { due: "2026-06-22", size: "m", children: [] }, + ], + }; + const roots = [project]; + const hp = createDateHelpers(calendarToday(new Date(2026, 5, 12)), () => roots); + const span = hp.rollupSpan(project); + expect(span.s).toBe(2); expect(span.e).toBe(10); }); From 39acbba0e75c5e5b33d79a2d6817a51c47b8c38f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 16:24:26 +0000 Subject: [PATCH 02/12] Decouple parent task bar from subtask date edits Tasks with subtasks now always render using their own dates/size on the gantt. Changing a subtask's dates no longer expands or shrinks the parent bar. Parent resize only updates subtasks when the window narrows (clip to explicit start/due), not when extending. Co-authored-by: Tanops --- CHANGELOG.md | 4 ++-- src/app/main.js | 4 ++-- src/lib/dates.js | 52 +++++++-------------------------------------- tests/dates.test.js | 21 ++++++++---------- 4 files changed, 21 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eab394..7d4c8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Changed - Parent tasks keep their own t-shirt size in the detail sheet; subtask sizes no longer replace it. -- Gantt bars for tasks with subtasks use the task's own dates/size as the baseline and only expand when a subtask falls outside that window. -- Editing a parent task's start or end date clips subtasks that extend past the new bounds. +- Gantt bars for tasks with subtasks use only the task's own dates/size; subtask date edits never change the parent bar. +- Parent task gantt resize/drag updates only the parent unless the window shrinks (then subtasks clip to fit). - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/src/app/main.js b/src/app/main.js index 22f8ea3..dae6f54 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -275,8 +275,8 @@ function scheduleTodayRefresh() { } /* bars carry only FOUR urgency colors (owner identity is in the bubble) */ /* vivid, candy-bright status palette (like the reference) */ -/* a parent task's bar spans its own dates/size; subtasks may only extend it outward - (never shrink it). Projects still roll up across all descendant leaves. */ +/* a parent task's bar uses only its own dates/size — subtask edits never change it. + Projects still roll up across all descendant leaves. */ /* effort weight = working days (Mon–Fri) inside a leaf's bar span, so the team doesn't have to size every item — a longer subtask simply weighs more. A set size still counts, because size feeds the bar's span via LEAD, which feeds this. Min 1 so a single-day or weekend-only diff --git a/src/lib/dates.js b/src/lib/dates.js index 564fb85..beaddec 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -85,16 +85,12 @@ export function createDateHelpers(today, getRoots = () => null) { return e === -Infinity ? null : { s, e }; } - /** Parent task span: own dates/size win; subtasks may only push the bar outward. */ + /** Parent tasks use only their own dates/size; subtask edits do not change the bar. */ function rollupSpan(n) { const env = childEnvelope(n); if (!env) return barSpan(n); - if (!taskWithSubtasks(n) || !n.due) return env; - const own = barSpan(n); - return { - s: env.s < own.s ? env.s : own.s, - e: env.e > own.e ? env.e : own.e, - }; + if (taskWithSubtasks(n) && n.due) return barSpan(n); + return env; } const spanFor = (n) => (kids(n).length ? rollupSpan(n) : barSpan(n)); @@ -128,13 +124,14 @@ export function createDateHelpers(today, getRoots = () => null) { leaf.due = dayIsoLocal(newE); } - /** Shrink subtask dates to fit when a parent task's window narrows. */ + /** Shrink subtasks that exceed a parent task's explicit start/due bounds. */ function clipLeavesToParentSpan(n) { if (!taskWithSubtasks(n) || !n.due) return; - const { s, e } = barSpan(n); + const maxE = dayNLocal(n.due); + const minS = n.start ? dayNLocal(n.start) : -Infinity; flat([n], (x) => { if (kids(x).length || !x.due) return; - clampLeafToSpan(x, s, e); + clampLeafToSpan(x, minS, maxE); }); } @@ -175,15 +172,7 @@ export function createDateHelpers(today, getRoots = () => null) { kids(n).forEach((c) => shiftSubtreeDates(c, dd)); } - function rollupLeaves(n) { - const leaves = []; - flat([n], (x) => { - if (!kids(x).length && x.due) leaves.push(x); - }); - return leaves; - } - - /** Apply a bar move/resize; parent tasks with subtasks shift the rollup envelope. */ + /** Apply a bar move/resize; parent tasks keep their own dates when resized. */ function commitBarDrag(n, mode, s, e, s0, e0) { if (!kids(n).length) { if (mode === "move") { @@ -203,36 +192,11 @@ export function createDateHelpers(today, getRoots = () => null) { return; } if (mode === "l") { - const dd = s - old.s; - if (!dd) return; - const leaves = rollupLeaves(n); - let minS = Infinity; - leaves.forEach((l) => { - const ls = barSpan(l).s; - if (ls < minS) minS = ls; - }); - leaves.forEach((l) => { - if (barSpan(l).s !== minS) return; - if (l.start) l.start = shiftIso(l.start, dd); - else l.due = shiftIso(l.due, dd); - }); n.start = dayIsoLocal(s); clipLeavesToParentSpan(n); return; } - const dd = e - old.e; - if (!dd) return; - const leaves = rollupLeaves(n); - let maxE = -Infinity; - leaves.forEach((l) => { - const le = barSpan(l).e; - if (le > maxE) maxE = le; - }); - leaves.forEach((l) => { - if (barSpan(l).e === maxE) l.due = shiftIso(l.due, dd); - }); n.due = dayIsoLocal(e); - if (!n.start) n.start = dayIsoLocal(old.s); clipLeavesToParentSpan(n); } diff --git a/tests/dates.test.js b/tests/dates.test.js index f70a61e..c400641 100644 --- a/tests/dates.test.js +++ b/tests/dates.test.js @@ -37,7 +37,7 @@ describe("dates", () => { expect(h.spanFor(leaf).e).toBe(8); }); - it("rolls parent span across child leaves", () => { + it("uses only the parent task's own span regardless of subtask dates", () => { const parent = { due: "2026-06-30", size: "m", @@ -46,11 +46,9 @@ describe("dates", () => { { due: "2026-06-22", size: "m", children: [] }, ], }; + const own = h.barSpan(parent); const span = h.rollupSpan(parent); - expect(span.s).toBeLessThanOrEqual(span.e); - // parent end (Jun 30) wins over subtasks; earliest subtask still extends the left edge - expect(span.s).toBe(2); - expect(span.e).toBe(18); + expect(span).toEqual(own); }); it("does not shrink a parent task when subtasks fall inside its dates", () => { @@ -66,17 +64,15 @@ describe("dates", () => { expect(span.e).toBe(own.e); }); - it("expands a parent task when a subtask is scheduled outside its dates", () => { + it("does not expand a parent task when a subtask is scheduled outside its dates", () => { const parent = { due: "2026-06-20", size: "m", children: [{ due: "2026-07-05", size: "s", children: [] }], }; const own = h.barSpan(parent); - const span = h.spanFor(parent); - expect(span.s).toBe(own.s); - expect(span.e).toBeGreaterThan(own.e); - expect(span.e).toBe(h.barSpan(parent.children[0]).e); + parent.children[0].due = "2026-07-10"; + expect(h.spanFor(parent)).toEqual(own); }); it("clips subtasks when a parent task's end date moves earlier", () => { @@ -125,7 +121,7 @@ describe("dates", () => { expect(h.spanFor(parent).e).toBe(before.e + 3); }); - it("resizes a parent task rollup from the right edge", () => { + it("extends only the parent task when its bar is resized on the right", () => { const parent = { due: "2026-06-30", size: "m", @@ -135,8 +131,9 @@ describe("dates", () => { ], }; const before = h.spanFor(parent); + const subDue = parent.children[1].due; h.commitBarDrag(parent, "r", before.s, before.e + 2, before.s, before.e); expect(h.spanFor(parent).e).toBe(before.e + 2); - expect(h.dayN(parent.children[1].due)).toBe(h.dayN("2026-06-24")); + expect(parent.children[1].due).toBe(subDue); }); }); From bcba3c0a714038154d6344baa856c8dbe6196297 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 17:00:44 +0000 Subject: [PATCH 03/12] Clip subtasks only when they extend past parent due date Subtasks are shortened to end on the parent task's due date when they run past it, including on direct subtask edits and gantt drags. Parent start changes no longer affect subtasks, and subtasks are never extended when they already end before the parent. Co-authored-by: Tanops --- CHANGELOG.md | 2 +- src/app/main.js | 13 +++++++---- src/lib/dates.js | 56 +++++++++++++++++++-------------------------- tests/dates.test.js | 34 ++++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4c8c5..cf40543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changed - Parent tasks keep their own t-shirt size in the detail sheet; subtask sizes no longer replace it. - Gantt bars for tasks with subtasks use only the task's own dates/size; subtask date edits never change the parent bar. -- Parent task gantt resize/drag updates only the parent unless the window shrinks (then subtasks clip to fit). +- Subtasks that extend past a parent task's due date are shortened to end on that date (including when the subtask is edited directly). Parent start changes do not affect subtasks. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/src/app/main.js b/src/app/main.js index dae6f54..3a47cb6 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -238,10 +238,10 @@ function sizeScale(){ /* chart starts at today - no dead space on the left */ let ZOOM = 2, showDone = false; let dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentSpan; + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, clipLeafToParentDue; function syncDateHelpers() { ({ dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentSpan } = createDateHelpers(TODAY, () => DATA)); + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, clipLeafToParentDue } = createDateHelpers(TODAY, () => DATA)); } syncDateHelpers(); @@ -890,12 +890,15 @@ function syncTaskDates(n,field){ if(isNaN(s)||isNaN(e)||s<=e) return; if(field==="start") n.due=n.start; else n.start=n.due; } -function updTask(id,f,v,quiet){ snap(); const n=findPath(id).pop(); +function updTask(id,f,v,quiet){ snap(); const path=findPath(id); const n=path.pop(); if(f==="title") n.title=v.trim()||n.title; else if(f==="owner") n.owner=v; else if(f==="priority") n.priority=v; - else if(f==="due"){ n.due=v||null; syncTaskDates(n,"due"); clipLeavesToParentSpan(n); } - else if(f==="start"){ n.start=v||null; syncTaskDates(n,"start"); clipLeavesToParentSpan(n); } + else if(f==="due"){ n.due=v||null; syncTaskDates(n,"due"); + if(path.length===1) clipLeavesToParentDue(n); + else if(path.length===2) clipLeafToParentDue(n, path[1]); } + else if(f==="start"){ n.start=v||null; syncTaskDates(n,"start"); + if(path.length===2) clipLeafToParentDue(n, path[1]); } else if(f==="size") n.size=v?normalizeSize(v):null; else if(f==="comment") n.comment=v.trim()||null; renderAll(); if(!quiet&&f!=="title"&&f!=="comment") openDetail(id); } diff --git a/src/lib/dates.js b/src/lib/dates.js index beaddec..abe24bb 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -1,7 +1,7 @@ import { LEAD } from "../data/constants.js"; import { C_DONE, C_LATE, C_LATER, C_RADAR, C_TODAY } from "../data/constants.js"; import { barSpan as _barSpan, dayIso, dayN, parseLocalIso } from "./date-core.js"; -import { flat, kids } from "./tree.js"; +import { flat, kids, findPath } from "./tree.js"; import { taskDone } from "./tree.js"; export { dayN, dayIso } from "./date-core.js"; @@ -95,43 +95,30 @@ export function createDateHelpers(today, getRoots = () => null) { const spanFor = (n) => (kids(n).length ? rollupSpan(n) : barSpan(n)); - function clampLeafToSpan(leaf, minS, maxE) { + function clampLeafEnd(leaf, maxE) { const sp = barSpan(leaf); - let newS = sp.s; - let newE = sp.e; - const dur = newE - newS; - if (newS < minS) { - newS = minS; - newE = newS + dur; - } - if (newE > maxE) { - newE = maxE; - newS = newE - dur; - } - if (newS < minS) { - newS = minS; - newE = Math.min(newS + dur, maxE); - } - if (newE > maxE) { - newE = maxE; - newS = Math.max(newE - dur, minS); - } - if (newS > newE) { - newS = minS; - newE = maxE; - } + if (sp.e <= maxE) return; + const dur = sp.e - sp.s; + let newE = maxE; + let newS = newE - dur; + if (newS > newE) newS = newE; leaf.start = dayIsoLocal(newS); leaf.due = dayIsoLocal(newE); } - /** Shrink subtasks that exceed a parent task's explicit start/due bounds. */ - function clipLeavesToParentSpan(n) { + /** Shorten a subtask that extends past its parent task's due date. */ + function clipLeafToParentDue(leaf, parent) { + if (!parent?.due || kids(leaf).length) return; + clampLeafEnd(leaf, dayNLocal(parent.due)); + } + + /** Shorten any subtasks that extend past the parent task's due date. */ + function clipLeavesToParentDue(n) { if (!taskWithSubtasks(n) || !n.due) return; const maxE = dayNLocal(n.due); - const minS = n.start ? dayNLocal(n.start) : -Infinity; flat([n], (x) => { if (kids(x).length || !x.due) return; - clampLeafToSpan(x, minS, maxE); + clampLeafEnd(x, maxE); }); } @@ -184,6 +171,11 @@ export function createDateHelpers(today, getRoots = () => null) { if (!n.start) n.start = dayIsoLocal(s0); n.due = dayIsoLocal(e); } + const roots = getRoots(); + if (roots && n.id) { + const path = findPath(n.id, roots); + if (path?.length === 3) clipLeafToParentDue(n, path[1]); + } return; } const old = spanFor(n); @@ -193,11 +185,10 @@ export function createDateHelpers(today, getRoots = () => null) { } if (mode === "l") { n.start = dayIsoLocal(s); - clipLeavesToParentSpan(n); return; } n.due = dayIsoLocal(e); - clipLeavesToParentSpan(n); + clipLeavesToParentDue(n); } return { @@ -214,7 +205,8 @@ export function createDateHelpers(today, getRoots = () => null) { isUrgent, fmtD, commitBarDrag, - clipLeavesToParentSpan, + clipLeavesToParentDue, + clipLeafToParentDue, taskWithSubtasks, }; } diff --git a/tests/dates.test.js b/tests/dates.test.js index c400641..1458bbf 100644 --- a/tests/dates.test.js +++ b/tests/dates.test.js @@ -82,9 +82,37 @@ describe("dates", () => { children: [{ due: "2026-07-05", size: "s", children: [] }], }; parent.due = "2026-06-22"; - h.clipLeavesToParentSpan(parent); - expect(h.dayN(parent.children[0].due)).toBeLessThanOrEqual(h.dayN(parent.due)); - expect(h.barSpan(parent.children[0]).e).toBeLessThanOrEqual(h.barSpan(parent).e); + h.clipLeavesToParentDue(parent); + expect(h.dayN(parent.children[0].due)).toBe(h.dayN(parent.due)); + expect(h.barSpan(parent.children[0]).e).toBe(h.dayN(parent.due)); + }); + + it("clips a subtask when its due date extends past the parent task", () => { + const parent = { due: "2026-06-30", size: "m", children: [] }; + const sub = { due: "2026-07-05", size: "m", children: [] }; + h.clipLeafToParentDue(sub, parent); + expect(h.dayN(sub.due)).toBe(h.dayN(parent.due)); + }); + + it("does not lengthen a subtask when it ends before the parent task", () => { + const parent = { due: "2026-06-30", size: "m", children: [] }; + const sub = { due: "2026-06-20", size: "s", children: [] }; + const before = sub.due; + h.clipLeafToParentDue(sub, parent); + expect(sub.due).toBe(before); + }); + + it("does not clip subtasks when only the parent start date changes", () => { + const parent = { + start: "2026-06-20", + due: "2026-06-30", + size: "m", + children: [{ due: "2026-06-14", size: "s", children: [] }], + }; + const subDue = parent.children[0].due; + parent.start = "2026-06-18"; + h.clipLeavesToParentDue(parent); + expect(parent.children[0].due).toBe(subDue); }); it("still rolls up projects across all descendant leaves", () => { From 93bca05b3a7f20004f250df0be7798d24c27181c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 17:15:39 +0000 Subject: [PATCH 04/12] Expand parent task when subtasks grow past its dates Parent tasks now grow on the gantt (and update their stored start/due) when a subtask is enlarged or moved outside the task window. The parent never shrinks when subtasks move inward. Narrowing the parent due date still clips subtasks that extend past it. Co-authored-by: Tanops --- CHANGELOG.md | 2 +- src/app/main.js | 15 ++++++------- src/lib/dates.js | 30 +++++++++++++++++--------- tests/dates.test.js | 51 ++++++++++++++++++++++++++++----------------- 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf40543..fcf44d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changed - Parent tasks keep their own t-shirt size in the detail sheet; subtask sizes no longer replace it. - Gantt bars for tasks with subtasks use only the task's own dates/size; subtask date edits never change the parent bar. -- Subtasks that extend past a parent task's due date are shortened to end on that date (including when the subtask is edited directly). Parent start changes do not affect subtasks. +- Parent tasks grow when subtasks extend past their dates (including earlier starts and later ends), but never shrink when subtasks move inward. Narrowing a parent due date still clips subtasks. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/src/app/main.js b/src/app/main.js index 3a47cb6..9244c4c 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -238,10 +238,10 @@ function sizeScale(){ /* chart starts at today - no dead space on the left */ let ZOOM = 2, showDone = false; let dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, clipLeafToParentDue; + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, expandParentToFitSubtasks; function syncDateHelpers() { ({ dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, clipLeafToParentDue } = createDateHelpers(TODAY, () => DATA)); + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, expandParentToFitSubtasks } = createDateHelpers(TODAY, () => DATA)); } syncDateHelpers(); @@ -275,8 +275,8 @@ function scheduleTodayRefresh() { } /* bars carry only FOUR urgency colors (owner identity is in the bubble) */ /* vivid, candy-bright status palette (like the reference) */ -/* a parent task's bar uses only its own dates/size — subtask edits never change it. - Projects still roll up across all descendant leaves. */ +/* a parent task's bar uses its own dates/size but grows when subtasks extend + past it — never shrinks. Projects still roll up across all descendant leaves. */ /* effort weight = working days (Mon–Fri) inside a leaf's bar span, so the team doesn't have to size every item — a longer subtask simply weighs more. A set size still counts, because size feeds the bar's span via LEAD, which feeds this. Min 1 so a single-day or weekend-only @@ -896,10 +896,11 @@ function updTask(id,f,v,quiet){ snap(); const path=findPath(id); const n=path.po else if(f==="priority") n.priority=v; else if(f==="due"){ n.due=v||null; syncTaskDates(n,"due"); if(path.length===1) clipLeavesToParentDue(n); - else if(path.length===2) clipLeafToParentDue(n, path[1]); } + else if(path.length===2) expandParentToFitSubtasks(path[1]); } else if(f==="start"){ n.start=v||null; syncTaskDates(n,"start"); - if(path.length===2) clipLeafToParentDue(n, path[1]); } - else if(f==="size") n.size=v?normalizeSize(v):null; + if(path.length===2) expandParentToFitSubtasks(path[1]); } + else if(f==="size"){ n.size=v?normalizeSize(v):null; + if(path.length===2) expandParentToFitSubtasks(path[1]); } else if(f==="comment") n.comment=v.trim()||null; renderAll(); if(!quiet&&f!=="title"&&f!=="comment") openDetail(id); } function deleteTask(id){ const n=findPath(id).pop(); diff --git a/src/lib/dates.js b/src/lib/dates.js index abe24bb..e023ab2 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -85,11 +85,17 @@ export function createDateHelpers(today, getRoots = () => null) { return e === -Infinity ? null : { s, e }; } - /** Parent tasks use only their own dates/size; subtask edits do not change the bar. */ + /** Parent task span: own dates/size are the floor; subtasks may only push outward. */ function rollupSpan(n) { const env = childEnvelope(n); if (!env) return barSpan(n); - if (taskWithSubtasks(n) && n.due) return barSpan(n); + if (taskWithSubtasks(n) && n.due) { + const own = barSpan(n); + return { + s: env.s < own.s ? env.s : own.s, + e: env.e > own.e ? env.e : own.e, + }; + } return env; } @@ -106,13 +112,17 @@ export function createDateHelpers(today, getRoots = () => null) { leaf.due = dayIsoLocal(newE); } - /** Shorten a subtask that extends past its parent task's due date. */ - function clipLeafToParentDue(leaf, parent) { - if (!parent?.due || kids(leaf).length) return; - clampLeafEnd(leaf, dayNLocal(parent.due)); + /** Grow a parent task's stored dates when subtasks extend past them (never shrink). */ + function expandParentToFitSubtasks(n) { + if (!taskWithSubtasks(n) || !n.due) return; + const env = childEnvelope(n); + if (!env) return; + const own = barSpan(n); + const ownS = n.start ? dayNLocal(n.start) : own.s; + const ownE = dayNLocal(n.due); + if (env.e > ownE) n.due = dayIsoLocal(env.e); + if (env.s < ownS) n.start = dayIsoLocal(env.s); } - - /** Shorten any subtasks that extend past the parent task's due date. */ function clipLeavesToParentDue(n) { if (!taskWithSubtasks(n) || !n.due) return; const maxE = dayNLocal(n.due); @@ -174,7 +184,7 @@ export function createDateHelpers(today, getRoots = () => null) { const roots = getRoots(); if (roots && n.id) { const path = findPath(n.id, roots); - if (path?.length === 3) clipLeafToParentDue(n, path[1]); + if (path?.length === 3) expandParentToFitSubtasks(path[1]); } return; } @@ -206,7 +216,7 @@ export function createDateHelpers(today, getRoots = () => null) { fmtD, commitBarDrag, clipLeavesToParentDue, - clipLeafToParentDue, + expandParentToFitSubtasks, taskWithSubtasks, }; } diff --git a/tests/dates.test.js b/tests/dates.test.js index 1458bbf..1fd636c 100644 --- a/tests/dates.test.js +++ b/tests/dates.test.js @@ -37,7 +37,7 @@ describe("dates", () => { expect(h.spanFor(leaf).e).toBe(8); }); - it("uses only the parent task's own span regardless of subtask dates", () => { + it("expands a parent task when subtasks extend outside its dates", () => { const parent = { due: "2026-06-30", size: "m", @@ -48,7 +48,9 @@ describe("dates", () => { }; const own = h.barSpan(parent); const span = h.rollupSpan(parent); - expect(span).toEqual(own); + expect(span.e).toBe(own.e); + expect(span.s).toBe(h.barSpan(parent.children[0]).s); + expect(span.s).toBeLessThan(own.s); }); it("does not shrink a parent task when subtasks fall inside its dates", () => { @@ -64,15 +66,27 @@ describe("dates", () => { expect(span.e).toBe(own.e); }); - it("does not expand a parent task when a subtask is scheduled outside its dates", () => { + it("expands the parent task when a subtask due date is moved later", () => { const parent = { due: "2026-06-20", size: "m", children: [{ due: "2026-07-05", size: "s", children: [] }], }; - const own = h.barSpan(parent); - parent.children[0].due = "2026-07-10"; - expect(h.spanFor(parent)).toEqual(own); + const ownE = h.barSpan(parent).e; + h.expandParentToFitSubtasks(parent); + expect(h.dayN(parent.due)).toBeGreaterThan(ownE); + expect(h.spanFor(parent).e).toBe(h.barSpan(parent.children[0]).e); + }); + + it("expands the parent task start when a subtask begins earlier", () => { + const parent = { + start: "2026-06-20", + due: "2026-06-30", + size: "m", + children: [{ due: "2026-06-14", size: "s", children: [] }], + }; + h.expandParentToFitSubtasks(parent); + expect(h.dayN(parent.start)).toBe(h.barSpan(parent.children[0]).s); }); it("clips subtasks when a parent task's end date moves earlier", () => { @@ -87,19 +101,18 @@ describe("dates", () => { expect(h.barSpan(parent.children[0]).e).toBe(h.dayN(parent.due)); }); - it("clips a subtask when its due date extends past the parent task", () => { - const parent = { due: "2026-06-30", size: "m", children: [] }; - const sub = { due: "2026-07-05", size: "m", children: [] }; - h.clipLeafToParentDue(sub, parent); - expect(h.dayN(sub.due)).toBe(h.dayN(parent.due)); - }); - - it("does not lengthen a subtask when it ends before the parent task", () => { - const parent = { due: "2026-06-30", size: "m", children: [] }; - const sub = { due: "2026-06-20", size: "s", children: [] }; - const before = sub.due; - h.clipLeafToParentDue(sub, parent); - expect(sub.due).toBe(before); + it("does not shrink a parent task when subtasks are moved inward", () => { + const parent = { + start: "2026-06-10", + due: "2026-06-30", + size: "m", + children: [{ due: "2026-06-20", size: "s", children: [] }], + }; + const dueBefore = parent.due; + const startBefore = parent.start; + h.expandParentToFitSubtasks(parent); + expect(parent.due).toBe(dueBefore); + expect(parent.start).toBe(startBefore); }); it("does not clip subtasks when only the parent start date changes", () => { From d2d8666f518b4ceceee8d89e08ce3f578c383bab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 17:33:54 +0000 Subject: [PATCH 05/12] Allow Enter to add subtasks from the detail sheet Bind the subtask input keydown handler after each detail render so Enter reliably calls addChild, and refocus the input after adding for rapid entry. Co-authored-by: Tanops --- CHANGELOG.md | 2 +- src/app/main.js | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf44d7..c70eb51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ ### Changed - Parent tasks keep their own t-shirt size in the detail sheet; subtask sizes no longer replace it. -- Gantt bars for tasks with subtasks use only the task's own dates/size; subtask date edits never change the parent bar. - Parent tasks grow when subtasks extend past their dates (including earlier starts and later ends), but never shrink when subtasks move inward. Narrowing a parent due date still clips subtasks. +- Press Enter in the subtask input on the detail sheet to add a subtask without clicking Add. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/src/app/main.js b/src/app/main.js index 9244c4c..6485125 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -923,6 +923,7 @@ function addChild(id){ if(path.length>1&&!subOpen(id)){ if(subsAll) COL.delete(id); else EXP.add(id); } // show new subtask rows on the gantt renderAll(); openDetail(id,{reveal:"bottom"}); + requestAnimationFrame(()=>document.getElementById("dSubNew")?.focus()); revealGanttTask(child.id); } function addProject(){ @@ -1001,13 +1002,19 @@ function openDetail(id,opts){ ${ownerPill(ch.owner,`updTask(${ch.id},'owner',this.value,true);openDetail(${id})`)} ${szCtl} ${dueChip(ch.due,lleaf&&ch.done)}
`;}).join("")} - ${path.length>=3?"":`
`} + ${path.length>=3?"":`
+
`} `; document.getElementById("tmodal").classList.add("show"); document.getElementById("scrim").classList.add("show"); if(opts?.reveal) revealDetailScroll(box,opts.reveal); else restoreDetailScroll(box,savedScroll); + const subNew=document.getElementById("dSubNew"); + if(subNew) subNew.onkeydown=e=>{ + if(e.key!=="Enter") return; + e.preventDefault(); + addChild(id); + }; } function closeSheet(){ DETAIL_ID=null; document.getElementById("tmodal").classList.remove("show"); From 57f158393f046025be7e6eb063a81a12c2ea3597 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 17:38:05 +0000 Subject: [PATCH 06/12] Add Save button to project, task, and subtask detail pop-ups Save flushes pending title, comment, and subtask input edits, persists to the server, shows brief confirmation, and closes the sheet. Co-authored-by: Tanops --- CHANGELOG.md | 1 + index.html | 6 +++++- src/app/main.js | 25 +++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70eb51..cbe3a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Parent tasks keep their own t-shirt size in the detail sheet; subtask sizes no longer replace it. - Parent tasks grow when subtasks extend past their dates (including earlier starts and later ends), but never shrink when subtasks move inward. Narrowing a parent due date still clips subtasks. - Press Enter in the subtask input on the detail sheet to add a subtask without clicking Add. +- Detail pop-ups for projects, tasks, and subtasks include a Save button that flushes pending edits, persists, and closes the sheet. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/index.html b/index.html index 2e540b4..5d4793f 100644 --- a/index.html +++ b/index.html @@ -202,7 +202,11 @@ background-repeat:no-repeat;background-position:right 5px center;background-size:15px} .tbox .frow select:hover{background-color:#f0f2f6} .tbox .danger{color:var(--red);background:var(--red-soft);border-radius:11px;padding:12px 18px; - font-weight:700;font-size:14px;margin-top:18px;width:100%} + font-weight:700;font-size:14px;margin-top:10px;width:100%} + .tbox .dsave{width:100%;margin-top:18px;padding:12px 18px;border-radius:11px;background:var(--accent); + color:#fff;font-weight:700;font-size:15px} + .tbox .dsave:active{transform:scale(.98)} + .tbox .dsave.saved{background:var(--green)} .closex{position:absolute;top:14px;right:16px;width:36px;height:36px; border-radius:50%;background:#eef0f4;color:var(--ink-2);font-size:16px;font-weight:700;z-index:1} .searchwrap{position:relative;flex:1;max-width:430px} diff --git a/src/app/main.js b/src/app/main.js index 6485125..f184500 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -906,7 +906,7 @@ function updTask(id,f,v,quiet){ snap(); const path=findPath(id); const n=path.po function deleteTask(id){ const n=findPath(id).pop(); if(typeof confirm!=="undefined"&&!confirm('Delete "'+n.title+'"'+(n.children.length?" and its subtasks":"")+"?")) return; snap(); detach(id); closeSheet(); renderAll(); } -function addChild(id){ +function addChild(id, quiet){ const el=document.getElementById("dSubNew"); if(!el) return; const v=el.value.trim(); @@ -922,10 +922,30 @@ function addChild(id){ n.open=true; if(path.length>1&&!subOpen(id)){ if(subsAll) COL.delete(id); else EXP.add(id); } // show new subtask rows on the gantt renderAll(); + if(quiet) return child; openDetail(id,{reveal:"bottom"}); requestAnimationFrame(()=>document.getElementById("dSubNew")?.focus()); revealGanttTask(child.id); } +function saveDetail(){ + if(DETAIL_ID==null) return; + const id=DETAIL_ID; + snap(); + const path=findPath(id); + if(!path) return; + const n=path[path.length-1]; + const titleEl=document.getElementById("dTitle"); + if(titleEl){ const t=titleEl.value.trim(); if(t) n.title=t; } + const commentEl=document.querySelector(".dcomment"); + if(commentEl) n.comment=commentEl.value.trim()||null; + if(document.getElementById("dSubNew")?.value.trim()) addChild(id,true); + requestSave(); + renderAll(); + const btn=document.querySelector(".dsave"); + if(btn){ btn.textContent="Saved ✓"; btn.classList.add("saved"); btn.disabled=true; } + ding(1); + setTimeout(closeSheet,400); +} function addProject(){ snap(); const proj=T("New project","fd",{d:dayIso(LEAD.l),open:true}); @@ -1004,6 +1024,7 @@ function openDetail(id,opts){ ${dueChip(ch.due,lleaf&&ch.done)}`;}).join("")} ${path.length>=3?"":`
`} + `; document.getElementById("tmodal").classList.add("show"); document.getElementById("scrim").classList.add("show"); @@ -1673,7 +1694,7 @@ const _globals = { toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, - closeTeam, closeSheet, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, + closeTeam, closeSheet, saveDetail, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, toggleExp, updTask, refreshBarMenu, addChild, addProject, deleteTask, addCapTask, barDown, barContext, pickSearch, projDown, rowDown, uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, From 373e0cd03135f4cbb2c16177705a0b12c6252ced Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 18:04:07 +0000 Subject: [PATCH 07/12] Make project chart background clickable to add tasks Each project row gets a light wash over its timeline span. Clicking empty chart space inside that area opens the project detail sheet and focuses the new-task input. Task bars and controls remain fully interactive. Co-authored-by: Tanops --- CHANGELOG.md | 1 + index.html | 8 ++++++++ src/app/main.js | 8 +++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe3a67..0bba299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Parent tasks grow when subtasks extend past their dates (including earlier starts and later ends), but never shrink when subtasks move inward. Narrowing a parent due date still clips subtasks. - Press Enter in the subtask input on the detail sheet to add a subtask without clicking Add. - Detail pop-ups for projects, tasks, and subtasks include a Save button that flushes pending edits, persists, and closes the sheet. +- Clicking the chart background inside a project opens that project's detail sheet and focuses the new-task input. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/index.html b/index.html index 5d4793f..0ef7cb0 100644 --- a/index.html +++ b/index.html @@ -282,6 +282,14 @@ /* project group: a thin summary bar spanning the project's task range, then its tasks */ .pgroup{position:relative;margin-top:22px;padding:2px 0 8px} .pgroup:first-of-type{margin-top:0;padding-top:0} + /* clickable project wash — empty chart space opens the project to add tasks */ + .pgclick{position:absolute;top:0;bottom:0;z-index:0;cursor:pointer; + background:rgba(59,110,246,.05);border-radius:10px;transition:background .15s} + .pgclick:hover{background:rgba(59,110,246,.1)} + .pgroup>.grow{position:relative;z-index:1} + .pgroup .gtrack{pointer-events:none} + .pgroup .gbar,.pgroup .gsumlbl,.pgroup .gsumpct,.pgroup .gsumline,.pgroup .gsumfill, + .pgroup .gexp,.pgroup .gexpw,.pgroup .ear,.pgroup .gdot,.pgroup .gttlout{pointer-events:auto} /* project header: name on its own line, the progress bar on the line below it */ /* top-align so the row's extra height (the gap before the first task) stays BELOW the bar, instead of being centered and pushing the project name down away from the ribbon */ diff --git a/src/app/main.js b/src/app/main.js index f184500..f17ab78 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -489,6 +489,8 @@ function renderGantt(){ let pPts=0; flat([p],x=>{ if(x.children.length) return; pPts+=sizePts(x.size); }); const ph=Math.max(7,Math.min(20,Math.round(5+Math.sqrt(pPts)*2.2))); rows.push(`
+
@@ -973,6 +975,9 @@ function revealGanttTask(id){ if(typeof requestAnimationFrame!=="undefined") requestAnimationFrame(()=>requestAnimationFrame(run)); else run(); } +function openProjectChart(id){ + openDetail(id,{focusTask:true,reveal:"bottom"}); +} function openDetail(id,opts){ const path=findPath(id); if(!path){ if(DETAIL_ID===id) closeSheet(); DETAIL_ID=null; return; } @@ -1036,6 +1041,7 @@ function openDetail(id,opts){ e.preventDefault(); addChild(id); }; + if(opts?.focusTask) requestAnimationFrame(()=>document.getElementById("dSubNew")?.focus()); } function closeSheet(){ DETAIL_ID=null; document.getElementById("tmodal").classList.remove("show"); @@ -1694,7 +1700,7 @@ const _globals = { toggleSearch, openTeam, micFabTap, openTranscript, toggleSettings, toggleSidebar, closeSettings, toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, - closeTeam, closeSheet, saveDetail, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, + closeTeam, closeSheet, saveDetail, openProjectChart, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, toggleExp, updTask, refreshBarMenu, addChild, addProject, deleteTask, addCapTask, barDown, barContext, pickSearch, projDown, rowDown, uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, From b4f5a1fc18faccce4ad495de6ceff04918606b5d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 18:09:17 +0000 Subject: [PATCH 08/12] Fix project chart background clicks passing through task rows Task row containers were intercepting clicks before they reached the project background layer. Grow rows now pass through pointer events, the click target spans the full project block, and project header clicks focus the new-task input. Co-authored-by: Tanops --- index.html | 9 +++++---- src/app/main.js | 13 ++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 0ef7cb0..554fd0a 100644 --- a/index.html +++ b/index.html @@ -283,10 +283,11 @@ .pgroup{position:relative;margin-top:22px;padding:2px 0 8px} .pgroup:first-of-type{margin-top:0;padding-top:0} /* clickable project wash — empty chart space opens the project to add tasks */ - .pgclick{position:absolute;top:0;bottom:0;z-index:0;cursor:pointer; - background:rgba(59,110,246,.05);border-radius:10px;transition:background .15s} - .pgclick:hover{background:rgba(59,110,246,.1)} - .pgroup>.grow{position:relative;z-index:1} + .pgclick{position:absolute;inset:0;z-index:0;cursor:pointer;border:none;padding:0;background:transparent;font:inherit} + .pgwash{position:absolute;top:0;bottom:0;background:rgba(59,110,246,.05);border-radius:10px; + transition:background .15s;pointer-events:none} + .pgclick:hover .pgwash{background:rgba(59,110,246,.1)} + .pgroup>.grow{position:relative;z-index:1;pointer-events:none} .pgroup .gtrack{pointer-events:none} .pgroup .gbar,.pgroup .gsumlbl,.pgroup .gsumpct,.pgroup .gsumline,.pgroup .gsumfill, .pgroup .gexp,.pgroup .gexpw,.pgroup .ear,.pgroup .gdot,.pgroup .gttlout{pointer-events:auto} diff --git a/src/app/main.js b/src/app/main.js index f17ab78..9397040 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -489,14 +489,17 @@ function renderGantt(){ let pPts=0; flat([p],x=>{ if(x.children.length) return; pPts+=sizePts(x.size); }); const ph=Math.max(7,Math.min(20,Math.round(5+Math.sqrt(pPts)*2.2))); rows.push(`
-
+
-
-
- +
+
+
${taskRows}
`); }); From 7af452ee661ab088a37cd6a72bc41751483f852a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 18:18:13 +0000 Subject: [PATCH 09/12] Add sort-by-start-date to project and task bar menus Projects and tasks now show a Sort by start date action at the bottom of the quick bar menu. It reorders tasks and subtasks within the project by effective start (explicit start, else due-derived), with undated items last. Project summary bars also open the quick menu on right-click. Co-authored-by: Tanops --- CHANGELOG.md | 1 + index.html | 2 ++ src/app/main.js | 33 +++++++++++++++++++++++++++++---- src/lib/tree.js | 11 +++++++++++ tests/tree.test.js | 23 ++++++++++++++++++++++- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bba299..8e0e36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Press Enter in the subtask input on the detail sheet to add a subtask without clicking Add. - Detail pop-ups for projects, tasks, and subtasks include a Save button that flushes pending edits, persists, and closes the sheet. - Clicking the chart background inside a project opens that project's detail sheet and focuses the new-task input. +- Bar quick menu on projects and tasks includes **Sort by start date** to reorder tasks/subtasks within the project. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/index.html b/index.html index 554fd0a..0c294ce 100644 --- a/index.html +++ b/index.html @@ -405,6 +405,8 @@ .bm-rw input[type=date],.bm-rw select{flex:1;min-width:0;font:inherit;font-size:13px;border:1px solid var(--line); border-radius:8px;padding:5px 8px;background:var(--bg);color:var(--ink)} .bm-rw .szseg{flex:1;justify-content:space-between;flex-wrap:wrap;gap:4px} + .bm-sort{margin-top:6px;border-top:1px solid var(--line);color:var(--accent)} + .bm-sort:hover{background:var(--accent-soft)} /* the "today" band: ends with a hard vertical line — left of it = needs doing today */ /* today = a wider column with a faint tint, no box */ /* today tint + week shading stay in the chart body (below the 41px header: diff --git a/src/app/main.js b/src/app/main.js index 9397040..27981a1 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -8,6 +8,7 @@ import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildV import { createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, taskDoneAt as taskDoneAtIn, contains, depthOf as depthOfIn, heightOf, fitsDepth as fitsDepthIn, + sortSubtreeByStart, } from "../lib/tree.js"; import { createDateHelpers } from "../lib/dates.js"; import { calendarToday, parseLocalIso, todayLocalIso } from "../lib/date-core.js"; @@ -496,9 +497,14 @@ function renderGantt(){
-
-
+
+
${taskRows}
`); @@ -867,6 +873,11 @@ let BARMENU=null; const BM=document.createElement("div"); BM.id="barMenu"; BM.className="barmenu"; document.body.appendChild(BM); function openBarMenu(id,anchor){ const path=findPath(id); if(!path) return; const n=path.pop(); + const depth=path.length; + const sortBtn=(depth===0||depth===1) + ? `` + : ""; if(anchor&&anchor.getBoundingClientRect) anchor=anchor.getBoundingClientRect(); if(anchor) BM._anchor={left:anchor.left,right:anchor.right,top:anchor.top,bottom:anchor.bottom}; const a=BM._anchor||{left:100,right:160,top:100,bottom:130}; @@ -875,7 +886,8 @@ function openBarMenu(id,anchor){
${Object.entries(PEOPLE).map(([k,p])=>``).join("")}
Size${SIZE_KEYS.map(z=>``).join("")}
Start
-
End
`; +
End
+ ${sortBtn}`; BM.classList.add("show"); BARMENU=id; const mw=BM.offsetWidth||236, mh=BM.offsetHeight||170, gap=8, vw=window.innerWidth, vh=window.innerHeight; // sit to the RIGHT of the pill (flip left only if there's no room) @@ -887,6 +899,19 @@ function openBarMenu(id,anchor){ } function refreshBarMenu(id){ if(BARMENU===id) openBarMenu(id); } function closeBarMenu(){ BM.classList.remove("show"); BARMENU=null; } +function startSortKey(n){ + const { s }=barSpan(n); + return (n.due||n.start)&&!isNaN(s)?s:Infinity; +} +function sortByStartDate(id){ + const path=findPath(id); + if(!path||path.length>2) return; + snap(); + sortSubtreeByStart(path[path.length-1], startSortKey); + renderAll(); + ding(1); + if(BARMENU===id) refreshBarMenu(id); +} document.addEventListener("pointerdown",e=>{ if(BARMENU&&!e.target.closest("#barMenu")) closeBarMenu(); },true); function syncTaskDates(n,field){ @@ -1704,7 +1729,7 @@ const _globals = { toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, closeTeam, closeSheet, saveDetail, openProjectChart, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, - toggleExp, updTask, refreshBarMenu, addChild, addProject, deleteTask, addCapTask, barDown, barContext, pickSearch, + toggleExp, updTask, refreshBarMenu, sortByStartDate, addChild, addProject, deleteTask, addCapTask, barDown, barContext, pickSearch, projDown, rowDown, uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, doSearch, refreshCard, delCapTask, setTask, setTaskOwner, setTaskSize, setSub, setSubOwner, addSub, diff --git a/src/lib/tree.js b/src/lib/tree.js index 5fc522d..aee7116 100644 --- a/src/lib/tree.js +++ b/src/lib/tree.js @@ -26,6 +26,17 @@ export function createTaskFactory() { export const kids = (n) => n.children || []; +/** Sort children (and nested subtasks) by ascending start key; undated items go last. */ +export function sortSubtreeByStart(n, startKey) { + const ch = kids(n); + if (!ch.length) return; + ch.sort((a, b) => { + const d = startKey(a) - startKey(b); + return d !== 0 ? d : a.title.localeCompare(b.title); + }); + ch.forEach((c) => sortSubtreeByStart(c, startKey)); +} + export const flat = (nodes, fn, depth = 0, path = []) => nodes.forEach((n) => { fn(n, depth, path); diff --git a/tests/tree.test.js b/tests/tree.test.js index 853b16b..1970978 100644 --- a/tests/tree.test.js +++ b/tests/tree.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createTaskFactory, counts, findPath, normalizeTaskTree, pct, progFrac, taskDone, taskDoneAt } from "../src/lib/tree.js"; +import { createTaskFactory, counts, findPath, normalizeTaskTree, pct, progFrac, sortSubtreeByStart, taskDone, taskDoneAt } from "../src/lib/tree.js"; import { SIZE_PTS } from "../src/data/constants.js"; describe("tree", () => { @@ -74,4 +74,25 @@ describe("tree", () => { const task = T("Task", "sk", { comment: "Needs review" }); expect(task.comment).toBe("Needs review"); }); + + it("sorts children and subtasks by start key", () => { + const root = T("Project", "ia", { + c: [ + T("Late task", "sk", { d: "2026-06-30", st: "2026-06-20", c: [ + T("Sub B", "sk", { d: "2026-06-25", st: "2026-06-22", c: [] }), + T("Sub A", "sk", { d: "2026-06-18", st: "2026-06-15", c: [] }), + ] }), + T("Early task", "sk", { d: "2026-06-15", st: "2026-06-10", c: [] }), + T("No dates", "sk", { c: [] }), + ], + }); + const key = (n) => { + if (n.start) return Date.parse(n.start); + if (n.due) return Date.parse(n.due); + return Infinity; + }; + sortSubtreeByStart(root, key); + expect(root.children.map((c) => c.title)).toEqual(["Early task", "Late task", "No dates"]); + expect(root.children[1].children.map((c) => c.title)).toEqual(["Sub A", "Sub B"]); + }); }); From ceb6e835b190e9927e215fedff9e887e32c6ffc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 18:23:08 +0000 Subject: [PATCH 10/12] Add sort-by-start button to project and task detail sheets Places Sort by start date in the Tasks/Subtasks header row (as shown in the design mockup) in addition to the bar quick menu. Refreshes the open detail sheet after sorting. Co-authored-by: Tanops --- CHANGELOG.md | 2 +- index.html | 5 ++++- src/app/main.js | 12 ++++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0e36e..8fa91be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Press Enter in the subtask input on the detail sheet to add a subtask without clicking Add. - Detail pop-ups for projects, tasks, and subtasks include a Save button that flushes pending edits, persists, and closes the sheet. - Clicking the chart background inside a project opens that project's detail sheet and focuses the new-task input. -- Bar quick menu on projects and tasks includes **Sort by start date** to reorder tasks/subtasks within the project. +- Bar quick menu and project/task detail sheet include **Sort by start date** to reorder tasks/subtasks within the project. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/index.html b/index.html index 0c294ce..8b9d2ac 100644 --- a/index.html +++ b/index.html @@ -424,7 +424,10 @@ .gaxis span{position:absolute;top:0;font-size:11px;color:var(--ink-3);transform:translateX(-50%);white-space:nowrap;font-weight:600} .frow{display:flex;align-items:center;gap:12px;padding:13px 0;border-bottom:1px solid var(--line);font-size:14.5px} .frow .lbl{width:92px;color:var(--ink-3);font-weight:600;font-size:13px;flex:none} - .subhdr{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);margin:20px 0 8px} + .subhdr{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:20px 0 8px} + .subhdr>span{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3)} + .subsort{flex:none;font-size:12px;font-weight:700;color:var(--accent);background:var(--accent-soft);border-radius:8px;padding:6px 12px} + .subsort:hover{background:#dce8ff} .bigbar{margin:16px 0 4px} .bigbar .bar{height:10px} .bigbar .nums{display:flex;justify-content:space-between;font-size:13px;color:var(--ink-2);margin-top:7px} diff --git a/src/app/main.js b/src/app/main.js index 27981a1..876c123 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -910,7 +910,8 @@ function sortByStartDate(id){ sortSubtreeByStart(path[path.length-1], startSortKey); renderAll(); ding(1); - if(BARMENU===id) refreshBarMenu(id); + if(DETAIL_ID===id) openDetail(id); + else if(BARMENU===id) refreshBarMenu(id); } document.addEventListener("pointerdown",e=>{ if(BARMENU&&!e.target.closest("#barMenu")) closeBarMenu(); },true); @@ -1032,6 +1033,13 @@ function openDetail(id,opts){ ${SIZE_KEYS.map(z=>``).join("")}
` : `
Size${_szPts} pts
`; const kind=path.length===1?"project":path.length===2?"task":"subtask"; + const listHdr=path.length===1 + ? `Tasks — grip ⠿ to drag onto another project` + : `Subtasks`; + const sortBtn=path.length<=2 + ? `` + : ""; document.getElementById("dBody").innerHTML=`
Owner${av(n.owner)}
@@ -1045,7 +1053,7 @@ function openDetail(id,opts){
`:""}
Move to
- ${path.length>=3?"":`
${path.length>1?"Subtasks":"Tasks — grip ⠿ to drag onto another project"}
`} + ${path.length>=3?"":`
${listHdr}${sortBtn}
`} ${n.children.map(ch=>{ const lp=pct(ch), lleaf=!ch.children.length; const szCtl=``; return `
From b45a51b9db96bf7dd8515a4e2976dd9ebbf6282e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 18:30:28 +0000 Subject: [PATCH 11/12] Restore independent date editing; remove sort and date coupling Roll back sort-by-start-date and automatic parent/subtask date expand/clip behavior that fought manual edits. Each project, task, and subtask now owns its start and end dates; only dragging a parent bar still shifts the whole subtree. Also remove syncTaskDates so start and end fields no longer snap together when edited separately. Co-authored-by: Tanops --- CHANGELOG.md | 9 ++++- index.html | 7 +--- src/app/main.js | 58 +++++---------------------- src/lib/dates.js | 53 ++---------------------- src/lib/tree.js | 11 ----- tests/dates.test.js | 98 ++++++--------------------------------------- tests/tree.test.js | 23 +---------- 7 files changed, 35 insertions(+), 224 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa91be..e8a2642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,19 @@ # Changelog +## Independent date editing (2026-06-22) + +### Changed +- Removed sort-by-start-date controls and automatic parent/subtask date coupling (no more expanding or clipping dates across levels). +- Start and end dates on a project, task, or subtask no longer snap together when edited; each field updates on its own. +- Projects, tasks, and subtasks can each have start and end dates extended or shortened independently via the detail sheet, bar menu, or gantt drag handles. + ## Parent task priority over subtasks (2026-06-22) ### Changed - Parent tasks keep their own t-shirt size in the detail sheet; subtask sizes no longer replace it. -- Parent tasks grow when subtasks extend past their dates (including earlier starts and later ends), but never shrink when subtasks move inward. Narrowing a parent due date still clips subtasks. - Press Enter in the subtask input on the detail sheet to add a subtask without clicking Add. - Detail pop-ups for projects, tasks, and subtasks include a Save button that flushes pending edits, persists, and closes the sheet. - Clicking the chart background inside a project opens that project's detail sheet and focuses the new-task input. -- Bar quick menu and project/task detail sheet include **Sort by start date** to reorder tasks/subtasks within the project. - Projects still roll up dates and size points across all descendant leaves. ## Rebase PR #14 onto main (2026-06-19) diff --git a/index.html b/index.html index 8b9d2ac..554fd0a 100644 --- a/index.html +++ b/index.html @@ -405,8 +405,6 @@ .bm-rw input[type=date],.bm-rw select{flex:1;min-width:0;font:inherit;font-size:13px;border:1px solid var(--line); border-radius:8px;padding:5px 8px;background:var(--bg);color:var(--ink)} .bm-rw .szseg{flex:1;justify-content:space-between;flex-wrap:wrap;gap:4px} - .bm-sort{margin-top:6px;border-top:1px solid var(--line);color:var(--accent)} - .bm-sort:hover{background:var(--accent-soft)} /* the "today" band: ends with a hard vertical line — left of it = needs doing today */ /* today = a wider column with a faint tint, no box */ /* today tint + week shading stay in the chart body (below the 41px header: @@ -424,10 +422,7 @@ .gaxis span{position:absolute;top:0;font-size:11px;color:var(--ink-3);transform:translateX(-50%);white-space:nowrap;font-weight:600} .frow{display:flex;align-items:center;gap:12px;padding:13px 0;border-bottom:1px solid var(--line);font-size:14.5px} .frow .lbl{width:92px;color:var(--ink-3);font-weight:600;font-size:13px;flex:none} - .subhdr{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:20px 0 8px} - .subhdr>span{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3)} - .subsort{flex:none;font-size:12px;font-weight:700;color:var(--accent);background:var(--accent-soft);border-radius:8px;padding:6px 12px} - .subsort:hover{background:#dce8ff} + .subhdr{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);margin:20px 0 8px} .bigbar{margin:16px 0 4px} .bigbar .bar{height:10px} .bigbar .nums{display:flex;justify-content:space-between;font-size:13px;color:var(--ink-2);margin-top:7px} diff --git a/src/app/main.js b/src/app/main.js index 876c123..f633639 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -8,7 +8,6 @@ import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildV import { createTaskFactory, flat, findPath as findPathIn, counts, pct, taskDone, taskDoneAt as taskDoneAtIn, contains, depthOf as depthOfIn, heightOf, fitsDepth as fitsDepthIn, - sortSubtreeByStart, } from "../lib/tree.js"; import { createDateHelpers } from "../lib/dates.js"; import { calendarToday, parseLocalIso, todayLocalIso } from "../lib/date-core.js"; @@ -239,10 +238,10 @@ function sizeScale(){ /* chart starts at today - no dead space on the left */ let ZOOM = 2, showDone = false; let dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, expandParentToFitSubtasks; + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag; function syncDateHelpers() { ({ dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag, clipLeavesToParentDue, expandParentToFitSubtasks } = createDateHelpers(TODAY, () => DATA)); + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag } = createDateHelpers(TODAY, () => DATA)); } syncDateHelpers(); @@ -276,8 +275,8 @@ function scheduleTodayRefresh() { } /* bars carry only FOUR urgency colors (owner identity is in the bubble) */ /* vivid, candy-bright status palette (like the reference) */ -/* a parent task's bar uses its own dates/size but grows when subtasks extend - past it — never shrinks. Projects still roll up across all descendant leaves. */ +/* a parent task's bar uses its own dates/size; subtask edits do not change it. + Projects still roll up across all descendant leaves. */ /* effort weight = working days (Mon–Fri) inside a leaf's bar span, so the team doesn't have to size every item — a longer subtask simply weighs more. A set size still counts, because size feeds the bar's span via LEAD, which feeds this. Min 1 so a single-day or weekend-only @@ -873,11 +872,6 @@ let BARMENU=null; const BM=document.createElement("div"); BM.id="barMenu"; BM.className="barmenu"; document.body.appendChild(BM); function openBarMenu(id,anchor){ const path=findPath(id); if(!path) return; const n=path.pop(); - const depth=path.length; - const sortBtn=(depth===0||depth===1) - ? `` - : ""; if(anchor&&anchor.getBoundingClientRect) anchor=anchor.getBoundingClientRect(); if(anchor) BM._anchor={left:anchor.left,right:anchor.right,top:anchor.top,bottom:anchor.bottom}; const a=BM._anchor||{left:100,right:160,top:100,bottom:130}; @@ -886,8 +880,7 @@ function openBarMenu(id,anchor){
${Object.entries(PEOPLE).map(([k,p])=>``).join("")}
Size${SIZE_KEYS.map(z=>``).join("")}
Start
-
End
- ${sortBtn}`; +
End
`; BM.classList.add("show"); BARMENU=id; const mw=BM.offsetWidth||236, mh=BM.offsetHeight||170, gap=8, vw=window.innerWidth, vh=window.innerHeight; // sit to the RIGHT of the pill (flip left only if there's no room) @@ -899,39 +892,15 @@ function openBarMenu(id,anchor){ } function refreshBarMenu(id){ if(BARMENU===id) openBarMenu(id); } function closeBarMenu(){ BM.classList.remove("show"); BARMENU=null; } -function startSortKey(n){ - const { s }=barSpan(n); - return (n.due||n.start)&&!isNaN(s)?s:Infinity; -} -function sortByStartDate(id){ - const path=findPath(id); - if(!path||path.length>2) return; - snap(); - sortSubtreeByStart(path[path.length-1], startSortKey); - renderAll(); - ding(1); - if(DETAIL_ID===id) openDetail(id); - else if(BARMENU===id) refreshBarMenu(id); -} document.addEventListener("pointerdown",e=>{ if(BARMENU&&!e.target.closest("#barMenu")) closeBarMenu(); },true); -function syncTaskDates(n,field){ - if(!n.start||!n.due) return; - const s=dayN(n.start), e=dayN(n.due); - if(isNaN(s)||isNaN(e)||s<=e) return; - if(field==="start") n.due=n.start; else n.start=n.due; -} function updTask(id,f,v,quiet){ snap(); const path=findPath(id); const n=path.pop(); if(f==="title") n.title=v.trim()||n.title; else if(f==="owner") n.owner=v; else if(f==="priority") n.priority=v; - else if(f==="due"){ n.due=v||null; syncTaskDates(n,"due"); - if(path.length===1) clipLeavesToParentDue(n); - else if(path.length===2) expandParentToFitSubtasks(path[1]); } - else if(f==="start"){ n.start=v||null; syncTaskDates(n,"start"); - if(path.length===2) expandParentToFitSubtasks(path[1]); } - else if(f==="size"){ n.size=v?normalizeSize(v):null; - if(path.length===2) expandParentToFitSubtasks(path[1]); } + else if(f==="due") n.due=v||null; + else if(f==="start") n.start=v||null; + else if(f==="size") n.size=v?normalizeSize(v):null; else if(f==="comment") n.comment=v.trim()||null; renderAll(); if(!quiet&&f!=="title"&&f!=="comment") openDetail(id); } function deleteTask(id){ const n=findPath(id).pop(); @@ -1033,13 +1002,6 @@ function openDetail(id,opts){ ${SIZE_KEYS.map(z=>``).join("")}
` : `
Size${_szPts} pts
`; const kind=path.length===1?"project":path.length===2?"task":"subtask"; - const listHdr=path.length===1 - ? `Tasks — grip ⠿ to drag onto another project` - : `Subtasks`; - const sortBtn=path.length<=2 - ? `` - : ""; document.getElementById("dBody").innerHTML=`
Owner${av(n.owner)}
@@ -1053,7 +1015,7 @@ function openDetail(id,opts){ `:""}
Move to
- ${path.length>=3?"":`
${listHdr}${sortBtn}
`} + ${path.length>=3?"":`
${path.length>1?"Subtasks":"Tasks — grip ⠿ to drag onto another project"}
`} ${n.children.map(ch=>{ const lp=pct(ch), lleaf=!ch.children.length; const szCtl=``; return `
@@ -1737,7 +1699,7 @@ const _globals = { toggleFlyout, toggleFocus, toggleShowDone, toggleSubs, closeCapture, toggleCapLang, minimizeCapture, sendTurn, restoreCapture, skipKey, saveKey, clearKey, closeTranscript, runTranscript, closeReview, closeTeam, closeSheet, saveDetail, openProjectChart, setFilter, setScaleView, ding, toggleDone, openDetail, setZoom, setGView, - toggleExp, updTask, refreshBarMenu, sortByStartDate, addChild, addProject, deleteTask, addCapTask, barDown, barContext, pickSearch, + toggleExp, updTask, refreshBarMenu, addChild, addProject, deleteTask, addCapTask, barDown, barContext, pickSearch, projDown, rowDown, uploadPhoto, removePhoto, rvToggle, rvText, rvOwner, rvDue, rvSize, pushApproved, attachTranscript, doSearch, refreshCard, delCapTask, setTask, setTaskOwner, setTaskSize, setSub, setSubOwner, addSub, diff --git a/src/lib/dates.js b/src/lib/dates.js index e023ab2..01feadc 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -1,7 +1,7 @@ import { LEAD } from "../data/constants.js"; import { C_DONE, C_LATE, C_LATER, C_RADAR, C_TODAY } from "../data/constants.js"; import { barSpan as _barSpan, dayIso, dayN, parseLocalIso } from "./date-core.js"; -import { flat, kids, findPath } from "./tree.js"; +import { flat, kids } from "./tree.js"; import { taskDone } from "./tree.js"; export { dayN, dayIso } from "./date-core.js"; @@ -85,53 +85,16 @@ export function createDateHelpers(today, getRoots = () => null) { return e === -Infinity ? null : { s, e }; } - /** Parent task span: own dates/size are the floor; subtasks may only push outward. */ + /** Parent tasks use their own dates on the gantt; projects roll up descendant leaves. */ function rollupSpan(n) { const env = childEnvelope(n); if (!env) return barSpan(n); - if (taskWithSubtasks(n) && n.due) { - const own = barSpan(n); - return { - s: env.s < own.s ? env.s : own.s, - e: env.e > own.e ? env.e : own.e, - }; - } + if (taskWithSubtasks(n) && n.due) return barSpan(n); return env; } const spanFor = (n) => (kids(n).length ? rollupSpan(n) : barSpan(n)); - function clampLeafEnd(leaf, maxE) { - const sp = barSpan(leaf); - if (sp.e <= maxE) return; - const dur = sp.e - sp.s; - let newE = maxE; - let newS = newE - dur; - if (newS > newE) newS = newE; - leaf.start = dayIsoLocal(newS); - leaf.due = dayIsoLocal(newE); - } - - /** Grow a parent task's stored dates when subtasks extend past them (never shrink). */ - function expandParentToFitSubtasks(n) { - if (!taskWithSubtasks(n) || !n.due) return; - const env = childEnvelope(n); - if (!env) return; - const own = barSpan(n); - const ownS = n.start ? dayNLocal(n.start) : own.s; - const ownE = dayNLocal(n.due); - if (env.e > ownE) n.due = dayIsoLocal(env.e); - if (env.s < ownS) n.start = dayIsoLocal(env.s); - } - function clipLeavesToParentDue(n) { - if (!taskWithSubtasks(n) || !n.due) return; - const maxE = dayNLocal(n.due); - flat([n], (x) => { - if (kids(x).length || !x.due) return; - clampLeafEnd(x, maxE); - }); - } - function leafWeight(n) { const { s, e } = barSpan(n); const w = workDays(s, e); @@ -169,7 +132,7 @@ export function createDateHelpers(today, getRoots = () => null) { kids(n).forEach((c) => shiftSubtreeDates(c, dd)); } - /** Apply a bar move/resize; parent tasks keep their own dates when resized. */ + /** Apply a bar move/resize; each node's dates change independently except parent moves. */ function commitBarDrag(n, mode, s, e, s0, e0) { if (!kids(n).length) { if (mode === "move") { @@ -181,11 +144,6 @@ export function createDateHelpers(today, getRoots = () => null) { if (!n.start) n.start = dayIsoLocal(s0); n.due = dayIsoLocal(e); } - const roots = getRoots(); - if (roots && n.id) { - const path = findPath(n.id, roots); - if (path?.length === 3) expandParentToFitSubtasks(path[1]); - } return; } const old = spanFor(n); @@ -198,7 +156,6 @@ export function createDateHelpers(today, getRoots = () => null) { return; } n.due = dayIsoLocal(e); - clipLeavesToParentDue(n); } return { @@ -215,8 +172,6 @@ export function createDateHelpers(today, getRoots = () => null) { isUrgent, fmtD, commitBarDrag, - clipLeavesToParentDue, - expandParentToFitSubtasks, taskWithSubtasks, }; } diff --git a/src/lib/tree.js b/src/lib/tree.js index aee7116..5fc522d 100644 --- a/src/lib/tree.js +++ b/src/lib/tree.js @@ -26,17 +26,6 @@ export function createTaskFactory() { export const kids = (n) => n.children || []; -/** Sort children (and nested subtasks) by ascending start key; undated items go last. */ -export function sortSubtreeByStart(n, startKey) { - const ch = kids(n); - if (!ch.length) return; - ch.sort((a, b) => { - const d = startKey(a) - startKey(b); - return d !== 0 ? d : a.title.localeCompare(b.title); - }); - ch.forEach((c) => sortSubtreeByStart(c, startKey)); -} - export const flat = (nodes, fn, depth = 0, path = []) => nodes.forEach((n) => { fn(n, depth, path); diff --git a/tests/dates.test.js b/tests/dates.test.js index 1fd636c..a61e004 100644 --- a/tests/dates.test.js +++ b/tests/dates.test.js @@ -37,7 +37,7 @@ describe("dates", () => { expect(h.spanFor(leaf).e).toBe(8); }); - it("expands a parent task when subtasks extend outside its dates", () => { + it("uses a parent task's own span regardless of subtask dates", () => { const parent = { due: "2026-06-30", size: "m", @@ -46,85 +46,18 @@ describe("dates", () => { { due: "2026-06-22", size: "m", children: [] }, ], }; - const own = h.barSpan(parent); - const span = h.rollupSpan(parent); - expect(span.e).toBe(own.e); - expect(span.s).toBe(h.barSpan(parent.children[0]).s); - expect(span.s).toBeLessThan(own.s); + expect(h.rollupSpan(parent)).toEqual(h.barSpan(parent)); }); - it("does not shrink a parent task when subtasks fall inside its dates", () => { - const parent = { - start: "2026-06-15", - due: "2026-06-30", - size: "l", - children: [{ due: "2026-06-20", size: "s", children: [] }], - }; - const own = h.barSpan(parent); - const span = h.spanFor(parent); - expect(span.s).toBe(own.s); - expect(span.e).toBe(own.e); - }); - - it("expands the parent task when a subtask due date is moved later", () => { - const parent = { - due: "2026-06-20", - size: "m", - children: [{ due: "2026-07-05", size: "s", children: [] }], - }; - const ownE = h.barSpan(parent).e; - h.expandParentToFitSubtasks(parent); - expect(h.dayN(parent.due)).toBeGreaterThan(ownE); - expect(h.spanFor(parent).e).toBe(h.barSpan(parent.children[0]).e); - }); - - it("expands the parent task start when a subtask begins earlier", () => { - const parent = { - start: "2026-06-20", - due: "2026-06-30", - size: "m", - children: [{ due: "2026-06-14", size: "s", children: [] }], - }; - h.expandParentToFitSubtasks(parent); - expect(h.dayN(parent.start)).toBe(h.barSpan(parent.children[0]).s); - }); - - it("clips subtasks when a parent task's end date moves earlier", () => { + it("does not change subtasks when a parent task due date is edited", () => { const parent = { due: "2026-06-30", size: "m", children: [{ due: "2026-07-05", size: "s", children: [] }], }; - parent.due = "2026-06-22"; - h.clipLeavesToParentDue(parent); - expect(h.dayN(parent.children[0].due)).toBe(h.dayN(parent.due)); - expect(h.barSpan(parent.children[0]).e).toBe(h.dayN(parent.due)); - }); - - it("does not shrink a parent task when subtasks are moved inward", () => { - const parent = { - start: "2026-06-10", - due: "2026-06-30", - size: "m", - children: [{ due: "2026-06-20", size: "s", children: [] }], - }; - const dueBefore = parent.due; - const startBefore = parent.start; - h.expandParentToFitSubtasks(parent); - expect(parent.due).toBe(dueBefore); - expect(parent.start).toBe(startBefore); - }); - - it("does not clip subtasks when only the parent start date changes", () => { - const parent = { - start: "2026-06-20", - due: "2026-06-30", - size: "m", - children: [{ due: "2026-06-14", size: "s", children: [] }], - }; const subDue = parent.children[0].due; - parent.start = "2026-06-18"; - h.clipLeavesToParentDue(parent); + parent.due = "2026-06-22"; + h.commitBarDrag(parent, "r", h.barSpan(parent).s, h.dayN("2026-06-22"), h.barSpan(parent).s, h.barSpan(parent).e); expect(parent.children[0].due).toBe(subDue); }); @@ -162,19 +95,12 @@ describe("dates", () => { expect(h.spanFor(parent).e).toBe(before.e + 3); }); - it("extends only the parent task when its bar is resized on the right", () => { - const parent = { - due: "2026-06-30", - size: "m", - children: [ - { due: "2026-06-14", size: "s", children: [] }, - { due: "2026-06-22", size: "m", children: [] }, - ], - }; - const before = h.spanFor(parent); - const subDue = parent.children[1].due; - h.commitBarDrag(parent, "r", before.s, before.e + 2, before.s, before.e); - expect(h.spanFor(parent).e).toBe(before.e + 2); - expect(parent.children[1].due).toBe(subDue); + it("resizes a leaf bar start and end independently", () => { + const leaf = { due: "2026-06-30", start: "2026-06-20", size: "m", children: [] }; + h.commitBarDrag(leaf, "l", 5, 18, 10, 18); + expect(h.dayN(leaf.start)).toBe(5); + expect(h.dayN(leaf.due)).toBe(18); + h.commitBarDrag(leaf, "r", 5, 12, 5, 18); + expect(h.dayN(leaf.due)).toBe(12); }); }); diff --git a/tests/tree.test.js b/tests/tree.test.js index 1970978..853b16b 100644 --- a/tests/tree.test.js +++ b/tests/tree.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createTaskFactory, counts, findPath, normalizeTaskTree, pct, progFrac, sortSubtreeByStart, taskDone, taskDoneAt } from "../src/lib/tree.js"; +import { createTaskFactory, counts, findPath, normalizeTaskTree, pct, progFrac, taskDone, taskDoneAt } from "../src/lib/tree.js"; import { SIZE_PTS } from "../src/data/constants.js"; describe("tree", () => { @@ -74,25 +74,4 @@ describe("tree", () => { const task = T("Task", "sk", { comment: "Needs review" }); expect(task.comment).toBe("Needs review"); }); - - it("sorts children and subtasks by start key", () => { - const root = T("Project", "ia", { - c: [ - T("Late task", "sk", { d: "2026-06-30", st: "2026-06-20", c: [ - T("Sub B", "sk", { d: "2026-06-25", st: "2026-06-22", c: [] }), - T("Sub A", "sk", { d: "2026-06-18", st: "2026-06-15", c: [] }), - ] }), - T("Early task", "sk", { d: "2026-06-15", st: "2026-06-10", c: [] }), - T("No dates", "sk", { c: [] }), - ], - }); - const key = (n) => { - if (n.start) return Date.parse(n.start); - if (n.due) return Date.parse(n.due); - return Infinity; - }; - sortSubtreeByStart(root, key); - expect(root.children.map((c) => c.title)).toEqual(["Early task", "Late task", "No dates"]); - expect(root.children[1].children.map((c) => c.title)).toEqual(["Sub A", "Sub B"]); - }); }); From 6aa2e00904d9636ab2e84757324ee7c87a112e0d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 18:41:26 +0000 Subject: [PATCH 12/12] Allow resizing start date on overdue and due-today bars Stop collapsing late/today task bars to a fixed today box in barGeom. Bars now render from their real start through today so the left resize ear moves with the start date during gantt drags. Co-authored-by: Tanops --- CHANGELOG.md | 5 +++++ src/app/main.js | 4 ++-- src/lib/dates.js | 7 ++++--- tests/dates.test.js | 5 +++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a2642..8fe678d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Draggable start on overdue and due-today bars (2026-06-22) + +### Fixed +- Overdue and due-today task bars no longer collapse to a fixed today box; they show the real start date through today so the left resize ear can change the start date on the chart. + ## Independent date editing (2026-06-22) ### Changed diff --git a/src/app/main.js b/src/app/main.js index f633639..48019e6 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -290,9 +290,9 @@ function scheduleTodayRefresh() { let TW = 2.2, SPAN_EFFV = SPAN_G + 1.2; const uDay=t=>t<0?t:(t<1?t*TW:TW+(t-1)); // day → stretched-day units const gx=t=>(uDay(t)-R0G)/SPAN_EFFV*100; // day → % position on the track -/* a due date means END of that day, and open work never lives in the past: +/* a due date means END of that day: — done tasks keep their historical span - — late and due-today tasks ALL span exactly the today box, ending ON the today line + — overdue / due-today tasks show their real start through today so resize ears work — future tasks start today at the earliest and end at the end of their due day */ /* re-renders triggered by clicks are deferred out of the input event: mutating the DOM while Chrome is still dispatching the click can wedge its hover/input pipeline diff --git a/src/lib/dates.js b/src/lib/dates.js index 01feadc..7144fa7 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -63,13 +63,14 @@ export function createDateHelpers(today, getRoots = () => null) { rs = s; re = e + 1; } else if (e <= 0) { - rs = 0; - re = 1; + // overdue / due today: keep the real start so resize ears work; stretch through today + rs = s; + re = Math.max(e + 1, 1); } else { rs = Math.max(s, 0); re = e + 1; } - const cs = Math.max(rs, r0g); + const cs = !done && e <= 0 ? rs : Math.max(rs, r0g); return [cs, Math.min(Math.max(re, cs + 0.5), r1g)]; } diff --git a/tests/dates.test.js b/tests/dates.test.js index a61e004..ab595da 100644 --- a/tests/dates.test.js +++ b/tests/dates.test.js @@ -28,8 +28,9 @@ describe("dates", () => { expect(h.barColor(5, 3, true)).toBe("#c8cdd6"); }); - it("clamps late tasks to the today box", () => { - expect(h.barGeom(-3, -1, false)).toEqual([0, 1]); + it("shows overdue spans from start through today for resize", () => { + expect(h.barGeom(-3, -1, false)).toEqual([-3, 1]); + expect(h.barGeom(-2, 0, false)).toEqual([-2, 1]); }); it("spanFor tolerates nodes without a children array", () => {