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 });
+ });
});