feat: plan Search results into Week Ahead (Closes #1194)
This commit is contained in:
1 parent
5c74051aab
commit
ea1b5a77f5
13 files changed
+417
-2
No files matched your search
@@ -50,6 +50,20 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.search-week-plan-sheet { position:fixed; inset:0; z-index:118; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.search-week-plan-sheet[hidden] { display:none; }
|
||||
.search-week-plan-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.search-week-plan-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.search-week-plan-panel h2 { margin:.2rem 0; }
|
||||
.search-week-plan-panel fieldset { min-width:0; margin:12px 0; padding:0; border:0; }
|
||||
.search-week-plan-days { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-week-plan-days button { display:grid; min-width:0; min-height:44px; text-align:left; overflow-wrap:anywhere; }
|
||||
.search-week-plan-days button[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
|
||||
.search-week-plan-days small { color:#9fb3c8; }
|
||||
.search-week-plan-estimate { display:grid; gap:6px; }
|
||||
.search-week-plan-estimate input, #confirm-search-week-plan, #cancel-search-week-plan { box-sizing:border-box; min-height:44px; }
|
||||
#confirm-search-week-plan { width:100%; }
|
||||
@media (max-width:359px) { .search-week-plan-days { grid-template-columns:1fr; } }
|
||||
.notification-undo { position:fixed; }
|
||||
.notification-undo button { min-height:44px; }
|
||||
.notification-undo, .today-completion-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
|
||||
@@ -5611,6 +5611,7 @@
|
||||
const claimButton = qs('#claim-search-result');
|
||||
const deferButton = qs('#defer-search-result');
|
||||
const queueButton = qs('#queue-search-result');
|
||||
const planButton = qs('#plan-search-result');
|
||||
const startButton = qs('#start-search-result');
|
||||
const shareButton = qs('#share-search-result');
|
||||
|
||||
@@ -5627,6 +5628,8 @@
|
||||
deferButton.hidden = true;
|
||||
deferButton.disabled = false;
|
||||
queueButton.hidden = true;
|
||||
planButton.hidden = true;
|
||||
planButton.disabled = false;
|
||||
startButton.hidden = true;
|
||||
startButton.disabled = false;
|
||||
shareButton.disabled = true;
|
||||
@@ -5675,6 +5678,9 @@
|
||||
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||||
(detail.claimable || detail.assigned_to_me));
|
||||
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
|
||||
planButton.hidden = !searchWeekPlan.eligible(detail);
|
||||
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
|
||||
planButton.disabled = searchWeekPlan.pending();
|
||||
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
|
||||
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
|
||||
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
|
||||
@@ -5732,6 +5738,15 @@
|
||||
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
const searchWeekPlan = createSearchWeekPlan({
|
||||
week:weekPlan,claim:detail=>searchPreview.claim(detail),accept:acceptClaimedIssue,
|
||||
identity:item=>todayWork.identity(item),
|
||||
});
|
||||
createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr,
|
||||
getDetail:()=>searchPreviewDetail,
|
||||
announce:message=>{qs('#search-preview-status').textContent=message;},
|
||||
onPlanned:item=>{searchPreviewDetail={...item,assigned_to_me:true,claimable:false};},
|
||||
});
|
||||
searchBatchPlanning = mountSearchBatchPlanning(
|
||||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||||
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
||||
@@ -6089,6 +6104,7 @@
|
||||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
|
||||
laterPicker.open(detail, event.currentTarget, 'search');
|
||||
});
|
||||
|
||||
qs('#queue-search-result').addEventListener('click', async () => {
|
||||
const detail = searchPreviewDetail;
|
||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="defer-search-result" type="button" hidden>Assign & defer</button>
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
@@ -862,6 +863,23 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="search-week-plan-sheet" id="search-week-plan-sheet" role="dialog" aria-modal="true"
|
||||
aria-labelledby="search-week-plan-title" hidden>
|
||||
<div class="search-week-plan-panel">
|
||||
<header>
|
||||
<div><div class="small">Week Ahead</div><h2 id="search-week-plan-title">Plan this issue</h2></div>
|
||||
<button id="cancel-search-week-plan" type="button">Cancel</button>
|
||||
</header>
|
||||
<p id="search-week-plan-copy" class="muted"></p>
|
||||
<fieldset><legend>Choose a day</legend><div class="search-week-plan-days" id="search-week-plan-days"></div></fieldset>
|
||||
<label class="search-week-plan-estimate" for="search-week-plan-estimate">Estimate in minutes
|
||||
<input id="search-week-plan-estimate" type="number" inputmode="numeric" min="1" max="1440" step="5" required />
|
||||
</label>
|
||||
<p id="search-week-plan-status" class="small" aria-live="polite"></p>
|
||||
<button id="confirm-search-week-plan" type="button">Plan</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="whiteboard-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-actions">
|
||||
@@ -1988,6 +2006,7 @@
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/tomorrow-plan.js"></script>
|
||||
<script src="static/week-plan.js"></script>
|
||||
<script src="static/search-week-plan.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}={}) {
|
||||
let running=null;
|
||||
const eligible=detail=>Boolean(detail&&detail.kind==='issue'&&detail.state==='open'&&
|
||||
(detail.claimable||detail.assigned_to_me));
|
||||
const loadDay=date=>{
|
||||
const value=week.day(date),estimates=value.estimates||{};
|
||||
return {...value,planned_minutes:(value.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0)};
|
||||
};
|
||||
async function preview(detail) {
|
||||
if(!eligible(detail))return {eligible:false,days:[]};
|
||||
await week.load();
|
||||
const id=identity(detail),existing=week.placement(id);
|
||||
return {eligible:true,existing,days:week.dates().map(item=>({...item,...loadDay(item.date)}))};
|
||||
}
|
||||
function plan(detail,options={}) {
|
||||
if(running)return running;
|
||||
const perform=async()=>{
|
||||
if(!eligible(detail))return {status:'ineligible'};
|
||||
const estimate=Number(options.estimate);
|
||||
if(!Number.isFinite(estimate)||estimate<=0)return {status:'estimate-required'};
|
||||
await week.load();
|
||||
if(!week.dates().some(item=>item.date===options.date))return {status:'date-required'};
|
||||
const originalId=identity(detail),existing=week.placement(originalId);
|
||||
if(existing&&existing.date!==options.date&&!options.move)
|
||||
return {status:'already-planned',date:existing.date,estimate:existing.estimate};
|
||||
const destination=loadDay(options.date);
|
||||
const alreadyThere=(destination.ids||[]).includes(originalId);
|
||||
if(!alreadyThere&&(destination.ids||[]).length>=maxItems)return {status:'day-full',limit:maxItems};
|
||||
const previous=alreadyThere?(Number(destination.estimates?.[originalId])||0):0;
|
||||
const plannedMinutes=destination.planned_minutes-previous+estimate;
|
||||
if(Number(destination.capacity_minutes)>0&&plannedMinutes>Number(destination.capacity_minutes)&&!options.confirmOverload)
|
||||
return {status:'overload-confirmation-required',planned_minutes:plannedMinutes,
|
||||
capacity_minutes:Number(destination.capacity_minutes)};
|
||||
let item=detail,assigned=false;
|
||||
if(detail.claimable){item=await claim(detail);assigned=true;}
|
||||
item=accept(item)||item;
|
||||
const id=identity(item);
|
||||
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
|
||||
return {status:assigned?'assigned-not-planned':'not-planned',item};
|
||||
try {
|
||||
await week.flush();
|
||||
return {status:'planned',date:options.date,estimate,item};
|
||||
} catch(_error) {
|
||||
return {status:'planned-pending',date:options.date,estimate};
|
||||
}
|
||||
};
|
||||
running=perform().finally(()=>{running=null;});
|
||||
return running;
|
||||
}
|
||||
return {eligible,preview,plan,pending:()=>Boolean(running)};
|
||||
}
|
||||
function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=>{},announce=()=>{},escapeHtml,escapeAttribute}={}) {
|
||||
const qs=selector=>document.querySelector(selector);
|
||||
let trigger=null,detail=null,preview=null,overload=false;
|
||||
function close(navigate=false) {
|
||||
if(navigate&&window.history.state?.searchWeekPlan){window.history.back();return;}
|
||||
qs('#search-week-plan-sheet').hidden=true;
|
||||
detail=null;preview=null;overload=false;trigger?.focus?.();trigger=null;
|
||||
}
|
||||
function selectDate(date) {
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(button=>
|
||||
button.setAttribute('aria-pressed',String(button.dataset.searchWeekDate===date)));
|
||||
qs('#search-week-plan-days').dataset.selected=date;
|
||||
overload=false;qs('#confirm-search-week-plan').textContent='Plan';
|
||||
}
|
||||
async function open(button) {
|
||||
const selected=getDetail();
|
||||
if(!selected||planner.pending())return false;
|
||||
trigger=button;detail=selected;overload=false;
|
||||
const sheet=qs('#search-week-plan-sheet'),status=qs('#search-week-plan-status');
|
||||
sheet.hidden=false;status.textContent='Loading Week Ahead…';qs('#confirm-search-week-plan').disabled=true;
|
||||
window.history.pushState({...window.history.state,searchWeekPlan:true},'',window.location.href);
|
||||
qs('#search-week-plan-copy').textContent=detail.title||'Untitled issue';
|
||||
try {
|
||||
preview=await planner.preview(detail);
|
||||
const existing=preview.existing;
|
||||
qs('#search-week-plan-days').innerHTML=preview.days.map((day,index)=>
|
||||
'<button type="button" data-search-week-date="'+escapeAttribute(day.date)+'" aria-pressed="'+String(index===0)+'"><strong>'+escapeHtml(day.label)+
|
||||
'</strong><small>'+escapeHtml(day.planned_minutes+(day.capacity_minutes?' of '+day.capacity_minutes:'')+' min · '+day.ids.length+' planned')+'</small></button>').join('');
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(dayButton=>
|
||||
dayButton.addEventListener('click',()=>selectDate(dayButton.dataset.searchWeekDate)));
|
||||
selectDate(existing?.date||preview.days[0].date);
|
||||
qs('#search-week-plan-estimate').value=existing?.estimate||'';
|
||||
status.textContent=existing?'Already planned for '+existing.date+'. Choose another day to move it.':'Choose a day and add an estimate.';
|
||||
qs('#confirm-search-week-plan').disabled=false;qs('#search-week-plan-estimate').focus();return true;
|
||||
} catch(error) {status.textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
|
||||
}
|
||||
async function confirm() {
|
||||
if(!detail||planner.pending())return;
|
||||
const date=qs('#search-week-plan-days').dataset.selected,estimate=Number(qs('#search-week-plan-estimate').value);
|
||||
const existing=preview?.existing,move=Boolean(existing&&existing.date!==date);
|
||||
const button=qs('#confirm-search-week-plan'),status=qs('#search-week-plan-status');button.disabled=true;
|
||||
const outcome=await planner.plan(detail,{date,estimate,move,confirmOverload:overload});
|
||||
if(outcome.status==='overload-confirmation-required'){
|
||||
overload=true;button.textContent='Confirm over capacity';
|
||||
status.textContent=outcome.planned_minutes+' min exceeds '+outcome.capacity_minutes+' min capacity. Confirm to plan anyway.';
|
||||
} else if(outcome.status==='estimate-required')status.textContent='Enter an estimate in minutes.';
|
||||
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==='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'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
|
||||
else status.textContent='Could not plan this issue. Retry.';
|
||||
button.disabled=false;
|
||||
}
|
||||
qs('#plan-search-result').addEventListener('click',event=>open(event.currentTarget));
|
||||
qs('#cancel-search-week-plan').addEventListener('click',()=>close(true));
|
||||
qs('#confirm-search-week-plan').addEventListener('click',confirm);
|
||||
window.addEventListener('popstate',()=>{if(!qs('#search-week-plan-sheet').hidden)close();});
|
||||
document.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'&&!qs('#search-week-plan-sheet').hidden){event.preventDefault();close(true);}
|
||||
});
|
||||
return {open,close,confirm};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports){module.exports=createSearchWeekPlan;module.exports.UI=createSearchWeekPlanUI;}
|
||||
@@ -125,6 +125,7 @@ const SHELL = [
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/tomorrow-plan.js',
|
||||
BASE + 'static/week-plan.js',
|
||||
BASE + 'static/search-week-plan.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
|
||||
+22
-1
@@ -107,6 +107,27 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
if(Number.isFinite(estimate)) destination.estimates[id]=estimate;
|
||||
return Boolean(stageDays(moved));
|
||||
}
|
||||
function placement(id) {
|
||||
const found=week.days.find(item=>(item.ids||[]).includes(id));
|
||||
if(!found)return null;
|
||||
const estimate=Number(found.estimates?.[id]);
|
||||
return {date:found.plan_date,estimate:Number.isFinite(estimate)?estimate:null};
|
||||
}
|
||||
function place(id,toDate,estimate,{move:allowMove=false}={}) {
|
||||
if(!id||!dates().some(item=>item.date===toDate)||!Number.isFinite(Number(estimate))||Number(estimate)<=0)return false;
|
||||
const existing=placement(id);
|
||||
if(existing&&existing.date!==toDate&&!allowMove)return false;
|
||||
const changed=week.days.map(item=>{
|
||||
const estimates={...(item.estimates||{})};
|
||||
if(item.plan_date!==toDate){delete estimates[id];return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};}
|
||||
return cloneDay(item);
|
||||
});
|
||||
let destination=changed.find(item=>item.plan_date===toDate);
|
||||
if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};changed.push(destination);}
|
||||
if(!destination.ids.includes(id))destination.ids.push(id);
|
||||
destination.estimates[id]=Number(estimate);
|
||||
return Boolean(stageDays(changed));
|
||||
}
|
||||
function deliveryBody(value) {
|
||||
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
|
||||
}
|
||||
@@ -288,7 +309,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
||||
return label+(pending()?' · sync pending':'');
|
||||
}
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,review,move,pending,flush,conflict,chooseDay,saveMerged,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,summary};
|
||||
}
|
||||
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
|
||||
|
||||
Reference in new issue
Block a user