Plan Search results directly into Week Ahead #1195
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
|||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Eligible open issue previews also expose **Plan ahead**: choose one of the next seven local dates, review
|
||||
that day's current load and capacity, and enter a required estimate without leaving Search. Existing Week
|
||||
Ahead placement is moved only after an explicit day change, never duplicated; full days are blocked and
|
||||
over-capacity admission requires a second confirmation. The action claims unassigned work only after these
|
||||
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
|
||||
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
|
||||
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
|
||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
frontend/search-week-plan.js
Normal file
115
frontend/search-week-plan.js
Normal file
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ FEATURE_SOURCES = {
|
|||
"planning": (
|
||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||
"static/tomorrow-plan.js", "static/week-plan.js", "static/mobile-plan-today-nav.js",
|
||||
"static/tomorrow-plan.js", "static/week-plan.js", "static/search-week-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||
),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
|
|
|
|||
43
tests/e2e/test_mobile_search_week_plan.py
Normal file
43
tests/e2e/test_mobile_search_week_plan.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
FRONTEND = Path(__file__).parents[2] / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
|
||||
def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width, height):
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
with sync_api.sync_playwright() as playwright:
|
||||
try:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium unavailable: {error}")
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#search-week-plan-days").evaluate(
|
||||
"""node => node.innerHTML = Array.from({length:7}, (_, index) =>
|
||||
`<button type="button" data-search-week-date="2026-08-${21+index}" aria-pressed="${index===0}">
|
||||
<strong>Day ${index+1}</strong><small>${index*30} of 120 min · ${index} planned</small>
|
||||
</button>`).join('')"""
|
||||
)
|
||||
page.locator("#search-week-plan-sheet").evaluate("node => node.hidden = false")
|
||||
page.locator("#search-week-plan-estimate").fill("45")
|
||||
|
||||
expect = sync_api.expect
|
||||
expect(page.locator("#search-week-plan-sheet")).to_be_visible()
|
||||
expect(page.locator("#confirm-search-week-plan")).to_be_visible()
|
||||
for button in page.locator("#search-week-plan-days button").all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.locator("#confirm-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.locator("#cancel-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator(".search-week-plan-panel").evaluate(
|
||||
"node => node.scrollWidth <= node.clientWidth"
|
||||
)
|
||||
browser.close()
|
||||
|
|
@ -61,6 +61,7 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
|
|||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_search_week_plan.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_sign_out_release.py "
|
||||
|
|
|
|||
101
tests/test_search_week_plan.py
Normal file
101
tests/test_search_week_plan.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
MODULE = FRONTEND / "search-week-plan.js"
|
||||
|
||||
|
||||
def run_planner(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSearchWeekPlan = require({json.dumps(str(MODULE))});
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_search_week_plan_previews_seven_days_and_requires_explicit_overload_confirmation():
|
||||
result = run_planner("""
|
||||
let claims=0,stages=0;
|
||||
const days=Array.from({length:7},(_,index)=>({date:`2026-08-${21+index}`,label:`Day ${index+1}`}));
|
||||
const week={load:async()=>({}),dates:()=>days,day:date=>date==='2026-08-21'
|
||||
? {plan_date:date,ids:['existing'],capacity_minutes:60,estimates:{existing:50}}
|
||||
: {plan_date:date,ids:[],capacity_minutes:90,estimates:{}},
|
||||
placement:()=>null,place:()=>{stages+=1;return true;},flush:async()=>({revision:2})};
|
||||
const planner=createSearchWeekPlan({week,claim:async item=>{claims+=1;return {...item,assigned_to_me:true,claimable:false};},
|
||||
accept:item=>item,identity:item=>`issue:${item.repository}:${item.number}:`});
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const preview=await planner.preview(detail);
|
||||
const invalid=await planner.plan(detail,{date:'2026-08-21',estimate:0});
|
||||
const overload=await planner.plan(detail,{date:'2026-08-21',estimate:30});
|
||||
console.log(JSON.stringify({preview,invalid,overload,claims,stages}));
|
||||
""")
|
||||
|
||||
assert len(result["preview"]["days"]) == 7
|
||||
assert result["preview"]["days"][0]["planned_minutes"] == 50
|
||||
assert result["preview"]["days"][0]["capacity_minutes"] == 60
|
||||
assert result["invalid"] == {"status": "estimate-required"}
|
||||
assert result["overload"]["status"] == "overload-confirmation-required"
|
||||
assert result["overload"]["planned_minutes"] == 80
|
||||
assert result["claims"] == 0
|
||||
assert result["stages"] == 0
|
||||
|
||||
|
||||
def test_search_week_plan_assigns_once_then_durably_places_canonical_issue():
|
||||
result = run_planner("""
|
||||
let release,claims=0,places=0,flushes=0;
|
||||
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat, Aug 22'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
|
||||
place:(id,date,estimate)=>{places+=1;return {id,date,estimate};},flush:async()=>{flushes+=1;return {revision:3};}};
|
||||
const planner=createSearchWeekPlan({week,
|
||||
claim:async item=>{claims+=1;await new Promise(resolve=>{release=resolve});return {...item,assigned_to_me:true,claimable:false,title:'Canonical'};},
|
||||
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const first=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
const second=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
await Promise.resolve();release();
|
||||
const outcomes=await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({outcomes,claims,places,flushes}));
|
||||
""")
|
||||
|
||||
assert result["claims"] == 1
|
||||
assert result["places"] == 1
|
||||
assert result["flushes"] == 1
|
||||
assert result["outcomes"][0] == result["outcomes"][1]
|
||||
assert result["outcomes"][0]["status"] == "planned"
|
||||
assert result["outcomes"][0]["item"]["accepted"] is True
|
||||
|
||||
|
||||
def test_search_week_plan_reports_existing_placement_and_partial_assignment_truthfully():
|
||||
result = run_planner("""
|
||||
const base={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),flush:async()=>({})};
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const existing=createSearchWeekPlan({week:{...base,placement:()=>({date:'2026-08-23',estimate:30}),place:()=>true},
|
||||
claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
||||
const blocked=await existing.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
const partial=createSearchWeekPlan({week:{...base,placement:()=>null,place:()=>false},
|
||||
claim:async item=>({...item,assigned_to_me:true,claimable:false}),accept:item=>item,identity:()=> 'work'});
|
||||
const failed=await partial.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
console.log(JSON.stringify({blocked,failed}));
|
||||
""")
|
||||
|
||||
assert result["blocked"] == {"status": "already-planned", "date": "2026-08-23", "estimate": 30}
|
||||
assert result["failed"]["status"] == "assigned-not-planned"
|
||||
|
||||
|
||||
def test_search_week_plan_moves_without_duplicates_and_keeps_pending_sync_on_network_failure():
|
||||
result = run_planner("""
|
||||
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),
|
||||
placement:()=>({date:'2026-08-21',estimate:30}),place:()=>true,
|
||||
flush:async()=>{throw new Error('offline');}};
|
||||
const planner=createSearchWeekPlan({week,claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
||||
const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true},
|
||||
{date:'2026-08-22',estimate:45,move:true});
|
||||
console.log(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
|
||||
51
tests/test_search_week_plan_ui.py
Normal file
51
tests/test_search_week_plan_ui.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def test_search_preview_exposes_accessible_plan_ahead_sheet_and_packaged_controller():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
assert 'id="plan-search-result"' in html
|
||||
assert 'id="search-week-plan-sheet" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-week-plan-title"' in html
|
||||
assert 'id="search-week-plan-days"' in html
|
||||
assert 'id="search-week-plan-estimate"' in html
|
||||
assert 'id="confirm-search-week-plan"' in html
|
||||
assert 'id="cancel-search-week-plan"' in html
|
||||
assert "static/search-week-plan.js" in build.page_sources
|
||||
assert b"createSearchWeekPlan" in build.feature_bundles["planning"].runtime_bytes
|
||||
|
||||
|
||||
def test_search_plan_ahead_ui_connects_eligibility_preview_and_truthful_outcomes():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
source = (FRONTEND / "search-week-plan.js").read_text()
|
||||
|
||||
assert "const searchWeekPlan = createSearchWeekPlan" in dashboard
|
||||
assert "createSearchWeekPlanUI" in dashboard
|
||||
assert "planButton.hidden = !searchWeekPlan.eligible(detail)" in dashboard
|
||||
assert "await planner.preview(detail)" in source
|
||||
assert "overload-confirmation-required" in source
|
||||
assert "assigned-not-planned" in source
|
||||
assert "planned-pending" in source
|
||||
assert "Already planned for" in source
|
||||
assert "planner.pending()" in source
|
||||
assert "searchWeekPlan:true" in source
|
||||
assert "if(!qs('#search-week-plan-sheet').hidden)" in source
|
||||
assert "window.history.back()" in source
|
||||
|
||||
|
||||
def test_search_plan_ahead_sheet_is_phone_safe_and_touch_sized():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert ".search-week-plan-sheet" in css
|
||||
assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in css
|
||||
assert ".search-week-plan-days button" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "@media (max-width:359px)" in css
|
||||
|
|
@ -1329,6 +1329,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/tomorrow-plan.js",
|
||||
"/dashboard/static/week-plan.js",
|
||||
"/dashboard/static/search-week-plan.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
|
|
|
|||
|
|
@ -95,6 +95,32 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
|
|||
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
||||
|
||||
|
||||
def test_week_controller_places_new_work_or_explicitly_moves_existing_work_without_duplicates():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-21',ids:['existing'],capacity_minutes:60,estimates:{existing:30}},
|
||||
{plan_date:'2026-08-22',ids:['other'],capacity_minutes:120,estimates:{other:45}}
|
||||
]});
|
||||
const before=week.placement('existing');
|
||||
const refused=week.place('existing','2026-08-22',50);
|
||||
const moved=week.place('existing','2026-08-22',50,{move:true});
|
||||
const added=week.place('new','2026-08-23',25);
|
||||
console.log(JSON.stringify({before,refused,moved,added,state:week.state(),pending:week.pending()}));
|
||||
""")
|
||||
|
||||
assert result["before"] == {"date": "2026-08-21", "estimate": 30}
|
||||
assert result["refused"] is False
|
||||
assert result["moved"] is True
|
||||
assert result["added"] is True
|
||||
assert [day["ids"] for day in result["state"]["days"]] == [[], ["other", "existing"], ["new"]]
|
||||
assert result["state"]["days"][1]["estimates"]["existing"] == 50
|
||||
assert result["pending"]["days"] == result["state"]["days"]
|
||||
|
||||
|
||||
def test_week_workflow_saves_and_advances_without_closing_the_planner():
|
||||
result = run_controller("""
|
||||
const createWorkflow=createWeekPlan.Workflow;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user