diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f11881..cc2bdf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Rebase PR #13 onto main (2026-06-19) + +### Fixed +- Rebased `cursor/expand-size-range-0037` onto `main` (replacing the merge commit) so GitHub can update/rebase the branch cleanly. +- Bar menu keeps expanded `SIZE_KEYS` (S-XXL) from the PR and Start/End date fields from `main`. +- `updTask` keeps `normalizeSize` for size and `syncTaskDates` for start/end from `main`. + ## Rebase PR #10 onto main (2026-06-19) ### Fixed diff --git a/index.html b/index.html index 530b7cb..ccead89 100644 --- a/index.html +++ b/index.html @@ -128,6 +128,7 @@ .pill.sz-m {font-size:13.5px;padding:9px 16px} .pill.sz-l {font-size:15.5px;padding:13px 22px} .pill.sz-xl{font-size:17px; padding:17px 28px} + .pill.sz-xxl{font-size:18px;padding:18px 30px} .pill.late {background:var(--red-soft);color:var(--red)} .pill.today{background:var(--accent-soft);color:var(--accent)} .pill.soon {background:var(--green-soft);color:var(--green)} @@ -307,10 +308,11 @@ .gbar.gsz-m .gava .av{width:26px;height:26px;font-size:11px} .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.gsz-xxl .gava .av{width:54px;height:54px;font-size:19px} + .gbar.gsz-s{height:26px}.gbar.gsz-m{height:34px}.gbar.gsz-l{height:44px}.gbar.gsz-xl{height:56px}.gbar.gsz-xxl{height:68px} .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} + .gbar.gsz-s .ttl{font-size:10.5px}.gbar.gsz-l .ttl{font-size:13px}.gbar.gsz-xl .ttl{font-size:14px}.gbar.gsz-xxl .ttl{font-size:15px} /* 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} @@ -333,8 +335,8 @@ .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} + .gbar.gsub .ttl{color:inherit} + .gbar.gsub .gava .av{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} @@ -381,7 +383,7 @@ .bm-rw .bm-lbl{margin:0;width:40px;flex:none} .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} + .bm-rw .szseg{flex:1;justify-content:space-between;flex-wrap:wrap;gap:4px} /* 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: @@ -474,8 +476,8 @@ .duepill input:focus{outline:none} .duepill.sm input{width:96px;font-size:11.5px} /* segmented t-shirt size */ - .szseg{display:inline-flex;background:#eef0f4;border-radius:999px;padding:2px} - .szb{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:800;color:var(--ink-3);line-height:1.5} + .szseg{display:inline-flex;flex-wrap:wrap;background:#eef0f4;border-radius:999px;padding:2px;gap:2px} + .szb{padding:3px 8px;border-radius:999px;font-size:10.5px;font-weight:800;color:var(--ink-3);line-height:1.5} .szb.on{background:var(--accent);color:#fff} .tc-sub{display:flex;align-items:center;gap:7px;margin-top:8px;padding-left:14px;position:relative} .tc-sub::before{content:"";position:absolute;left:3px;top:4px;bottom:4px;width:2px;background:var(--line);border-radius:2px} diff --git a/src/app/main.js b/src/app/main.js index a128d51..a56fe06 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -1,6 +1,6 @@ import { PEOPLE, TODAY, HARDWARE_VOCAB, CLIENTS, - SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, + SIZE_KEYS, SIZE_PTS, SIZE_NAMES, LEAD, ZOOMS, GBAR_H, normalizeSize, sizePts, barHeight, R0G, R1G, SPAN_G, TODAY_PX, C_LATE, C_TODAY, C_RADAR, C_LATER, C_DONE, } from "../data/constants.js"; @@ -35,7 +35,7 @@ const fitsDepth = (node, destId) => fitsDepthIn(node, destId, DATA); /* ================= helpers ================= */ /* size-weighted progress (0..1): done size-points / total size-points across the leaves */ -const progFrac = (n) => { let done = 0, tot = 0; flat([n], (x) => { if (x.children.length) return; const w = SIZE_PTS[x.size || "m"]; tot += w; if (x.done) done += w; }); return tot ? done / tot : 0; }; +const progFrac = (n) => { let done = 0, tot = 0; flat([n], (x) => { if (x.children.length) return; const w = sizePts(x.size); tot += w; if (x.done) done += w; }); return tot ? done / tot : 0; }; function dueChip(due,done){ if(!due||done) return ""; const dd=Math.round((parseLocalIso(due)-TODAY)/864e5); const cls=dd<0?"overdue":dd<=3?"soon":""; @@ -127,7 +127,7 @@ function renderDash(){ const da=taskDoneAt(n); if(!da) return; const ago=Math.round((TODAY-parseLocalIso(da))/864e5); if(ago>=0&&ago<=Math.max(HZ,0)) banked.push({n,proj:(path[0]||n).title,dn:true}); }); - const PT=x=>SIZE_PTS[x.n.size||"m"]; + const PT=x=>sizePts(x.n.size); const pill=(x,cls,side)=>`
@@ -426,8 +426,8 @@ function renderGantt(){ 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=n.size||"m"; - const h=isSub?Math.max(15,Math.round(GBAR_H[sz]*0.62)):GBAR_H[sz]; // subtasks shorter than tasks + const late=!done&&e<0, [tcs,tce]=barGeom(s,e,done), 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) const hasKids=!isSub&&n.children.length>0, donePct=hasKids?Math.round(progWD(n)*100):0; @@ -485,7 +485,7 @@ function renderGantt(){ any=true; const prog=progWD(p), ppc=Math.round(prog*100), spanW=gx(sce)-gx(scs); // project bar thickness scales with the project's total weight (sum of its leaf size points) - let pPts=0; flat([p],x=>{ if(x.children.length) return; pPts+=SIZE_PTS[x.size||"m"]; }); + 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(`
@@ -513,7 +513,7 @@ function renderGantt(){ cand.push({n,e,root,par}); }); cand.sort((a,b)=>a.e-b.e - ||SIZE_PTS[b.n.size||"m"]-SIZE_PTS[a.n.size||"m"] + ||sizePts(b.n.size)-sizePts(a.n.size) ||PRW[a.n.priority||"med"]-PRW[b.n.priority||"med"]); any=cand.length>0; rows.push('
'); @@ -867,7 +867,7 @@ function openBarMenu(id,anchor){ BM.innerHTML=`
Owner
${Object.entries(PEOPLE).map(([k,p])=>``).join("")}
-
Size${["s","m","l","xl"].map(z=>``).join("")}
+
Size${SIZE_KEYS.map(z=>``).join("")}
Start
End
`; BM.classList.add("show"); BARMENU=id; @@ -895,7 +895,7 @@ function updTask(id,f,v,quiet){ snap(); const n=findPath(id).pop(); 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==="size") n.size=v||null; + else if(f==="size") n.size=v?normalizeSize(v):null; renderAll(); if(!quiet&&f!=="title") 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; @@ -966,10 +966,10 @@ function openDetail(id,opts){ 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. - let _szPts=0; flat([n],x=>{ if(x.children.length) return; _szPts+=SIZE_PTS[x.size||"m"]; }); + let _szPts=0; flat([n],x=>{ if(x.children.length) return; _szPts+=sizePts(x.size); }); const sizeFld=leaf ? `
Size
` + ${SIZE_KEYS.map(z=>``).join("")}
` : `
Size${_szPts} pts
`; document.getElementById("dBody").innerHTML=`
Owner${av(n.owner)}${mopts.join("")}
${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+=SIZE_PTS[x.size||"m"]; }); + let _cp=0; flat([ch],x=>{ if(x.children.length) return; _cp+=sizePts(x.size); }); const szCtl=lleaf - ? `` + ? `` : `${_cp} pts`; return `
@@ -1105,6 +1105,7 @@ function updateKeyBadge(){ const b=$id("keyBtn"); if(b) b.textContent=getKey()?" /* swappable extraction: a live endpoint, then OpenAI direct (browser key), then the mock */ const OWNER_IDS=[...Object.keys(PEOPLE),null]; // owner must be a real teammate id, never free text +const SIZE_SCHEMA_ENUM=[...SIZE_KEYS,null]; const OAI_SCHEMA={type:"object",additionalProperties:false, required:["intent","project","task","tasks","remove","owner","parentId","due","size","pending","ready","assistantSay"], properties:{ @@ -1114,13 +1115,13 @@ const OAI_SCHEMA={type:"object",additionalProperties:false, tasks:{type:"array",items:{type:"object",additionalProperties:false, required:["title","owner","due","size","subs"], properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}, + due:{type:["string","null"]},size:{type:["string","null"],enum:SIZE_SCHEMA_ENUM}, subs:{type:"array",items:{type:"object",additionalProperties:false, required:["title","owner","due","size"], properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}}}}}, + due:{type:["string","null"]},size:{type:["string","null"],enum:SIZE_SCHEMA_ENUM}}}}}}}, owner:{type:["string","null"],enum:OWNER_IDS}, parentId:{type:["integer","null"]}, - due:{type:["string","null"]}, size:{type:["string","null"],enum:["s","m","l","xl",null]}, + due:{type:["string","null"]}, size:{type:["string","null"],enum:SIZE_SCHEMA_ENUM}, pending:{type:["string","null"]}, ready:{type:"boolean"}, assistantSay:{type:"string"}}}; function captureContext(){ return {today:isoCap(TODAY), people:Object.entries(PEOPLE).map(([id,p])=>({id,name:p.name,responsibility:p.role,aka:p.al})), @@ -1158,7 +1159,7 @@ ${VOCAB_TEXT} - Use intent create_task / create_subtask ONLY when adding to a project/task that ALREADY EXISTS in context.projects. Then set parentId to that existing id. - "task" (singular) is only for create_task/create_subtask; for create_project leave "task" null and use "tasks". - owner MUST be one of the provided people ids, or null. Names are frequently MIS-HEARD by voice transcription — map any spelling variant or mishearing listed in the responsibility map to the correct id (e.g. "Janice"/"Yannis"/"Ioannis" → Iannis "ia"; "Flo"/"Florine" → Florian "fd"; "Sankeet" → Sanket "sk"). Do NOT assign the work to a different real teammate just because the heard name is fuzzy; if you genuinely cannot resolve it, use null rather than guessing the wrong person. -- due: resolve relative dates ("Monday","tomorrow","in 3 days") to absolute YYYY-MM-DD using context.today; else null. size: s/m/l/xl if stated else null. +- due: resolve relative dates ("Monday","tomorrow","in 3 days") to absolute YYYY-MM-DD using context.today; else null. size: s/m/l/xl/xxl if stated else null. - pending = the single most useful field still needed ("projectName","taskTitle","parent","owner"), or null if nothing required is missing. Required: create_project needs project(name); create_task/subtask need task(title) and parentId. - ready = true when required fields are present (a project is ready once it has a name, even with zero tasks). - assistantSay = one short, natural sentence confirming what you understood and asking the next thing (or noting it's ready). Talk like a helpful colleague, not a form. If you just appended a task, acknowledge it and invite another or Create. @@ -1338,7 +1339,7 @@ function ownerPill(v,onch){ const col=v?person(v).color:"#c2c8d2"; return ` `; } function duePill(v,onch,sm){ return ``; } -function szSeg(v,onch){ return `${["s","m","l","xl"].map(z=> +function szSeg(v,onch){ return `${SIZE_KEYS.map(z=> ``).join("")}`; } function taskCardHTML(tk,i){ tk.subs=tk.subs||[]; return `
@@ -1417,19 +1418,19 @@ const OAI_PROPOSAL_SCHEMA={type:"object",additionalProperties:false, title:{type:"string"}, owner:{type:["string","null"],enum:OWNER_IDS}, due:{type:["string","null"]}, - size:{type:["string","null"],enum:["s","m","l","xl",null]}, + size:{type:["string","null"],enum:SIZE_SCHEMA_ENUM}, client:{type:["string","null"],enum:CLIENT_NAMES}, subs:{type:"array",items:{type:"object",additionalProperties:false, required:["title","owner","due","size"], properties:{title:{type:"string"},owner:{type:["string","null"],enum:OWNER_IDS}, - due:{type:["string","null"]},size:{type:["string","null"],enum:["s","m","l","xl",null]}}}} + due:{type:["string","null"]},size:{type:["string","null"],enum:SIZE_SCHEMA_ENUM}}}} }}} }}}}}; async function openaiTranscript(text,key){ const sys=`You read a raw client/team conversation transcript and extract the NEW engineering projects and tasks it implies, for a 3-level planner (project > task > subtask). LANGUAGE: the transcript may be English or French — ALL OUTPUT MUST BE IN ENGLISH. - Group work into projects. A customer pilot becomes a project; set its "client" to the matching known client. Pure internal work has client=null. -- Each task: a concise imperative title (no leading article), owner, due (YYYY-MM-DD resolved from context.today, else null), size (s/m/l/xl or null), client (if the task is for a known client else null), and a subs array (usually empty). +- Each task: a concise imperative title (no leading article), owner, due (YYYY-MM-DD resolved from context.today, else null), size (s/m/l/xl/xxl or null), client (if the task is for a known client else null), and a subs array (usually empty). - ASSIGNEE: infer each owner from this RESPONSIBILITY MAP using the task's content; only null if genuinely unclear: ${RESP_MAP_TEXT} ${VOCAB_TEXT} diff --git a/src/data/constants.js b/src/data/constants.js index cc8ac4f..e4f8081 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -36,9 +36,20 @@ export const DOMAIN_RULES = [ { o: "fd", kw: ["client", "outreach", "fundrais", "recruit", "hiring", "pilot", "sales", "demo", "investor", "contract"] }, ]; -export const SIZE_PTS = { s: 1, m: 2, l: 4, xl: 8 }; -export const SIZE_NAMES = { s: "S", m: "M", l: "L", xl: "XL" }; -export const LEAD = { s: 1, m: 3, l: 7, xl: 14 }; +export const SIZE_KEYS = ["s", "m", "l", "xl", "xxl"]; +export const SIZE_PTS = { s: 1, m: 2, l: 4, xl: 6, xxl: 8 }; +export const SIZE_NAMES = { s: "S", m: "M", l: "L", xl: "XL", xxl: "XXL" }; +export const LEAD = { s: 1, m: 3, l: 7, xl: 14, xxl: 28 }; + +/** Map legacy/invalid sizes onto the active scale (same rules for tasks and subtasks). */ +export function normalizeSize(size) { + if (size && SIZE_KEYS.includes(size)) return size; + if (size === "xs") return "s"; + return "m"; +} + +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 }, @@ -47,7 +58,7 @@ export const ZOOMS = [ { l: "6 weeks", h: 42, v: 42 }, ]; -export const GBAR_H = { s: 26, m: 34, l: 44, xl: 56 }; +export const GBAR_H = { s: 26, m: 34, l: 44, xl: 56, xxl: 68 }; export const R0G = 0; export const R1G = 90; export const SPAN_G = R1G - R0G; diff --git a/src/lib/capture.js b/src/lib/capture.js index 8051a0b..fa5cb05 100644 --- a/src/lib/capture.js +++ b/src/lib/capture.js @@ -61,18 +61,27 @@ export function findDue(t, today = TODAY) { } export function findSize(t) { - const m = t.match(/\b(extra large|x-?large|xl|small|medium|large)\b/); + const m = t.match(/\b(extra extra large|xx-?large|xxl|extra large|x-?large|xl|extra small|x-?small|xs|small|medium|large)\b/i); if (!m) return null; + const key = m[1].toLowerCase().replace(/\s+/g, " "); return ( { + "extra extra large": "xxl", + "xx-large": "xxl", + xxlarge: "xxl", + xxl: "xxl", + "extra large": "xl", + "x-large": "xl", + xlarge: "xl", + xl: "xl", + "extra small": "s", + "x-small": "s", + xsmall: "s", + xs: "s", small: "s", medium: "m", large: "l", - xl: "xl", - "x-large": "xl", - xlarge: "xl", - "extra large": "xl", - }[m[1]] || null + }[key] || null ); } diff --git a/src/lib/tree.js b/src/lib/tree.js index 6d3e155..2884123 100644 --- a/src/lib/tree.js +++ b/src/lib/tree.js @@ -1,3 +1,5 @@ +import { normalizeSize } from "../data/constants.js"; + export function createTaskFactory() { let uid = 0; const T = (title, o, opts = {}) => ({ @@ -42,6 +44,7 @@ export function findPath(id, nodes, path = []) { export function normalizeTaskTree(nodes) { nodes.forEach((n) => { if (!Array.isArray(n.children)) n.children = []; + if (n.size != null) n.size = normalizeSize(n.size); normalizeTaskTree(n.children); }); } diff --git a/tests/capture.test.js b/tests/capture.test.js index 4c03dd4..9933364 100644 --- a/tests/capture.test.js +++ b/tests/capture.test.js @@ -38,6 +38,8 @@ Install RS03 motors for JCDecaux.`; it("parses t-shirt sizes", () => { expect(findSize("this is a large task")).toBe("l"); expect(findSize("extra large integration")).toBe("xl"); + expect(findSize("extra small fix")).toBe("s"); + expect(findSize("xxl platform rollout")).toBe("xxl"); }); it("extracts projects and tasks from a conversation offline", () => { diff --git a/tests/constants.test.js b/tests/constants.test.js new file mode 100644 index 0000000..539deb4 --- /dev/null +++ b/tests/constants.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + barHeight, + GBAR_H, + LEAD, + normalizeSize, + SIZE_KEYS, + SIZE_NAMES, + SIZE_PTS, + sizePts, +} from "../src/data/constants.js"; + +describe("size constants", () => { + it("defines S through XXL with the requested point weights", () => { + expect(SIZE_KEYS).toEqual(["s", "m", "l", "xl", "xxl"]); + expect(SIZE_PTS).toEqual({ s: 1, m: 2, l: 4, xl: 6, xxl: 8 }); + for (const k of SIZE_KEYS) { + expect(SIZE_NAMES[k]).toBeTruthy(); + expect(GBAR_H[k]).toBeGreaterThan(0); + expect(LEAD[k]).toBeGreaterThan(0); + } + expect(GBAR_H.s).toBeLessThan(GBAR_H.xxl); + expect(LEAD.s).toBeLessThanOrEqual(LEAD.xxl); + }); + + it("normalizes legacy and missing sizes the same for tasks and subtasks", () => { + expect(normalizeSize("l")).toBe("l"); + expect(normalizeSize("xs")).toBe("s"); + expect(normalizeSize(null)).toBe("m"); + expect(normalizeSize(undefined)).toBe("m"); + }); + + it("uses the same point and bar-height scale for every item", () => { + for (const k of SIZE_KEYS) { + expect(sizePts(k)).toBe(SIZE_PTS[k]); + expect(barHeight(k)).toBe(GBAR_H[k]); + } + expect(sizePts(null)).toBe(2); + expect(barHeight("xs")).toBe(GBAR_H.s); + expect(barHeight("m")).toBe(barHeight(null)); + }); +}); diff --git a/tests/tree.test.js b/tests/tree.test.js index 5c4e754..26ee707 100644 --- a/tests/tree.test.js +++ b/tests/tree.test.js @@ -57,4 +57,10 @@ describe("tree", () => { expect(leaf.children).toEqual([]); expect(findPath(1, [root]).map((n) => n.title)).toEqual(["Root", "Leaf"]); }); + + it("migrates legacy xs size to s", () => { + const root = { id: 1, title: "Root", owner: "fd", size: "xs", children: [] }; + normalizeTaskTree([root]); + expect(root.size).toBe("s"); + }); });