feat: plan Search results into Week Ahead (Closes #1194)
Some checks failed
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 4m48s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-20 20:39:53 +00:00
parent 5c74051aab
commit ea1b5a77f5
13 changed files with 417 additions and 2 deletions

View File

@ -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.

View File

@ -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; }

View File

@ -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' ||

View File

@ -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 &amp; defer</button>
<button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button>
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
<button id="start-search-result" type="button" hidden>Assign &amp; 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>

View 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;}

View File

@ -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',

View File

@ -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,

View File

@ -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",

View 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()

View 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}

View 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

View File

@ -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",

View File

@ -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;