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