From 41af9e1d518fd3bff16bfd7e3e5876b453f3dc2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 15:19:21 +0000 Subject: [PATCH] Six ordered gantt timeline views (-6w through +6w) Add six zoom presets (last 6w, last 3w, 2w back/3w ahead, next week, next 3w, next 6w) with dynamic gR0/gR1 range. Rebased onto main as a single linear commit for clean GitHub rebase/merge. Co-authored-by: Sanket Sharma --- CHANGELOG.md | 8 +++++ index.html | 3 +- src/app/main.js | 75 ++++++++++++++++++++++++++++------------- src/data/constants.js | 16 ++++++--- tests/constants.test.js | 11 ++++++ 5 files changed, 84 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe678d..1014d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Past gantt views (2026-06-22) + +### Added +- Six timeline zoom presets in order: last 6 weeks, last 3 weeks, 2 weeks back through 3 weeks ahead, next week, next 3 weeks, and next 6 weeks. + +### Fixed +- Merged `main` (ear resize tooltips, mic FAB changes) and kept dynamic `gR0`/`gR1` gantt range from this branch. + ## Draggable start on overdue and due-today bars (2026-06-22) ### Fixed diff --git a/index.html b/index.html index 471b478..77fd935 100644 --- a/index.html +++ b/index.html @@ -258,7 +258,8 @@ .gflegend{background:none;border:none;box-shadow:none;flex-direction:column;align-items:flex-end;gap:6px;padding:8px 4px} .gflegend span{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;font-weight:700;color:var(--ink);white-space:nowrap} .gflegend i{width:12px;height:12px;border-radius:4px;flex:none} - .gzoom button{padding:7px 13px;font-size:12.5px;min-height:34px;display:flex;align-items:center;justify-content:center} + .gzoom{flex-direction:column;align-items:stretch;max-width:min(260px,92vw)} + .gzoom button{padding:8px 14px;font-size:12.5px;min-height:34px;justify-content:flex-start;white-space:nowrap} /* owners shown as a compact segmented row of avatars */ .gpeople{padding:3px;gap:2px} .gpeople button{padding:2px;min-height:0;width:38px;height:34px;border-radius:8px;display:flex; diff --git a/src/app/main.js b/src/app/main.js index 36d526c..c850789 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -1,7 +1,7 @@ import { PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, SIZE_KEYS, SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, normalizeSize, sizePts, barHeight, - R0G, R1G, SPAN_G, TODAY_PX, + R0G, R1G, SPAN_G, TODAY_PX, ganttRange, C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, } from "../data/constants.js"; import { inferOwnerByDomain, canonHardware, findClient, buildRespMapText, buildVocabText, norm as _norm } from "../lib/domain.js"; @@ -150,7 +150,7 @@ function renderDash(){
-
late · due today${HZ>0?` · ${({7:"due this week",21:"due in 3 weeks",42:"due in 6 weeks"})[HZ]}`:""} · ${HZ>0?"done this period":"done today"} ✓
`; +
late · due today${HZ>0?` · ${ZOOMS[ZOOM].l.toLowerCase()}`:""} · ${HZ>0?"done this period":"done today"} ✓
`; sizeScale(); settleScale(lastTilt); // position pills synchronously, then animate to the new tilt if(typeof requestAnimationFrame!=="undefined") @@ -287,9 +287,9 @@ function scheduleTodayRefresh() { /* the today column has a FIXED pixel width at every zoom level; the other days share the remaining screen. TW = today's width expressed in "normal day" units, recomputed each render from the panel width. */ -let TW = 2.2, SPAN_EFFV = SPAN_G + 1.2; +let TW = 2.2, SPAN_EFFV = SPAN_G + 1.2, gR0 = 0, gR1 = R1G; 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 +const gx=t=>(uDay(t)-gR0)/SPAN_EFFV*100; // day → % position on the track /* a due date means END of that day: — done tasks keep their historical span — overdue / due-today tasks show their real start through today so resize ears work @@ -329,7 +329,12 @@ let ganttScroll={left:0,top:0}; // preserved across re-renders so edits don't ju let ganttRestoring=false; let pageScrollY=0; // window scroll on layouts where .gscroll is horizontal-only function setGView(v){ GVIEW=v; defer(renderGantt); } -function setZoom(i){ ZOOM=i; setTimeout(renderAll,0); } // drives the scale window and the gantt zoom +let zoomScrollEnd=false, zoomScrollStart=false, zoomScrollCenter=false; +function setZoom(i){ const next=ZOOMS[i]; ZOOM=i; + zoomScrollEnd=next.r1===0; + zoomScrollStart=next.r0===0; + zoomScrollCenter=next.r0<0&&next.r1>0; + setTimeout(renderAll,0); } // drives the scale window and the gantt zoom function onGanttScroll(){ const sc=document.querySelector(".gscroll"); if(!sc) return; if(!ganttRestoring){ ganttScroll.left=sc.scrollLeft; ganttScroll.top=sc.scrollTop; } @@ -366,26 +371,32 @@ function applyBarDrag(n,mode,s,e,s0,e0){ else { if(!n.start) n.start=dayIso(s0); n.due=dayIso(e); } } function renderGantt(){ - const VIS=ZOOMS[ZOOM].v; + const zoom=ZOOMS[ZOOM]; + const VIS=zoom.v; + ({ r0: gR0, r1: gR1 } = ganttRange(zoom)); + const gSpan=gR1-gR0; // fixed-width today box: convert TODAY_PX into day units for the current zoom + panel width. // On phones the box is narrower so the rest of the timeline isn't squeezed off-screen. const TPX=(typeof window!=="undefined"&&window.innerWidth<=740)?150:TODAY_PX; const host=document.querySelector(".gscroll")||document.getElementById("gantt"); const PW=Math.max((host&&host.clientWidth)||0,360); const dayPx=Math.max((PW-TPX)/Math.max(VIS-1,1),3); - TW=TPX/dayPx; SPAN_EFFV=SPAN_G+(TW-1); + TW=TPX/dayPx; SPAN_EFFV=gSpan+(TW-1); // calendar axis: month headers + one weekday-letter + number per day (weekly when too tight) const showDaily=dayPx>=20; const months=[]; let lastM=-1; - for(let d=0;d<=R1G;d++){ const dt=parseLocalIso(dayIso(d)), m=dt.getFullYear()*12+dt.getMonth(); + for(let d=gR0;d<=gR1;d++){ const dt=parseLocalIso(dayIso(d)), m=dt.getFullYear()*12+dt.getMonth(); if(m!==lastM){ lastM=m; months.push({d,label:dt.toLocaleDateString("en-GB",{month:"short",year:"numeric"})}); } } const dticks=[]; - for(let d=0;d<=R1G;d++){ const dt=parseLocalIso(dayIso(d)), wknd=dt.getDay()%6===0; + for(let d=gR0;d<=gR1;d++){ const dt=parseLocalIso(dayIso(d)), wknd=dt.getDay()%6===0; if(showDaily||dt.getDay()===1||d===0) dticks.push({d,wd:"SMTWTFS"[dt.getDay()],num:dt.getDate(),wknd,today:d===0}); } const zoomEl=document.getElementById("gzoom"); - if(zoomEl){ // build once; afterwards only toggle classes (keeps the clicked button alive) - if(!zoomEl.childElementCount) - zoomEl.innerHTML=ZOOMS.map((z,i)=>``).join(""); + if(zoomEl){ + const zoomSig=ZOOMS.map(z=>`${z.s}\0${z.r0}\0${z.r1}`).join("\n"); + if(zoomEl.dataset.sig!==zoomSig){ + zoomEl.dataset.sig=zoomSig; + zoomEl.innerHTML=ZOOMS.map((z,i)=>``).join(""); + } [...zoomEl.children].forEach((b,i)=>b.classList.toggle("active",ZOOM===i)); } // keep the three toggle pictograms lit in line with their state @@ -399,15 +410,15 @@ function renderGantt(){ [...gv.children].forEach((b,i)=>b.classList.toggle("active",GVIEW===GVKEYS[i])); } // calendar grid: faint day lines, firmer Monday lines, alternate weeks washed - const wk=[0]; - for(let d=1;d<=R1G;d++) if(parseLocalIso(dayIso(d)).getDay()===1) wk.push(d); - wk.push(R1G); + const wk=[gR0]; + for(let d=gR0+1;d<=gR1;d++) if(parseLocalIso(dayIso(d)).getDay()===1) wk.push(d); + if(wk[wk.length-1]!==gR1) wk.push(gR1); const deco=[]; for(let i=0;i`); - for(let d=1;d`); } - months.forEach(m=>{ if(m.d>0) deco.push(`
`); }); + months.forEach(m=>{ if(m.d>gR0) deco.push(`
`); }); const td=parseLocalIso(dayIso(0)); const ord=n=>{const s=["th","st","nd","rd"],v=n%100;return n+(s[(v-20)%10]||s[v]||s[0]);}; const todayStr="Today, "+td.toLocaleDateString("en-GB",{weekday:"long"})+" "+ord(td.getDate())+" "+td.toLocaleDateString("en-GB",{month:"long"}); @@ -415,7 +426,7 @@ function renderGantt(){ const rows=[deco.join("")+ `
-
${months.map((m,i)=>{const end=i+1${months.map((m,i)=>{const end=i+1${m.label}`;}).join("")}
${dticks.map(t=> t.today ? `${todayStr}` @@ -426,8 +437,8 @@ function renderGantt(){ const barRow=(n,extra,isSub,ctx)=>{ // one chart row for a task or a subtask if(!n.due) return ""; const {s,e}=spanFor(n), done=isSub?n.done:taskDone(n); - if(eR1G) return ""; - const late=!done&&e<0, [tcs,tce]=barGeom(s,e,done), sz=normalizeSize(n.size); + if(egR1) return ""; + const late=!done&&e<0, [tcs,tce]=barGeom(s,e,done,gR0,gR1), sz=normalizeSize(n.size); const h=barHeight(sz); // same thickness scale for tasks and subtasks // a task with subtasks shows its duration-weighted completion as a darker fill inside its // own bar (same two-tone idea as the project summary bar, applied in place) @@ -474,7 +485,7 @@ function renderGantt(){ const col=person(p.owner).color; // thin summary bar spanning the project's whole task range (earliest start → latest due) const sp=rollupSpan(p); - const scs=Math.max(sp.s,R0G), sce=Math.max(Math.min(sp.e+1,R1G),scs+0.5); + const scs=Math.max(sp.s,gR0), sce=Math.max(Math.min(sp.e+1,gR1),scs+0.5); const taskRows=[...p.children] .filter(t=>{ let rel=want(t.owner); flat([t],x=>{ if(!x.children.length&&want(x.owner)) rel=true; }); return rel; }) @@ -520,7 +531,7 @@ function renderGantt(){ if((wantTask?taskDone(n):n.done)&&!showDone) return; if(!n.due) return; const {s,e}=barSpan(n); - if(eR1G) return; + if(egR1) return; const root=path[0]||n, par=path.length>1?path[path.length-2]:null; cand.push({n,e,root,par}); }); @@ -547,7 +558,23 @@ function renderGantt(){ `
`; const sc=document.querySelector(".gscroll"); sc.addEventListener("scroll",onGanttScroll,{passive:true}); - restoreGanttScroll(sc); + if(zoomScrollEnd){ + zoomScrollEnd=false; + ganttRestoring=true; + const snapEnd=()=>{ sc.scrollLeft=Math.max(0,sc.scrollWidth-sc.clientWidth); ganttScroll.left=sc.scrollLeft; pinFlags(); }; + snapEnd(); + if(typeof requestAnimationFrame!=="undefined") + requestAnimationFrame(()=>{ snapEnd(); requestAnimationFrame(()=>{ snapEnd(); ganttRestoring=false; }); }); + else ganttRestoring=false; + } else if(zoomScrollCenter){ + zoomScrollCenter=false; + ganttScroll.left=0; + restoreGanttScroll(sc); + } else if(zoomScrollStart){ + zoomScrollStart=false; + ganttScroll.left=0; + restoreGanttScroll(sc); + } else restoreGanttScroll(sc); restorePageScroll(); const gpane=document.querySelector(".gantt"); if(gpane&&!gpane._floatBound){ gpane._floatBound=true; gpane.addEventListener("scroll",placeFloat,{passive:true}); } @@ -690,7 +717,7 @@ function barMove(ev){ else if(G.mode==="l"){ G.s=Math.min(G.s0+dd,G.e0); } else { G.e=Math.max(G.e0+dd,G.s0); } const done=(G.n.children||[]).length?taskDone(G.n):G.n.done; - const [cs,ce]=barGeom(G.s,G.e,done); + const [cs,ce]=barGeom(G.s,G.e,done,gR0,gR1); G.el.style.left=gx(cs)+"%"; G.el.style.width=(gx(ce)-gx(cs))+"%"; if(G.ghost){ G.ghost.style.display=G.moved?"":"none"; diff --git a/src/data/constants.js b/src/data/constants.js index e4f8081..1433205 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -52,12 +52,20 @@ export const sizePts = (size) => SIZE_PTS[normalizeSize(size)]; export const barHeight = (size) => GBAR_H[normalizeSize(size)]; export const ZOOMS = [ - { l: "Day", h: 0, v: 3 }, - { l: "Week", h: 7, v: 7 }, - { l: "3 weeks", h: 21, v: 21 }, - { l: "6 weeks", h: 42, v: 42 }, + { l: "Last 6 weeks", s: "−6 weeks", h: 42, v: 42, r0: -41, r1: 0 }, + { l: "Last 3 weeks", s: "−3 weeks", h: 21, v: 21, r0: -20, r1: 0 }, + { l: "2 weeks back to 3 weeks ahead", s: "−2 wks to +3 wks", h: 35, v: 36, r0: -14, r1: 21 }, + { l: "Next week", s: "+1 week", h: 7, v: 7, r0: 0, r1: 6 }, + { l: "Next 3 weeks", s: "+3 weeks", h: 21, v: 21, r0: 0, r1: 20 }, + { l: "Next 6 weeks", s: "+6 weeks", h: 42, v: 42, r0: 0, r1: 41 }, ]; +/** Visible gantt day range for a zoom preset (day offsets from today). */ +export function ganttRange(zoom) { + if (zoom?.r0 != null && zoom?.r1 != null) return { r0: zoom.r0, r1: zoom.r1 }; + return { r0: R0G, r1: R1G }; +} + export const GBAR_H = { s: 26, m: 34, l: 44, xl: 56, xxl: 68 }; export const R0G = 0; export const R1G = 90; diff --git a/tests/constants.test.js b/tests/constants.test.js index 539deb4..6e6d661 100644 --- a/tests/constants.test.js +++ b/tests/constants.test.js @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { barHeight, GBAR_H, + ganttRange, LEAD, normalizeSize, SIZE_KEYS, SIZE_NAMES, SIZE_PTS, sizePts, + ZOOMS, } from "../src/data/constants.js"; describe("size constants", () => { @@ -39,4 +41,13 @@ describe("size constants", () => { expect(barHeight("xs")).toBe(GBAR_H.s); expect(barHeight("m")).toBe(barHeight(null)); }); + + it("maps zoom presets to explicit gantt day ranges", () => { + expect(ganttRange(ZOOMS[0])).toEqual({ r0: -41, r1: 0 }); + expect(ganttRange(ZOOMS[1])).toEqual({ r0: -20, r1: 0 }); + expect(ganttRange(ZOOMS[2])).toEqual({ r0: -14, r1: 21 }); + expect(ganttRange(ZOOMS[3])).toEqual({ r0: 0, r1: 6 }); + expect(ganttRange(ZOOMS[4])).toEqual({ r0: 0, r1: 20 }); + expect(ganttRange(ZOOMS[5])).toEqual({ r0: 0, r1: 41 }); + }); });