feat: carry wrap-up work into Tomorrow (Closes #1174)
This commit is contained in:
parent
58c2d3f5d8
commit
c99d03b9bc
|
|
@ -2044,11 +2044,13 @@
|
||||||
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||||||
});
|
});
|
||||||
todaySummaryView.resume();
|
todaySummaryView.resume();
|
||||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
const todayWrapUpView = setupTodayWrapUp({ todayWork, tomorrowPlan, todaySync, qs, escapeHtml,
|
||||||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||||||
todayRecapView['completeReplan']();
|
todayRecapView['completeReplan']();
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
warmTodayOffline();
|
warmTodayOffline();
|
||||||
|
renderTomorrowQueueSummary();
|
||||||
|
syncPendingTomorrow();
|
||||||
todaySummaryView.open(workedItems, tomorrowItems);
|
todaySummaryView.open(workedItems, tomorrowItems);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -567,7 +567,7 @@
|
||||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
<p class="small muted">Carry selected work into the ordered Tomorrow plan. Unselected work stays in Today.</p>
|
||||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||||
<div class="today-wrap-up-actions">
|
<div class="today-wrap-up-actions">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
|
||||||
let items = [];
|
let items = [];
|
||||||
const selected = new Set();
|
const selected = new Set();
|
||||||
|
|
||||||
|
|
@ -28,16 +28,39 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finish() {
|
async function finish() {
|
||||||
const wake = laterWork.presetUntil('tomorrow');
|
const loaded = await tomorrowPlan.load();
|
||||||
let scheduled = 0;
|
const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : [];
|
||||||
for (const item of items) {
|
const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item));
|
||||||
|
if (!carried.length) {
|
||||||
|
return {
|
||||||
|
scheduled:0,
|
||||||
|
left:items.filter(item => todayWork.contains(item)).length,
|
||||||
|
plan_count:currentIds.length,
|
||||||
|
plan_date:loaded?.plan_date,
|
||||||
|
sync_pending:Boolean(loaded?.sync_pending),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const combinedIds = [...currentIds];
|
||||||
|
for (const item of carried) {
|
||||||
|
const identity = todayWork.identity(item);
|
||||||
|
if (!combinedIds.includes(identity)) combinedIds.push(identity);
|
||||||
|
}
|
||||||
|
if (combinedIds.length > limit) {
|
||||||
|
throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`);
|
||||||
|
}
|
||||||
|
const estimates = {...(loaded?.estimates || {})};
|
||||||
|
const capacityMinutes = loaded?.capacity_minutes ?? null;
|
||||||
|
const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0);
|
||||||
|
if (capacityMinutes !== null && plannedMinutes > capacityMinutes) {
|
||||||
|
throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.');
|
||||||
|
}
|
||||||
|
const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates});
|
||||||
|
if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.');
|
||||||
|
let scheduled = 0;
|
||||||
|
for (const item of carried) {
|
||||||
const identity = todayWork.identity(item);
|
const identity = todayWork.identity(item);
|
||||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
|
||||||
const deferred = laterWork.defer(item, wake, {handoff:'today'});
|
|
||||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
|
||||||
if (!todaySync.enqueue('remove', identity)) {
|
if (!todaySync.enqueue('remove', identity)) {
|
||||||
laterWork.restore?.(item);
|
throw new Error('Today sync could not be queued. The item remains in Today.');
|
||||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
|
||||||
}
|
}
|
||||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||||
|
|
@ -45,7 +68,13 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||||
scheduled += 1;
|
scheduled += 1;
|
||||||
}
|
}
|
||||||
await todaySync.flush();
|
await todaySync.flush();
|
||||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
return {
|
||||||
|
scheduled,
|
||||||
|
left:items.length - scheduled,
|
||||||
|
plan_count:combinedIds.length,
|
||||||
|
plan_date:staged.plan_date,
|
||||||
|
sync_pending:Boolean(staged.sync_pending),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { open, choose, snapshot, finish };
|
return { open, choose, snapshot, finish };
|
||||||
|
|
@ -69,7 +98,7 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||||
'> Tomorrow 09:00</label></div>';
|
'> Carry to Tomorrow</label></div>';
|
||||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||||
|
|
@ -97,7 +126,8 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||||
}));
|
}));
|
||||||
const result = await controller.finish();
|
const result = await controller.finish();
|
||||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left +
|
||||||
|
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
|
||||||
close();
|
close();
|
||||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,19 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||||
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
||||||
];
|
];
|
||||||
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
||||||
const today=items.map(identity); const later={}; const operations=[];
|
const today=items.map(identity); const operations=[];
|
||||||
|
let tomorrow={revision:4,ids:['issue:acme/mobile:42:'],capacity_minutes:120,
|
||||||
|
estimates:{'issue:acme/mobile:42:':45},plan_date:'2026-08-18'};
|
||||||
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
||||||
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
||||||
}};
|
}};
|
||||||
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
|
const tomorrowPlan={load:async()=>tomorrow,stage:value=>{
|
||||||
later[identity(item)]=wake.toISOString(); return 'deferred';
|
tomorrow={...tomorrow,...value,sync_pending:true}; return tomorrow;
|
||||||
}};
|
}};
|
||||||
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
||||||
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
|
const controller=createTodayWrapUp({todayWork,tomorrowPlan,todaySync});
|
||||||
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||||
window.__wrapTest={view,today,later,operations};
|
window.__wrapTest={view,today,getTomorrow:()=>tomorrow,operations};
|
||||||
view.open(items,{});
|
view.open(items,{});
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
@ -84,11 +86,18 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||||
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
||||||
expect(sheet).to_be_hidden()
|
expect(sheet).to_be_hidden()
|
||||||
result = page.evaluate(
|
result = page.evaluate(
|
||||||
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
|
"({today:window.__wrapTest.today,tomorrow:window.__wrapTest.getTomorrow(),operations:window.__wrapTest.operations})"
|
||||||
)
|
)
|
||||||
assert result == {
|
assert result == {
|
||||||
"today": ["issue:acme/mobile:42:"],
|
"today": ["issue:acme/mobile:42:"],
|
||||||
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
|
"tomorrow": {
|
||||||
|
"revision": 4,
|
||||||
|
"ids": ["issue:acme/mobile:42:", "issue:acme/mobile:41:"],
|
||||||
|
"capacity_minutes": 120,
|
||||||
|
"estimates": {"issue:acme/mobile:42:": 45},
|
||||||
|
"plan_date": "2026-08-18",
|
||||||
|
"sync_pending": True,
|
||||||
|
},
|
||||||
"operations": [["remove", "issue:acme/mobile:41:"]],
|
"operations": [["remove", "issue:acme/mobile:41:"]],
|
||||||
}
|
}
|
||||||
assert browser_errors == []
|
assert browser_errors == []
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def run_node(script: str):
|
||||||
return json.loads(result.stdout)
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_up_schedules_selected_work_for_tomorrow_and_leaves_other_work_in_order():
|
def test_wrap_up_appends_selected_work_to_the_ordered_tomorrow_plan_before_removing_it_from_today():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||||
const items = [
|
const items = [
|
||||||
|
|
@ -23,36 +23,117 @@ const items = [
|
||||||
];
|
];
|
||||||
const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':');
|
const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':');
|
||||||
const today = [...ids];
|
const today = [...ids];
|
||||||
const later = {{}};
|
|
||||||
const todayOps = [];
|
const todayOps = [];
|
||||||
const laterOps = [];
|
const events = [];
|
||||||
|
let tomorrow = {{revision:7, ids:[ids[0]], capacity_minutes:120, estimates:{{[ids[0]]:45}}, plan_date:'2026-08-21'}};
|
||||||
const todayWork = {{
|
const todayWork = {{
|
||||||
identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':',
|
identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':',
|
||||||
read:() => [...today],
|
read:() => [...today],
|
||||||
contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'),
|
contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'),
|
||||||
remove:item => {{
|
remove:item => {{
|
||||||
const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':';
|
const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':';
|
||||||
const index=today.indexOf(id); if(index < 0) return false; today.splice(index,1); return true;
|
const index=today.indexOf(id); if(index < 0) return false; events.push('remove:'+id); today.splice(index,1); return true;
|
||||||
}},
|
}},
|
||||||
}};
|
}};
|
||||||
const laterWork = {{
|
const tomorrowPlan = {{
|
||||||
identity:todayWork.identity,
|
load:async()=>tomorrow,
|
||||||
presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'),
|
state:()=>({{...tomorrow,ids:[...tomorrow.ids],estimates:{{...tomorrow.estimates}}}}),
|
||||||
defer:(item, until, options) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push([todayWork.identity(item), options]); return 'deferred'; }},
|
stage:value=>{{events.push('stage');tomorrow={{...tomorrow,...value,plan_date:'2026-08-21',sync_pending:true}};return tomorrow;}},
|
||||||
}};
|
}};
|
||||||
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
||||||
const wrap = createWrapUp({{todayWork,laterWork,todaySync}});
|
const wrap = createWrapUp({{todayWork,tomorrowPlan,todaySync}});
|
||||||
wrap.open(items);
|
wrap.open(items);
|
||||||
wrap.choose(ids[1], true);
|
wrap.choose(ids[1], true);
|
||||||
const result = await wrap.finish();
|
const result = await wrap.finish();
|
||||||
process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}}));
|
process.stdout.write(JSON.stringify({{result,today,tomorrow,todayOps,events}}));
|
||||||
"""
|
"""
|
||||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
assert output["result"] == {"scheduled": 1, "left": 2, "wake_at": "2026-08-17T13:00:00.000Z"}
|
assert output["result"] == {
|
||||||
|
"scheduled": 1,
|
||||||
|
"left": 2,
|
||||||
|
"plan_count": 2,
|
||||||
|
"plan_date": "2026-08-21",
|
||||||
|
"sync_pending": True,
|
||||||
|
}
|
||||||
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
||||||
assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"}
|
assert output["tomorrow"]["ids"] == [
|
||||||
|
"issue:stackchain/dashboard:1:",
|
||||||
|
"issue:stackchain/dashboard:2:",
|
||||||
|
]
|
||||||
|
assert output["tomorrow"]["capacity_minutes"] == 120
|
||||||
|
assert output["tomorrow"]["estimates"] == {"issue:stackchain/dashboard:1:": 45}
|
||||||
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
||||||
assert output["laterOps"] == [["issue:stackchain/dashboard:2:", {"handoff": "today"}]]
|
assert output["events"] == ["stage", "remove:issue:stackchain/dashboard:2:"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_up_keeps_today_unchanged_when_the_combined_tomorrow_plan_exceeds_five_items():
|
||||||
|
script = f"""
|
||||||
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||||
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Sixth'}};
|
||||||
|
const id='issue:acme/mobile:41:'; let present=true; let staged=0; let enqueued=0;
|
||||||
|
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||||
|
const tomorrowPlan={{
|
||||||
|
load:async()=>({{revision:3,ids:['a','b','c','d','e'],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}),
|
||||||
|
stage:()=>{{staged+=1;return {{}};}},
|
||||||
|
}};
|
||||||
|
const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}};
|
||||||
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
||||||
|
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
||||||
|
process.stdout.write(JSON.stringify({{error,present,staged,enqueued}}));
|
||||||
|
"""
|
||||||
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
|
assert output == {
|
||||||
|
"error": "Tomorrow can hold 5 items. Keep 1 in Today or edit Tomorrow first.",
|
||||||
|
"present": True,
|
||||||
|
"staged": 0,
|
||||||
|
"enqueued": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_up_keeps_today_unchanged_when_tomorrow_is_over_capacity():
|
||||||
|
script = f"""
|
||||||
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||||
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}};
|
||||||
|
const id='issue:acme/mobile:41:'; let present=true; let staged=0;
|
||||||
|
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||||
|
const tomorrowPlan={{
|
||||||
|
load:async()=>({{revision:3,ids:['a','b'],capacity_minutes:60,estimates:{{a:40,b:30}},plan_date:'2026-08-21'}}),
|
||||||
|
stage:()=>{{staged+=1;return {{}};}},
|
||||||
|
}};
|
||||||
|
const todaySync={{enqueue:()=>true,flush:async()=>true}};
|
||||||
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
||||||
|
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
||||||
|
process.stdout.write(JSON.stringify({{error,present,staged}}));
|
||||||
|
"""
|
||||||
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
|
assert output == {
|
||||||
|
"error": "Tomorrow is over capacity. Edit the plan before finishing wrap-up.",
|
||||||
|
"present": True,
|
||||||
|
"staged": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_up_keeps_today_unchanged_when_tomorrow_cannot_be_saved_on_the_phone():
|
||||||
|
script = f"""
|
||||||
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||||
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}};
|
||||||
|
const id='issue:acme/mobile:41:'; let present=true; let enqueued=0;
|
||||||
|
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||||
|
const tomorrowPlan={{
|
||||||
|
load:async()=>({{revision:3,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}),
|
||||||
|
stage:()=>false,
|
||||||
|
}};
|
||||||
|
const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}};
|
||||||
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
||||||
|
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
||||||
|
process.stdout.write(JSON.stringify({{error,present,enqueued}}));
|
||||||
|
"""
|
||||||
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
|
assert output == {
|
||||||
|
"error": "Tomorrow could not be saved on this device. Your Today plan is unchanged.",
|
||||||
|
"present": True,
|
||||||
|
"enqueued": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
||||||
|
|
@ -65,9 +146,13 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
|
||||||
|
|
||||||
assert 'id="today-wrap-up-sheet" role="dialog"' in html
|
assert 'id="today-wrap-up-sheet" role="dialog"' in html
|
||||||
assert 'id="today-wrap-up-items"' in html
|
assert 'id="today-wrap-up-items"' in html
|
||||||
|
assert 'Carry selected work into the ordered Tomorrow plan.' in html
|
||||||
|
assert 'Tomorrow 09:00' not in html
|
||||||
assert 'id="finish-today-wrap-up"' in html
|
assert 'id="finish-today-wrap-up"' in html
|
||||||
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
|
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
|
||||||
assert 'setupTodayWrapUp({' in dashboard
|
assert 'setupTodayWrapUp({' in dashboard
|
||||||
|
assert 'todayWork, tomorrowPlan, todaySync' in dashboard
|
||||||
|
assert 'syncPendingTomorrow();' in dashboard
|
||||||
assert 'todayWrapUpView.open' in dashboard
|
assert 'todayWrapUpView.open' in dashboard
|
||||||
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
|
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
|
||||||
assert '.today-wrap-up-actions button { min-height:44px;' in css
|
assert '.today-wrap-up-actions button { min-height:44px;' in css
|
||||||
|
|
@ -75,19 +160,20 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
|
||||||
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_up_repeat_confirmation_does_not_repeat_later_or_today_operations():
|
def test_wrap_up_repeat_confirmation_does_not_repeat_tomorrow_or_today_operations():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||||
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}};
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}};
|
||||||
const id='issue:acme/mobile:41:';
|
const id='issue:acme/mobile:41:';
|
||||||
let present=true; let deferred=0; let removed=0;
|
let present=true; let staged=0; let removed=0;
|
||||||
|
let tomorrow={{revision:1,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}};
|
||||||
const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||||
const laterWork={{presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:()=>{{deferred+=1;return 'deferred';}}}};
|
const tomorrowPlan={{load:async()=>tomorrow,stage:value=>{{staged+=1;tomorrow={{...tomorrow,...value,sync_pending:true}};return tomorrow;}}}};
|
||||||
const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}};
|
const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}};
|
||||||
const wrap=createWrapUp({{todayWork,laterWork,todaySync}});
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}});
|
||||||
wrap.open([item]); wrap.choose(id,true);
|
wrap.open([item]); wrap.choose(id,true);
|
||||||
await wrap.finish(); await wrap.finish();
|
await wrap.finish(); await wrap.finish();
|
||||||
process.stdout.write(JSON.stringify({{deferred,removed,present}}));
|
process.stdout.write(JSON.stringify({{staged,removed,present}}));
|
||||||
"""
|
"""
|
||||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||||
assert output == {"deferred": 1, "removed": 1, "present": False}
|
assert output == {"staged": 1, "removed": 1, "present": False}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user