Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 41 additions & 25 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -275,17 +275,28 @@
/* 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 */
.gsumrow{min-height:30px;align-items:flex-start}
/* project bar: a base track = the whole project duration, with a progress fill over it
(auto-computed from task completion weighted by size) */
.gsumline{position:absolute;top:18px;height:9px;border-radius:999px;background:#ccd4e2;pointer-events:none;z-index:0}
.gsumfill{position:absolute;top:18px;height:9px;border-radius:999px;background:#3b6ef6;pointer-events:none;z-index:1;min-width:0}
.gsumlbl{position:absolute;top:0;z-index:2;font-size:12.5px;font-weight:700;color:var(--ink-2);
background:none;padding:0 2px 0 1px;white-space:nowrap;cursor:grab;
.gsumrow{min-height:30px;align-items:flex-start;padding-top:2px}
/* project header: title bubble on row 1, progress bar + % on row 2 — never stacked */
.gsumrow .gtrack{height:auto;overflow:visible}
.gsumtrack{position:absolute;top:4px;display:flex;flex-direction:column;align-items:flex-start;gap:4px}
.gsumbar{position:relative;left:0;right:0;top:auto;width:100%;min-height:9px;flex-shrink:0}
.gsumline{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);
height:9px;border-radius:999px;background:#ccd4e2;pointer-events:none;z-index:0}
.gsumfill{position:absolute;left:0;top:50%;transform:translateY(-50%);
height:9px;border-radius:999px;background:#3b6ef6;pointer-events:none;z-index:1;min-width:0}
.gsumlbl{position:relative;top:auto;left:0;z-index:2;flex-shrink:0;
display:inline-flex;align-items:center;
background:var(--surface);border:1px solid var(--line);border-radius:999px;
padding:2px 9px 2px 2px;box-shadow:0 2px 8px rgba(20,24,35,.1);
font:inherit;white-space:nowrap;cursor:grab;
touch-action:none;-webkit-user-select:none;user-select:none}
.gsumlbl:hover{color:var(--ink)}
.gsumpct{position:absolute;top:16px;transform:translateX(7px);font-size:11px;font-weight:700;
color:var(--ink-3);white-space:nowrap;pointer-events:none}
.gsumlbl:hover{box-shadow:0 3px 11px rgba(20,24,35,.15)}
.gsumlbl .gpin{position:relative;left:0;top:auto;transform:none;padding-left:0;
max-width:min(220px,38vw);pointer-events:none;gap:6px}
.gsumlbl .gpin .ttl{color:var(--ink);font-size:12px;font-weight:700}
.gsumlbl .gava .av{width:20px;height:20px;font-size:8.5px;box-shadow:0 0 0 2px var(--surface)}
.gsumpct{position:absolute;left:100%;top:50%;transform:translate(7px,-50%);
font-size:11px;font-weight:700;color:var(--ink-3);white-space:nowrap;pointer-events:none;z-index:2}
.pgroup.gdropover .gsumline{background:var(--accent)}
.pgroup.insb{box-shadow:0 -3px 0 0 var(--accent)}
.pgroup.insa{box-shadow:0 3px 0 0 var(--accent)}
Expand All @@ -294,11 +305,19 @@
.gbar.gdropover{box-shadow:0 0 0 3px var(--accent)}
.pgroup.gdropover{background:var(--accent-soft);border-radius:12px}
.gbar{position:absolute;top:50%;transform:translateY(-50%);height:20px;border-radius:999px;min-width:16px;
display:flex;align-items:center;gap:6px;padding:0 12px 0 4px;overflow:hidden;
box-shadow:0 2px 7px rgba(20,24,35,.16);
cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;
overflow:hidden}
.gbar:hover{box-shadow:0 3px 11px rgba(20,24,35,.24)}
.gbar:active{cursor:grabbing}
/* avatar + title cluster — slides within the bar to stay in the scrolled viewport */
.gpin{position:absolute;left:0;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:6px;
z-index:2;pointer-events:none;padding-left:4px;max-width:calc(100% - 24px)}
.gpin .ttl{font-size:12px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;flex:1;min-width:0;text-align:left;padding-left:1px}
.gbar.gsz-s .gpin .ttl{font-size:10.5px}.gbar.gsz-l .gpin .ttl{font-size:13px}
.gbar.gsz-xl .gpin .ttl{font-size:14px}
.gbar.gsub .gpin .ttl{color:inherit;font-size:11px}
/* owner avatar fills the round left cap with an even ~4px margin on every side, so the
space around it reads the same top, bottom and left regardless of bar height */
.gava{flex:none;position:relative;z-index:2;line-height:0}
Expand All @@ -308,20 +327,18 @@
.gbar.gsz-l .gava .av{width:36px;height:36px;font-size:14px}
.gbar.gsz-xl .gava .av{width:48px;height:48px;font-size:18px}
.gbar.gsz-s{height:26px}.gbar.gsz-m{height:34px}.gbar.gsz-l{height:44px}.gbar.gsz-xl{height:56px}
.gbar .ttl{font-size:12px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;pointer-events:none;flex:1;min-width:0;text-align:left;padding-left:1px}
.gbar.gsz-s .ttl{font-size:10.5px}.gbar.gsz-l .ttl{font-size:13px}.gbar.gsz-xl .ttl{font-size:14px}
/* title rendered to the right of a too-narrow bar (plain black text, no background) */
.gttlout{position:absolute;top:50%;transform:translateY(-50%);font-size:12px;font-weight:700;
color:var(--ink);white-space:nowrap;pointer-events:none;z-index:3}
.gdot{flex:none;position:relative;z-index:2;width:16px;height:16px;border-radius:50%;padding:0;
.gdot{position:absolute;right:4px;top:50%;transform:translateY(-50%);
flex:none;z-index:2;width:16px;height:16px;border-radius:50%;padding:0;
border:2px solid rgba(255,255,255,.9);color:#fff;font-size:9px;
display:flex;align-items:center;justify-content:center;
opacity:0;width:0;margin:0;border-width:0;transition:opacity .12s}
.gbar:hover .gdot{opacity:1;width:16px;border-width:2px;margin-right:0}
.gbar.gsz-s:hover .gdot{width:13px}
.gdot.on{opacity:1;width:16px;border-width:2px;background:#fff;border-color:#fff;color:var(--green)}
opacity:0;border-width:0;transition:opacity .12s}
.gbar:hover .gdot{opacity:1;border-width:2px}
.gbar.gsz-s:hover .gdot{width:13px;height:13px}
.gdot.on{opacity:1;border-width:2px;background:#fff;border-color:#fff;color:var(--green)}
.gbar.gsz-s .gdot{width:13px;height:13px}
/* title rendered to the right of a too-narrow bar (plain black text, no background) */
.gttlout{position:absolute;top:50%;transform:translateY(-50%);font-size:12px;font-weight:700;
color:var(--ink);white-space:nowrap;pointer-events:none;z-index:3}
.gavb{flex:none;position:relative;z-index:2;margin-left:auto;width:18px;height:18px;border-radius:50%;
background:#fff;font-size:8.5px;font-weight:800;pointer-events:none;
display:flex;align-items:center;justify-content:center}
Expand All @@ -333,15 +350,14 @@
.gexp:hover{color:var(--accent)}
/* subtasks are OUTLINED (light fill, coloured border + text) vs solid filled tasks */
.gbar.gsub{box-shadow:0 1px 4px rgba(20,24,35,.12)}
.gbar.gsub .ttl{color:inherit;font-size:11px}
.gbar.gsub .gava .av{width:15px;height:15px;font-size:7.5px;box-shadow:0 0 0 2px #fff}
/* subtask bars have a WHITE fill, so the white done-dot was invisible on them — give the dot
the bar's own colour on hover, and a solid green tick once checked */
.gbar.gsub .gdot{border-color:currentColor;color:currentColor}
.gbar.gsub .gdot.on{background:var(--green);border-color:var(--green);color:#fff}
.pflat{padding:12px 0 6px}
.gbar.gdone{opacity:.4}
.gbar.gdone .ttl{text-decoration:line-through}
.gbar.gdone .gpin .ttl{text-decoration:line-through}
.ear{position:absolute;top:0;bottom:0;width:13px;cursor:ew-resize;touch-action:none;
opacity:0;transition:opacity .15s}
.gbar:hover .ear,.gbar.dragging .ear{opacity:1}
Expand Down
98 changes: 66 additions & 32 deletions src/app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,7 @@ function renderGantt(){
?`background:#fff;border:2px solid ${col};color:${col}`
:fillBg}">
<i class="ear el" onpointerdown="barDown(event,${n.id},'l')"></i>
<span class="gava">${av(n.owner,"xs")}</span>
<span class="ttl">${n.title}</span>
<div class="gpin"><span class="gava">${av(n.owner,"xs")}</span><span class="ttl">${n.title}</span></div>
<button class="gdot ${done?'on':''}" onpointerdown="event.stopPropagation()"
onclick="event.stopPropagation();ding(4);toggleDone(${n.id})" aria-label="${done?'Undo done':'Mark done'}">${done?'✓':''}</button>
<i class="ear er" onpointerdown="barDown(event,${n.id},'r')"></i>
Expand Down Expand Up @@ -414,12 +413,18 @@ function renderGantt(){
let pPts=0; flat([p],x=>{ if(x.children.length) return; pPts+=SIZE_PTS[x.size||"m"]; });
const ph=Math.max(7,Math.min(20,Math.round(5+Math.sqrt(pPts)*2.2)));
rows.push(`<div class="pgroup" data-pid="${p.id}">
<div class="grow gsumrow" style="min-height:${18+ph+16}px"><div class="gtrack">
<button class="gsumlbl" style="left:${gx(scs)}%" onpointerdown="projDown(event,${p.id})"
data-full="${p.title} — ${ppc}% done · ${pPts} pts · ${open} open · due ${p.due?fmtD(p.due):"no date"} — click to manage, drag to reorder">${p.title}</button>
<div class="gsumline" style="left:${gx(scs)}%;width:${spanW}%;height:${ph}px"></div>
<div class="gsumfill" style="left:${gx(scs)}%;width:${spanW*prog}%;height:${ph}px"></div>
<span class="gsumpct" style="left:${gx(sce)}%;top:${Math.round(18+ph/2-6)}px">${ppc}%</span>
<div class="grow gsumrow"><div class="gtrack" style="height:${ph+34}px">
<div class="gsumtrack" style="left:${gx(scs)}%;width:${spanW}%">
<button class="gsumlbl" onpointerdown="projDown(event,${p.id})"
data-full="${p.title} — ${ppc}% done · ${pPts} pts · ${open} open · due ${p.due?fmtD(p.due):"no date"} — click to manage, drag to reorder">
<div class="gpin"><span class="gava">${av(p.owner,"xs")}</span><span class="ttl">${p.title}</span></div>
</button>
<div class="gsumbar">
<div class="gsumline" style="height:${ph}px"></div>
<div class="gsumfill" style="width:${prog*100}%;height:${ph}px"></div>
<span class="gsumpct">${ppc}%</span>
</div>
</div>
</div></div>
${taskRows}</div>`);
});
Expand Down Expand Up @@ -457,10 +462,10 @@ function renderGantt(){
(any?"":'<div class="grow"><span style="color:var(--ink-3);font-size:13.5px;padding:6px 0">No scheduled tasks for this filter.</span></div>')+
`</div></div>`;
const sc=document.querySelector(".gscroll");
sc.addEventListener("scroll",pinFlags,{passive:true});
if(!sc._pinBound){ sc._pinBound=true; sc.addEventListener("scroll",pinGanttScroll,{passive:true}); }
const gpane=document.querySelector(".gantt");
if(gpane&&!gpane._floatBound){ gpane._floatBound=true; gpane.addEventListener("scroll",placeFloat,{passive:true}); }
pinFlags(); placeFloat(); placeOverflowTitles();
pinGanttScroll(); placeFloat(); placeOverflowTitles();
// unstick Chromium's hover hit-testing after the DOM swap (otherwise tooltips/hover
// stay dead until you move the mouse or switch tabs)
if(typeof requestAnimationFrame!=="undefined")
Expand All @@ -485,37 +490,65 @@ function kickHover(){
full title as plain text just to the right of the bar (no background) */
function placeOverflowTitles(){
document.querySelectorAll("#gantt .gttlout").forEach(x=>x.remove());
const overflow=(track,bar,ttl,rightPct,off)=>{
const full=ttl.scrollWidth, vis=ttl.clientWidth;
if(full>4 && vis<full*0.5){
ttl.style.visibility="hidden";
const lab=document.createElement("span");
lab.className="gttlout"; lab.textContent=ttl.textContent.trim();
lab.style.left="calc("+rightPct+"% + "+off+"px)";
track.appendChild(lab);
} else ttl.style.visibility="";
};
document.querySelectorAll("#gantt .gtrack").forEach(track=>{
track.querySelectorAll(":scope > .gbar").forEach(bar=>{
const ttl=bar.querySelector(".ttl"); if(!ttl) return;
const full=ttl.scrollWidth, vis=ttl.clientWidth;
if(full>4 && vis<full*0.5){
ttl.style.visibility="hidden";
const off=track.querySelector(".gexpw")?34:8; // clear the subtask chevron (5px gap + 26px button + slack)
const right=parseFloat(bar.style.left||0)+parseFloat(bar.style.width||0);
const lab=document.createElement("span");
lab.className="gttlout"; lab.textContent=ttl.textContent.trim();
lab.style.left="calc("+right+"% + "+off+"px)";
track.appendChild(lab);
} else ttl.style.visibility="";
const ttl=bar.querySelector(".gpin .ttl")||bar.querySelector(".ttl"); if(!ttl) return;
const off=track.querySelector(".gexpw")?34:8;
const right=parseFloat(bar.style.left||0)+parseFloat(bar.style.width||0);
overflow(track,bar,ttl,right,off);
});
const sum=track.querySelector(":scope > .gsumtrack");
const lbl=sum?.querySelector(".gsumlbl")||track.querySelector(":scope > .gsumlbl");
if(lbl){
const ttl=lbl.querySelector(".gpin .ttl")||lbl.querySelector(".ttl"); if(!ttl) return;
const right=sum
?((sum.offsetLeft+sum.offsetWidth)/Math.max(track.clientWidth,1)*100)
:(parseFloat(lbl.style.left||0)+lbl.offsetWidth/Math.max(track.clientWidth,1)*100);
overflow(track,lbl,ttl,right,8);
}
});
}
function pinGanttScroll(){ pinFlags(); pinBars(); placeOverflowTitles(); }
/* keep avatar + title visible when a bar extends beyond the horizontal viewport */
function pinBars(){
const sc=document.querySelector(".gscroll"); if(!sc) return;
const v0=sc.scrollLeft, v1=v0+sc.clientWidth, pad=6;
sc.querySelectorAll(".gbar").forEach(bar=>{
const pin=bar.querySelector(".gpin"); if(!pin) return;
pin.style.left="0px";
const bl=bar.offsetLeft, br=bl+bar.offsetWidth;
if(br<=v0+pad||bl>=v1-pad) return;
const pinW=pin.offsetWidth; if(pinW<2) return;
const want=Math.max(bl,v0+pad);
const maxL=Math.max(bl,br-pinW-pad);
const abs=Math.min(want,maxL);
if(abs>bl+1) pin.style.left=(abs-bl)+"px";
});
}
function pinFlags(){
const sc=document.querySelector(".gscroll"); if(!sc) return;
const v0=sc.scrollLeft;
sc.querySelectorAll(".gsumrow").forEach(row=>{
const lbl=row.querySelector(".gsumlbl"), line=row.querySelector(".gsumline");
if(!lbl||!line) return;
// keep the project name visible: slide the label to the viewport's left edge as its
// range line scrolls past, but never beyond the line's right end
const ll=line.offsetLeft, lr=ll+line.offsetWidth;
let left=Math.max(ll,v0+4);
left=Math.min(left,Math.max(lr-46,ll));
lbl.style.left=left+"px";
const v0=sc.scrollLeft, pad=6;
sc.querySelectorAll(".gsumtrack").forEach(track=>{
const lbl=track.querySelector(".gsumlbl");
if(!lbl) return;
const ll=track.offsetLeft, lr=ll+track.offsetWidth;
const pinW=lbl.offsetWidth||46;
let abs=Math.max(ll,v0+pad);
abs=Math.min(abs,Math.max(lr-pinW-pad,ll));
lbl.style.left=(abs-ll)+"px";
});
}
window.addEventListener("resize",()=>{ sizeScale(); applyTilt(lastTilt); defer(renderGantt); });
window.addEventListener("resize",()=>{ sizeScale(); applyTilt(lastTilt); defer(renderGantt); pinBars(); });

/* --- floating "gripped pill" ghost, shared by bar drags and pop-up task drags --- */
function makeGhost(text,color){ const g=document.createElement("div");
Expand Down Expand Up @@ -601,6 +634,7 @@ function barMove(ev){
const [cs,ce]=barGeom(G.s,G.e,G.n.done);
G.el.style.left=gx(cs)+"%";
G.el.style.width=(gx(ce)-gx(cs))+"%";
pinBars();
if(G.ghost){ G.ghost.style.display=G.moved?"":"none";
G.ghost.textContent=G.n.title+" · due "+fmtD(dayIso(G.e));
placeGhost(G.ghost,ev); }
Expand Down
20 changes: 20 additions & 0 deletions tests/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ describe("project layout", () => {
expect(html).toContain('onclick="addProject()"');
});

it("pins task bar avatar and title during horizontal scroll", () => {
const main = readFileSync("src/app/main.js", "utf8");
expect(main).toContain("function pinBars()");
expect(main).toContain('class="gpin"');
});

it("renders project name speech bubbles with owner avatar", () => {
const main = readFileSync("src/app/main.js", "utf8");
expect(main).toContain('class="gsumlbl"');
expect(main).toMatch(/gsumlbl[\s\S]*class="gpin"/);
});

it("keeps project title and progress bar in separate rows", () => {
const html = readFileSync("index.html", "utf8");
expect(html).toContain(".gsumtrack{position:absolute;top:4px;display:flex;flex-direction:column");
expect(html).toContain(".gsumrow .gtrack{height:auto;overflow:visible}");
const main = readFileSync("src/app/main.js", "utf8");
expect(main).toMatch(/gtrack" style="height:\$\{ph\+34\}px"/);
});

it("exports core lib modules", async () => {
const domain = await import("../src/lib/domain.js");
const tree = await import("../src/lib/tree.js");
Expand Down
Loading