feat: plan Following changes and continue review (Closes #1326)
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m46s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 23:33:36 +00:00
parent 0f36d3077b
commit 07836f7176
6 changed files with 107 additions and 7 deletions

View File

@ -1056,8 +1056,10 @@ textarea { resize: vertical; min-height: 120px; }
#keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; } #keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; }
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; } .search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:repeat(3,minmax(0,1fr)); }
@media (max-width:420px) { @media (max-width:420px) {
.search-preview-primary-actions { grid-template-columns:1fr; } .search-preview-primary-actions { grid-template-columns:1fr; }
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:1fr; }
} }
@media (max-width:600px) { @media (max-width:600px) {
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); } .search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); }

View File

@ -5716,6 +5716,7 @@
const detail = state.detail; const detail = state.detail;
if (!detail) return; if (!detail) return;
searchPreviewDetail = detail; searchPreviewDetail = detail;
shareButton.disabled = state.status === 'sharing'; shareButton.disabled = state.status === 'sharing';
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number; qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item'; qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
@ -5747,6 +5748,7 @@
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start'); (detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening'; startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
renderSearchPreviewWatch(detail, state, watchButton); renderSearchPreviewWatch(detail, state, watchButton);
followingQueue.preview(state);
const shareStatus = { const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.', sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.', 'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
@ -5817,7 +5819,7 @@
createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr, createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr,
getDetail:()=>searchPreviewDetail, getDetail:()=>searchPreviewDetail,
announce:message=>{qs('#search-preview-status').textContent=message;}, announce:message=>{qs('#search-preview-status').textContent=message;},
onPlanned:item=>{searchPreviewDetail={...item,assigned_to_me:true,claimable:false};}, onPlanned:item=>(searchPreviewDetail=item,item.following&&next()),
}); });
searchBatchPlanning = mountSearchBatchPlanning( searchBatchPlanning = mountSearchBatchPlanning(
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson, document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,

View File

@ -3,11 +3,33 @@
if (typeof module === 'object' && module.exports) { if (typeof module === 'object' && module.exports) {
module.exports = exports.createFollowing; module.exports = exports.createFollowing;
module.exports.attachFollowing = exports.attachFollowing; module.exports.attachFollowing = exports.attachFollowing;
module.exports.planningDisposition = exports.planningDisposition;
} else { } else {
root.createFollowing = exports.createFollowing; root.createFollowing = exports.createFollowing;
root.attachFollowing = exports.attachFollowing; root.attachFollowing = exports.attachFollowing;
root.followingPlanningDisposition = exports.planningDisposition;
} }
})(typeof self !== 'undefined' ? self : this, function () { })(typeof self !== 'undefined' ? self : this, function () {
function planningDisposition(detail, controls) {
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
Boolean(detail.claimable || detail.assigned_to_me);
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
if (!controls && globalThis.document) {
const query = selector => globalThis.document.querySelector(selector);
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
start:query('#start-search-result'),watch:query('#watch-search-result')};
}
controls?.root?.classList.toggle('following-disposition-mode', active);
if (active && controls) {
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
controls.today.textContent = result.today;
controls.later.textContent = result.later;
controls.week.textContent = result.week;
}
return result;
}
function createFollowing(options) { function createFollowing(options) {
let generation = 0; let generation = 0;
let snapshot = {revision:0, items:[]}; let snapshot = {revision:0, items:[]};
@ -248,7 +270,8 @@
keepForLater:feature.keepForLater, keepForLater:feature.keepForLater,
retire:feature.retire, retire:feature.retire,
preview(state) { preview(state) {
keepButton.hidden = state?.item?.following !== true; const disposition = planningDisposition(state?.detail || state?.item);
keepButton.hidden = state?.item?.following !== true || disposition.active;
keepButton.disabled = false; keepButton.disabled = false;
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = ''; if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
}, },
@ -265,5 +288,5 @@
}; };
} }
return {createFollowing, attachFollowing}; return {createFollowing, attachFollowing, planningDisposition};
}); });

View File

@ -42,7 +42,7 @@ function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}
await week.flush(); await week.flush();
return {status:'planned',date:options.date,estimate,item}; return {status:'planned',date:options.date,estimate,item};
} catch(_error) { } catch(_error) {
return {status:'planned-pending',date:options.date,estimate}; return {status:'planned-pending',date:options.date,estimate,item};
} }
}; };
running=perform().finally(()=>{running=null;}); running=perform().finally(()=>{running=null;});
@ -99,8 +99,8 @@ function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.'; else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.'; else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.'; else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);} else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');await onPlanned(outcome.item||detail,true,outcome);close(true);}
else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);} else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);close(true);}
else status.textContent='Could not plan this issue. Retry.'; else status.textContent='Could not plan this issue. Retry.';
button.disabled=false; button.disabled=false;
} }

View File

@ -487,6 +487,40 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
assert "followingQueue.returnToFollowing()" in dashboard assert "followingQueue.returnToFollowing()" in dashboard
assert "'Back to Following'" in dashboard assert "'Back to Following'" in dashboard
def test_following_open_issue_exposes_only_action_and_continue_planning_dispositions():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const disposition = createFollowing.planningDisposition;
process.stdout.write(JSON.stringify({{
active:disposition({{following:true,kind:'issue',state:'open',claimable:true}}),
generic:disposition({{following:false,kind:'issue',state:'open',claimable:true}}),
pull:disposition({{following:true,kind:'pull',state:'open',assigned_to_me:true}}),
}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"active": {
"active": True,
"today": "Add to Today & next",
"later": "Later & next",
"week": "Week Ahead & next",
},
"generic": {"active": False},
"pull": {"active": False},
}
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
assert "followingQueue.preview(state);" in dashboard
assert "following-disposition-mode" in MODULE.read_text()
assert "controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true" in MODULE.read_text()
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v137" in service_worker assert "stackchain-dashboard-shell-v137" in service_worker

View File

@ -98,7 +98,12 @@ const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true}
console.log(JSON.stringify(outcome)); console.log(JSON.stringify(outcome));
""") """)
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45} assert result == {
"status": "planned-pending",
"date": "2026-08-22",
"estimate": 45,
"item": {"kind": "issue", "state": "open", "assigned_to_me": True},
}
def test_search_week_plan_stages_canonical_details_before_flushing(): def test_search_week_plan_stages_canonical_details_before_flushing():
@ -122,3 +127,37 @@ console.log(JSON.stringify({outcome,calls}));
["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42], ["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42],
["flush"], ["flush"],
] ]
def test_search_week_plan_waits_for_success_callback_before_closing():
result = run_planner("""
const events=[];
const nodes={};
function node(extra={}) { return Object.assign({
hidden:true,disabled:false,textContent:'',value:'',dataset:{},
addEventListener(){},focus(){events.push('focus');},
setAttribute(){},querySelectorAll(){return[];}
},extra); }
nodes['#search-week-plan-sheet']=node();
nodes['#search-week-plan-status']=node();
nodes['#confirm-search-week-plan']=node();
nodes['#search-week-plan-copy']=node();
nodes['#search-week-plan-days']=node({dataset:{selected:'2026-08-22'}});
nodes['#search-week-plan-estimate']=node({value:'45'});
nodes['#plan-search-result']=node(); nodes['#cancel-search-week-plan']=node();
const document={querySelector:selector=>nodes[selector],addEventListener(){}};
const window={history:{state:{},pushState(value){this.state=value;},back(){events.push('back');nodes['#search-week-plan-sheet'].hidden=true;}},location:{href:'/'},addEventListener(){}};
const planner={pending:()=>false,preview:async()=>({days:[{date:'2026-08-22',label:'Sat',planned_minutes:0,capacity_minutes:90,ids:[]}]}),
plan:async()=>({status:'planned',date:'2026-08-22',estimate:45,item:{number:42}})};
const ui=createSearchWeekPlan.UI({planner,document,window,getDetail:()=>({title:'Issue',number:42}),
escapeHtml:value=>value,escapeAttribute:value=>value,announce:message=>events.push(message),
onPlanned:async()=>{events.push('callback-start');await Promise.resolve();events.push('callback-end');}});
await ui.open(node());
await ui.confirm();
events.push(nodes['#search-week-plan-sheet'].hidden?'closed':'open');
console.log(JSON.stringify({events}));
""")
assert result["events"][-4:] == [
"callback-start", "callback-end", "back", "closed"
]