From c15f7768e4fe9705b07e0fa2a6eb052f21dac88e Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 18:01:12 +0000 Subject: [PATCH] feat: preserve progressive My Work queue truth (Closes #1403) --- frontend/dashboard.js | 9 ++- frontend/progressive-my-work.js | 33 +++++++--- tests/test_progressive_my_work.py | 100 +++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 4dedbeb..caf2f85 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,6 +1,7 @@ (async function(){ const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window })); await workspaceLifecycle.optionalReady; + const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.(); window.stackchainProgressiveMyWork?.stop(); const qs = (s, el=document) => el.querySelector(s); const announceWork = message => qs('#my-work-action-status').textContent = message; @@ -322,15 +323,17 @@ let offlineWorkMode = false; const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; - let selectedWorkFilter = 'all'; + const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft']; + const hasProgressiveWorkFilter = WORK_FILTERS.includes(progressiveWorkHandoff?.selectedFilter); + let selectedWorkFilter = hasProgressiveWorkFilter ? progressiveWorkHandoff.selectedFilter : 'all'; let selectedFiledView = 'needs-review'; let selectedWorkMilestone = 'all'; let queueFindQuery = ''; let savedWorkFilter = null; - let launchFilterResolved = false; + let launchFilterResolved = hasProgressiveWorkFilter; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); - if (['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { + if (!hasProgressiveWorkFilter && ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { selectedWorkFilter = savedFilter; savedWorkFilter = savedFilter; launchFilterResolved = true; diff --git a/frontend/progressive-my-work.js b/frontend/progressive-my-work.js index 3a9b413..cbecab9 100644 --- a/frontend/progressive-my-work.js +++ b/frontend/progressive-my-work.js @@ -6,6 +6,10 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { let items = []; let active = 'all'; let stopped = false; + let selectedByUser = false; + const deferredQueues = { + today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts', + }; const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''', @@ -16,15 +20,26 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; } catch (_error) { return ''; } }; - const visibleItems = () => active === 'all' ? items : items.filter(item => - active === 'review' ? item.is_review : - active === 'update' ? item.has_update : - active === 'attention' ? item.needs_attention : item.kind === active - ); + const matches = (item, filter) => + filter === 'review' ? item.is_review : + filter === 'update' ? item.has_update : + filter === 'attention' ? item.needs_attention : + filter === 'filed' ? item.is_filed : + filter === 'authored' ? item.is_authored : + filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter; + const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active)); + const updateCounts = () => { + ['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => { + const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length; + const element = document.querySelector('[data-work-count="' + filter + '"]'); + if (element) element.textContent = String(count); + }); + }; const render = () => { if (stopped || !list) return; const visible = visibleItems(); - list.innerHTML = visible.length ? visible.map(item => { + list.innerHTML = deferredQueues[active] ? + '
' + deferredQueues[active] + ' is still loading…
' : visible.length ? visible.map(item => { const href = safeUrl(item.url); const title = escapeHtml(item.title || item.key || 'Untitled work'); const context = escapeHtml(item.key || ''); @@ -37,12 +52,15 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active))); }; filters.forEach(button => { - const listener = () => { active = button.dataset.workFilter || 'all'; render(); }; + const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); }; button.addEventListener('click', listener); listeners.push([button, listener]); }); return { + handoff() { + return { selectedFilter:selectedByUser ? active : null }; + }, async start() { if (status) status.textContent = 'Loading assigned work…'; try { @@ -50,6 +68,7 @@ function createProgressiveMyWork({ document, fetchSnapshot }) { if (stopped) return false; const context = snapshot?.context || snapshot || {}; items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); + updateCounts(); render(); const assigned = items.filter(item => item.is_assigned).length; if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.'; diff --git a/tests/test_progressive_my_work.py b/tests/test_progressive_my_work.py index 2d168d3..247e5b4 100644 --- a/tests/test_progressive_my_work.py +++ b/tests/test_progressive_my_work.py @@ -5,6 +5,7 @@ from pathlib import Path MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js" MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" +DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace(): @@ -35,4 +36,101 @@ const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{ assert state["status"] == "1 assigned work item ready." assert "Fix mobile queue" in state["rendered"] assert 'href="https://forge.example/issues/7"' in state["rendered"] - assert state["pressed"] == ["false", "true"] \ No newline at end of file + assert state["pressed"] == ["false", "true"] + + +def test_progressive_my_work_keeps_supported_queues_truthful_while_planning_loads(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const names=['all','today','agenda','attention','filed','authored','issue','pull','review','update','later','draft']; +const counts=Object.fromEntries(names.map(name=>[name,{{textContent:'0'}}])); +const buttons=names.map(name=>({{ + dataset:{{workFilter:name}},attrs:{{}}, + addEventListener(_name,cb){{this.cb=cb;}}, + removeEventListener(){{}}, + setAttribute(key,value){{this.attrs[key]=value;}}, +}})); +const list={{innerHTML:''}}; const status={{textContent:''}}; +const document={{ + querySelector(selector){{ + if(selector==='#my-work-list')return list; + if(selector==='#my-work-status')return status; + const match=selector.match(/^\\[data-work-count="(.+)"\\]$/); + return match?counts[match[1]]:null; + }}, + querySelectorAll:()=>buttons, +}}; +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context); +context.buildMyWork=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context); +const flow=context.module.exports({{document,fetchSnapshot:async()=>({{ + user:{{login:'timmy'}}, + issues:[{{number:7,title:'Filed from phone',repository:'stackchain/dashboard',assignees:[],work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}], + pull_requests:[{{number:8,title:'Authored release',repository:'stackchain/dashboard',assignees:[],work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}], +}})}}); +(async()=>{{ + await flow.start(); + buttons[names.indexOf('filed')].cb(); const filed=list.innerHTML; + buttons[names.indexOf('authored')].cb(); const authored=list.innerHTML; + buttons[names.indexOf('today')].cb(); const today=list.innerHTML; + console.log(JSON.stringify({{filed,authored,today,counts:Object.fromEntries(names.map(name=>[name,counts[name].textContent]))}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + state = json.loads(result.stdout) + assert "Filed from phone" in state["filed"] + assert "Authored release" in state["authored"] + assert "Today is still loading" in state["today"] + assert "No work" not in state["today"] + assert state["counts"] == { + "all": "2", + "today": "0", + "agenda": "0", + "attention": "0", + "filed": "1", + "authored": "1", + "issue": "1", + "pull": "1", + "review": "0", + "update": "0", + "later": "0", + "draft": "0", + } + + +def test_progressive_queue_selection_is_handed_to_the_hydrated_workspace(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +const buttons=['all','filed'].map(name=>({{ + dataset:{{workFilter:name}},attrs:{{}},removed:false, + addEventListener(_name,cb){{this.cb=cb;}}, + removeEventListener(_name,cb){{if(cb===this.cb)this.removed=true;}}, + setAttribute(key,value){{this.attrs[key]=value;}}, +}})); +const document={{ + querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null, + querySelectorAll:()=>buttons, +}}; +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context); +context.buildMyWork=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context); +const flow=context.module.exports({{document,fetchSnapshot:async()=>({{user:{{login:'timmy'}},issues:[],pull_requests:[]}})}}); +(async()=>{{ + await flow.start(); buttons[1].cb(); + const before=flow.handoff(); flow.stop(); const after=flow.handoff(); + console.log(JSON.stringify({{before,after,removed:buttons.map(button=>button.removed)}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + state = json.loads(result.stdout) + assert state == { + "before": {"selectedFilter": "filed"}, + "after": {"selectedFilter": "filed"}, + "removed": [True, True], + } + + dashboard = DASHBOARD.read_text() + assert "const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();" in dashboard + assert "progressiveWorkHandoff?.selectedFilter" in dashboard \ No newline at end of file