feat: plan Week Ahead in one mobile pass (Closes #1186)
This commit is contained in:
parent
6718160381
commit
55e1c3d6dd
|
|
@ -241,6 +241,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||||
|
.week-plan-progress { margin:8px 0 4px; color:#bfdbfe; font-weight:700; }
|
||||||
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
|
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
|
||||||
.week-plan-dates[hidden] { display:none; }
|
.week-plan-dates[hidden] { display:none; }
|
||||||
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
|
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
|
||||||
|
|
|
||||||
|
|
@ -3120,8 +3120,8 @@
|
||||||
qs('#plan-today-error').textContent = '';
|
qs('#plan-today-error').textContent = '';
|
||||||
qs('#plan-today-sheet').hidden = false;
|
qs('#plan-today-sheet').hidden = false;
|
||||||
document.body.classList.add('task-overlay-open');
|
document.body.classList.add('task-overlay-open');
|
||||||
weekWorkflow.renderDates();
|
|
||||||
renderPlanToday();
|
renderPlanToday();
|
||||||
|
weekWorkflow.renderDates();
|
||||||
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
|
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
|
||||||
(protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.');
|
(protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.');
|
||||||
qs('#cancel-plan-today').focus();
|
qs('#cancel-plan-today').focus();
|
||||||
|
|
@ -7920,7 +7920,7 @@
|
||||||
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
||||||
if (result === 'saved') {
|
if (result === 'saved') {
|
||||||
button.dataset.confirmOverCapacity = '';
|
button.dataset.confirmOverCapacity = '';
|
||||||
taskOverlayHistory.leave();
|
if (!weekWorkflow.active() || !weekWorkflow.advance()) taskOverlayHistory.leave();
|
||||||
} else if (result === 'confirm-over-capacity') {
|
} else if (result === 'confirm-over-capacity') {
|
||||||
button.dataset.confirmOverCapacity = 'true';
|
button.dataset.confirmOverCapacity = 'true';
|
||||||
qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
|
qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,7 @@
|
||||||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="week-plan-progress" id="week-plan-progress" aria-live="polite" hidden></p>
|
||||||
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
|
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
|
||||||
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
||||||
<div class="small">Cross-device change</div>
|
<div class="small">Cross-device change</div>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||||
const found=week.days.find(item=>item.plan_date===planDate);
|
const found=week.days.find(item=>item.plan_date===planDate);
|
||||||
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
|
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
|
||||||
}
|
}
|
||||||
|
function pass(planDate) {
|
||||||
|
const available=dates(),index=available.findIndex(item=>item.date===planDate);
|
||||||
|
if(index<0) return null;
|
||||||
|
return {position:index+1,total:available.length,planned:week.days.filter(item=>item.ids.length).length,
|
||||||
|
next_date:available[index+1]?.date||null,last:index===available.length-1};
|
||||||
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const queued=pending();
|
const queued=pending();
|
||||||
if(queued){week=queued;return state();}
|
if(queued){week=queued;return state();}
|
||||||
|
|
@ -231,13 +237,21 @@ 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';
|
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 label+(pending()?' · sync pending':'');
|
||||||
}
|
}
|
||||||
return {adopt,state,dates,day,load,saveDay,stageDay,pending,flush,conflict,chooseDay,saveMerged,
|
return {adopt,state,dates,day,pass,load,saveDay,stageDay,pending,flush,conflict,chooseDay,saveMerged,
|
||||||
keepLocal,useRemote,promote,summary};
|
keepLocal,useRemote,promote,summary};
|
||||||
}
|
}
|
||||||
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
|
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
|
||||||
todayWork,refresh,warm}={}) {
|
todayWork,refresh,warm}={}) {
|
||||||
let selectedDate=null;
|
let selectedDate=null;
|
||||||
let blockedReviewOpen=false;
|
let blockedReviewOpen=false;
|
||||||
|
function renderPass() {
|
||||||
|
const progress=qs('#week-plan-progress'),save=qs('#save-today-plan');
|
||||||
|
if(!selectedDate){progress.hidden=true;return;}
|
||||||
|
const current=controller.pass(selectedDate);
|
||||||
|
progress.hidden=false;
|
||||||
|
progress.textContent=`Day ${current.position} of ${current.total} · ${current.planned} planned`;
|
||||||
|
save.textContent=current.last?'Save week':'Save & next';
|
||||||
|
}
|
||||||
function conflictDetail(day) {
|
function conflictDetail(day) {
|
||||||
const estimates=day.estimates||{},ids=day.ids||[];
|
const estimates=day.estimates||{},ids=day.ids||[];
|
||||||
const minutes=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0),capacity=Number(day.capacity_minutes)||0;
|
const minutes=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0),capacity=Number(day.capacity_minutes)||0;
|
||||||
|
|
@ -273,6 +287,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||||
function renderDates() {
|
function renderDates() {
|
||||||
const root=qs('#week-plan-dates');
|
const root=qs('#week-plan-dates');
|
||||||
root.hidden=!selectedDate;
|
root.hidden=!selectedDate;
|
||||||
|
renderPass();
|
||||||
if(!selectedDate)return;
|
if(!selectedDate)return;
|
||||||
root.innerHTML=controller.dates().map(item=>'<button type="button" data-week-plan-date="'+
|
root.innerHTML=controller.dates().map(item=>'<button type="button" data-week-plan-date="'+
|
||||||
escapeAttribute(item.date)+'"'+(item.date===selectedDate?' aria-current="date"':'')+'><strong>'+escapeHtml(item.label)+
|
escapeAttribute(item.date)+'"'+(item.date===selectedDate?' aria-current="date"':'')+'><strong>'+escapeHtml(item.label)+
|
||||||
|
|
@ -284,7 +299,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||||
async function open(trigger) {
|
async function open(trigger) {
|
||||||
trigger.disabled=true;selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
|
trigger.disabled=true;selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
|
||||||
qs('#mobile-week-summary').textContent='Loading Week Ahead…';
|
qs('#mobile-week-summary').textContent='Loading Week Ahead…';
|
||||||
try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();openPlanner(null,false);return true;}
|
try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();renderDates();openPlanner(null,false);return true;}
|
||||||
catch(error){qs('#mobile-week-summary').textContent='Unavailable · tap to retry';qs('#my-work-action-status').textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
|
catch(error){qs('#mobile-week-summary').textContent='Unavailable · tap to retry';qs('#my-work-action-status').textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
|
||||||
finally{trigger.disabled=false;}
|
finally{trigger.disabled=false;}
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +310,21 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||||
qs('#mobile-week-summary').textContent=controller.summary();
|
qs('#mobile-week-summary').textContent=controller.summary();
|
||||||
qs('#my-work-action-status').textContent='Week Ahead saved on this phone · sync pending.';
|
qs('#my-work-action-status').textContent='Week Ahead saved on this phone · sync pending.';
|
||||||
controller.flush().then(()=>{qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent=(normalized.ids.length?'Week Ahead saved for ':'Week Ahead cleared for ')+date+'.';})
|
controller.flush().then(()=>{qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent=(normalized.ids.length?'Week Ahead saved for ':'Week Ahead cleared for ')+date+'.';})
|
||||||
.catch(error=>{qs('#mobile-week-summary').textContent=controller.conflict()?'Conflict · review required':controller.summary();qs('#my-work-action-status').textContent=controller.conflict()?'Another device changed Week Ahead. Both versions are preserved; open Week Ahead to choose one.':(error.message||'Week Ahead sync is unavailable.')+' Saved on this phone · sync pending.';});
|
.catch(error=>{
|
||||||
|
const conflict=controller.conflict();
|
||||||
|
qs('#mobile-week-summary').textContent=conflict?'Conflict · review required':controller.summary();
|
||||||
|
qs('#my-work-action-status').textContent=conflict?'Another device changed Week Ahead. Both versions are preserved; choose each changed day.':
|
||||||
|
(error.message||'Week Ahead sync is unavailable.')+' Saved on this phone · sync pending.';
|
||||||
|
if(conflict) openPlanner(null,false);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function advance() {
|
||||||
|
const current=selectedDate&&controller.pass(selectedDate);
|
||||||
|
if(!current?.next_date) return false;
|
||||||
|
selectedDate=current.next_date;renderDates();openPlanner(null,false);
|
||||||
|
const selected=qs('#week-plan-dates').querySelector(`[data-week-plan-date="${selectedDate}"]`);
|
||||||
|
selected?.scrollIntoView({block:'nearest',inline:'center'});selected?.focus?.();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async function promote(plan){
|
async function promote(plan){
|
||||||
|
|
@ -310,7 +339,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
||||||
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
|
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {open,save,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
|
return {open,save,advance,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate),selectedDate:()=>selectedDate,
|
||||||
day:()=>selectedDate?controller.day(selectedDate):null,
|
day:()=>selectedDate?controller.day(selectedDate):null,
|
||||||
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
|
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
|
||||||
clear(){selectedDate=null;renderDates();}};
|
clear(){selectedDate=null;renderDates();}};
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,21 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||||
assert bounds and bounds["height"] >= 44
|
assert bounds and bounds["height"] >= 44
|
||||||
dates.nth(2).click()
|
dates.nth(2).click()
|
||||||
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
|
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
|
||||||
|
expect(page.locator("#week-plan-progress")).to_have_text("Day 3 of 7 · 0 planned")
|
||||||
|
expect(page.locator("#save-today-plan")).to_have_text("Save & next")
|
||||||
|
page.locator("#save-today-plan").click()
|
||||||
|
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||||
|
expect(dates.nth(3)).to_have_attribute("aria-current", "date")
|
||||||
|
expect(page.locator("#week-plan-progress")).to_have_text("Day 4 of 7 · 0 planned")
|
||||||
|
dates.nth(6).click()
|
||||||
|
expect(page.locator("#save-today-plan")).to_have_text("Save week")
|
||||||
page.locator("#save-today-plan").click()
|
page.locator("#save-today-plan").click()
|
||||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||||
assert saved and len(saved[-1]["days"]) == 1
|
assert saved and len(saved[-1]["days"]) == 2
|
||||||
assert saved[-1]["days"][0]["plan_date"]
|
assert [day["plan_date"] for day in saved[-1]["days"]] == sorted(
|
||||||
|
day["plan_date"] for day in saved[-1]["days"]
|
||||||
|
)
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
browser.close()
|
browser.close()
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -134,8 +144,6 @@ def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
|
||||||
page.locator("#save-today-plan").click()
|
page.locator("#save-today-plan").click()
|
||||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
|
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="queues"]').click()
|
|
||||||
page.locator('[data-mobile-queue="week"]').click()
|
|
||||||
choices = page.locator("[data-week-conflict-choice]")
|
choices = page.locator("[data-week-conflict-choice]")
|
||||||
expect(choices).to_have_count(2)
|
expect(choices).to_have_count(2)
|
||||||
for index in range(2):
|
for index in range(2):
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,63 @@ console.log(JSON.stringify({dates,saved,requests}));
|
||||||
assert result["requests"][1]["body"]["days"][2]["ids"] == ["issue:r:3:"]
|
assert result["requests"][1]["body"]["days"][2]["ids"] == ["issue:r:3:"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_advances_through_one_continuous_seven_day_pass():
|
||||||
|
result = run_controller("""
|
||||||
|
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
week.adopt({revision:4,timezone:'UTC',days:[
|
||||||
|
{plan_date:'2026-08-21',ids:['planned'],capacity_minutes:60,estimates:{planned:30}},
|
||||||
|
{plan_date:'2026-08-23',ids:['also-planned'],capacity_minutes:90,estimates:{'also-planned':45}}
|
||||||
|
]});
|
||||||
|
const first=week.pass('2026-08-21');
|
||||||
|
const middle=week.pass('2026-08-24');
|
||||||
|
const last=week.pass('2026-08-27');
|
||||||
|
console.log(JSON.stringify({first,middle,last}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"first": {"position": 1, "total": 7, "planned": 2, "next_date": "2026-08-22", "last": False},
|
||||||
|
"middle": {"position": 4, "total": 7, "planned": 2, "next_date": "2026-08-25", "last": False},
|
||||||
|
"last": {"position": 7, "total": 7, "planned": 2, "next_date": None, "last": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_workflow_saves_and_advances_without_closing_the_planner():
|
||||||
|
result = run_controller("""
|
||||||
|
const createWorkflow=createWeekPlan.Workflow;
|
||||||
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||||
|
const elements=new Map();
|
||||||
|
const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',addEventListener:()=>{},focus:()=>{},
|
||||||
|
querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}});
|
||||||
|
const root=makeElement();
|
||||||
|
Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons=[...value.matchAll(/data-week-plan-date=\"([^\"]+)/g)].map(match=>({
|
||||||
|
dataset:{weekPlanDate:match[1]},addEventListener:()=>{},scrollIntoView(){this.scrolled=true;}
|
||||||
|
}));},get(){return this.value||'';}});
|
||||||
|
root.querySelectorAll=()=>root.buttons||[];
|
||||||
|
root.querySelector=selector=>(root.buttons||[]).find(button=>selector.includes(button.dataset.weekPlanDate))||null;
|
||||||
|
elements.set('#week-plan-dates',root);
|
||||||
|
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan']) elements.set(selector,makeElement());
|
||||||
|
let opened=0,staged=[];
|
||||||
|
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>({plan_date:date,ids:[]}),
|
||||||
|
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:staged.length,next_date:dates[index+1]||null,last:index===6};},
|
||||||
|
load:async()=>({}),summary:()=> 'Nothing planned',stageDay:(date,plan)=>{staged.push({date,plan});return true;},flush:async()=>({})};
|
||||||
|
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
|
||||||
|
openPlanner:()=>{opened+=1;},escapeHtml:value=>value,escapeAttribute:value=>value,
|
||||||
|
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
|
||||||
|
await workflow.open({disabled:false});
|
||||||
|
workflow.save({ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}});
|
||||||
|
const continued=workflow.advance();
|
||||||
|
console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,staged,
|
||||||
|
progress:elements.get('#week-plan-progress').textContent,label:elements.get('#save-today-plan').textContent}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["continued"] is True
|
||||||
|
assert result["selected"] == "2026-08-22"
|
||||||
|
assert result["opened"] == 3
|
||||||
|
assert result["staged"][0]["date"] == "2026-08-21"
|
||||||
|
assert result["progress"] == "Day 2 of 7 · 1 planned"
|
||||||
|
assert result["label"] == "Save & next"
|
||||||
|
|
||||||
|
|
||||||
def test_week_controller_preserves_both_versions_when_another_device_changes_the_week():
|
def test_week_controller_preserves_both_versions_when_another_device_changes_the_week():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
let calls=0;
|
let calls=0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user