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 h2, .plan-today-header p { margin-top:0; }
|
||||
.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[hidden] { display:none; }
|
||||
.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-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
weekWorkflow.renderDates();
|
||||
renderPlanToday();
|
||||
weekWorkflow.renderDates();
|
||||
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.');
|
||||
qs('#cancel-plan-today').focus();
|
||||
|
|
@ -7920,7 +7920,7 @@
|
|||
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
||||
if (result === 'saved') {
|
||||
button.dataset.confirmOverCapacity = '';
|
||||
taskOverlayHistory.leave();
|
||||
if (!weekWorkflow.active() || !weekWorkflow.advance()) taskOverlayHistory.leave();
|
||||
} else if (result === 'confirm-over-capacity') {
|
||||
button.dataset.confirmOverCapacity = 'true';
|
||||
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>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</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>
|
||||
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
||||
<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);
|
||||
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() {
|
||||
const queued=pending();
|
||||
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';
|
||||
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};
|
||||
}
|
||||
function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,escapeAttribute,
|
||||
todayWork,refresh,warm}={}) {
|
||||
let selectedDate=null;
|
||||
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) {
|
||||
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;
|
||||
|
|
@ -273,6 +287,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
|||
function renderDates() {
|
||||
const root=qs('#week-plan-dates');
|
||||
root.hidden=!selectedDate;
|
||||
renderPass();
|
||||
if(!selectedDate)return;
|
||||
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)+
|
||||
|
|
@ -284,7 +299,7 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
|||
async function open(trigger) {
|
||||
trigger.disabled=true;selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
|
||||
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;}
|
||||
finally{trigger.disabled=false;}
|
||||
}
|
||||
|
|
@ -295,7 +310,21 @@ function createWeekPlanWorkflow({controller,qs,getLogin,openPlanner,escapeHtml,e
|
|||
qs('#mobile-week-summary').textContent=controller.summary();
|
||||
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+'.';})
|
||||
.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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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,
|
||||
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
|
||||
clear(){selectedDate=null;renderDates();}};
|
||||
|
|
|
|||
|
|
@ -67,11 +67,21 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
|||
assert bounds and bounds["height"] >= 44
|
||||
dates.nth(2).click()
|
||||
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()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||
assert saved and len(saved[-1]["days"]) == 1
|
||||
assert saved[-1]["days"][0]["plan_date"]
|
||||
assert saved and len(saved[-1]["days"]) == 2
|
||||
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")
|
||||
browser.close()
|
||||
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.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]")
|
||||
expect(choices).to_have_count(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:"]
|
||||
|
||||
|
||||
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():
|
||||
result = run_controller("""
|
||||
let calls=0;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user