diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0fc0f..8fe678d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # 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 +- 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. +- 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) ### Fixed diff --git a/index.html b/index.html index 2e540b4..554fd0a 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} @@ -278,6 +282,15 @@ /* 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;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} /* 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 3281b36..48019e6 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -241,7 +241,7 @@ let dayN, dayIso, barSpan, workDays, barColor, barGeom, rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag; function syncDateHelpers() { ({ dayN, dayIso, barSpan, workDays, barColor, barGeom, - rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag } = createDateHelpers(TODAY)); + rollupSpan, spanFor, leafWeight, progWD, isUrgent, fmtD, commitBarDrag } = 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 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 @@ -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 @@ -489,12 +489,22 @@ 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}
`); }); @@ -884,25 +894,19 @@ function refreshBarMenu(id){ if(BARMENU===id) openBarMenu(id); } function closeBarMenu(){ BM.classList.remove("show"); BARMENU=null; } 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 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"); } - else if(f==="start"){ n.start=v||null; syncTaskDates(n,"start"); } + 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(); 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(); @@ -918,9 +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}); @@ -948,6 +973,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; } @@ -966,10 +994,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 +1017,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 `
@@ -1000,13 +1025,21 @@ 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); + }; + if(opts?.focusTask) requestAnimationFrame(()=>document.getElementById("dSubNew")?.focus()); } function closeSheet(){ DETAIL_ID=null; document.getElementById("tmodal").classList.remove("show"); @@ -1665,7 +1698,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, 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, diff --git a/src/lib/dates.js b/src/lib/dates.js index df69152..7144fa7 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; @@ -37,17 +63,18 @@ export function createDateHelpers(today) { 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)]; } - function rollupSpan(n) { + function childEnvelope(n) { let s = Infinity; let e = -Infinity; flat([n], (x) => { @@ -56,7 +83,15 @@ 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 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) return barSpan(n); + return env; } const spanFor = (n) => (kids(n).length ? rollupSpan(n) : barSpan(n)); @@ -98,15 +133,7 @@ export function createDateHelpers(today) { 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; each node's dates change independently except parent moves. */ function commitBarDrag(n, mode, s, e, s0, e0) { if (!kids(n).length) { if (mode === "move") { @@ -126,35 +153,10 @@ export function createDateHelpers(today) { 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); 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); } return { @@ -171,5 +173,6 @@ export function createDateHelpers(today) { isUrgent, fmtD, commitBarDrag, + taskWithSubtasks, }; } diff --git a/tests/dates.test.js b/tests/dates.test.js index 65a3cde..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", () => { @@ -37,7 +38,7 @@ describe("dates", () => { expect(h.spanFor(leaf).e).toBe(8); }); - it("rolls parent span across child leaves", () => { + it("uses a parent task's own span regardless of subtask dates", () => { const parent = { due: "2026-06-30", size: "m", @@ -46,30 +47,38 @@ describe("dates", () => { { due: "2026-06-22", size: "m", children: [] }, ], }; - const span = h.rollupSpan(parent); - expect(span.s).toBeLessThanOrEqual(span.e); - expect(span.e).toBe(10); + expect(h.rollupSpan(parent)).toEqual(h.barSpan(parent)); }); - it("moves a parent task and its subtasks together", () => { + 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: [] }], + }; + const subDue = parent.children[0].due; + 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); + }); + + 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 before = h.spanFor(parent); - h.commitBarDrag(parent, "move", before.s + 3, before.e + 3, before.s, before.e); - expect(h.dayN(parent.due)).toBe(h.dayN("2026-07-03")); - expect(h.dayN(parent.children[0].due)).toBe(h.dayN("2026-06-17")); - expect(h.dayN(parent.children[1].due)).toBe(h.dayN("2026-06-25")); - expect(h.spanFor(parent).s).toBe(before.s + 3); - expect(h.spanFor(parent).e).toBe(before.e + 3); + 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); }); - it("resizes a parent task rollup from the right edge", () => { + it("moves a parent task and its subtasks together", () => { const parent = { due: "2026-06-30", size: "m", @@ -79,8 +88,20 @@ describe("dates", () => { ], }; const before = h.spanFor(parent); - 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")); + h.commitBarDrag(parent, "move", before.s + 3, before.e + 3, before.s, before.e); + expect(h.dayN(parent.due)).toBe(h.dayN("2026-07-03")); + expect(h.dayN(parent.children[0].due)).toBe(h.dayN("2026-06-17")); + expect(h.dayN(parent.children[1].due)).toBe(h.dayN("2026-06-25")); + expect(h.spanFor(parent).s).toBe(before.s + 3); + expect(h.spanFor(parent).e).toBe(before.e + 3); + }); + + 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); }); });