feat: start deferred work directly (#457)
This commit is contained in:
parent
f8c5c2b058
commit
a610708e36
|
|
@ -274,8 +274,11 @@ accepts a valid future local date and time and returns the item at that exact in
|
||||||
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
||||||
Deferred items leave normal and Attention queues without marking notifications read
|
Deferred items leave normal and Attention queues without marking notifications read
|
||||||
or changing any Gitea issue or pull request. They automatically return to their
|
or changing any Gitea issue or pull request. They automatically return to their
|
||||||
existing priority position at the wake time, and **Bring back now** restores them
|
existing priority position at the wake time. **Start now** atomically admits a deferred
|
||||||
early. Later wake times are scoped to the confirmed Gitea login and synchronize
|
item to Today, removes its Later record, and opens that exact item in a resumable Today
|
||||||
|
session; full or unavailable Today storage leaves the deferral intact for retry. Items
|
||||||
|
already in Today are opened without duplication. **Bring back now** still restores an
|
||||||
|
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||||
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||||
and replay after reconnect. Each queued edit carries the account revision it was based
|
and replay after reconnect. Each queued edit carries the account revision it was based
|
||||||
on; if another device has since changed the same item, Stackchain keeps the newer
|
on; if another device has since changed the same item, Stackchain keeps the newer
|
||||||
|
|
|
||||||
|
|
@ -915,6 +915,19 @@
|
||||||
refresh: refreshMyWorkView,
|
refresh: refreshMyWorkView,
|
||||||
warm: warmTodayOffline,
|
warm: warmTodayOffline,
|
||||||
});
|
});
|
||||||
|
const laterAndStart = createLaterAndStart({
|
||||||
|
todayWork,
|
||||||
|
todaySync,
|
||||||
|
laterWork,
|
||||||
|
refresh: refreshMyWorkView,
|
||||||
|
warm: warmTodayOffline,
|
||||||
|
start: item => {
|
||||||
|
qs('[data-work-filter="today"]').click();
|
||||||
|
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
|
||||||
|
return todayReadiness.run('start', workSession.items(), item);
|
||||||
|
},
|
||||||
|
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||||
|
});
|
||||||
|
|
||||||
const selectedSessionItem = kind => ({
|
const selectedSessionItem = kind => ({
|
||||||
issue: selectedIssue,
|
issue: selectedIssue,
|
||||||
|
|
@ -1688,14 +1701,15 @@
|
||||||
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||||||
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
|
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
|
||||||
const laterActions = selectedWorkFilter === 'later' ?
|
const laterActions = selectedWorkFilter === 'later' ?
|
||||||
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
'<div class="later-actions" aria-label="Deferred work actions"><button type="button" data-later-start data-work-index="' + index + '"' + planningDisabled + '>Start now</button><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
||||||
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button><button type="button" data-later-custom data-work-index="' + index + '"' + planningDisabled + '>Choose date & time</button></div>';
|
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button><button type="button" data-later-custom data-work-index="' + index + '"' + planningDisabled + '>Choose date & time</button></div>';
|
||||||
const alreadyToday = todayWork.contains(item);
|
const alreadyToday = todayWork.contains(item);
|
||||||
const todayPosition = todayWork.position(item);
|
const todayPosition = todayWork.position(item);
|
||||||
const todayActions = selectedWorkFilter === 'today' ?
|
const todayActions = selectedWorkFilter === 'today' ?
|
||||||
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
||||||
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
||||||
const planningActions = '<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
const planningActions = selectedWorkFilter === 'later' ? laterActions :
|
||||||
|
'<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
||||||
if (item.is_review) {
|
if (item.is_review) {
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||||
}
|
}
|
||||||
|
|
@ -1777,6 +1791,15 @@
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-later-start]').forEach(button => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||||
|
if (!item) return;
|
||||||
|
button.disabled = true;
|
||||||
|
await laterAndStart.start(item);
|
||||||
|
if (button.isConnected) button.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-today-add]').forEach(button => {
|
document.querySelectorAll('[data-today-add]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
if (!planningOwnerLogin) {
|
if (!planningOwnerLogin) {
|
||||||
|
|
|
||||||
|
|
@ -652,6 +652,7 @@
|
||||||
<script src="static/update-ownership.js"></script>
|
<script src="static/update-ownership.js"></script>
|
||||||
<script src="static/later-work.js"></script>
|
<script src="static/later-work.js"></script>
|
||||||
<script src="static/later-sync.js"></script>
|
<script src="static/later-sync.js"></script>
|
||||||
|
<script src="static/later-and-start.js"></script>
|
||||||
<script src="static/detail-defer.js"></script>
|
<script src="static/detail-defer.js"></script>
|
||||||
<script src="static/later-picker.js"></script>
|
<script src="static/later-picker.js"></script>
|
||||||
<script src="static/pick-work.js"></script>
|
<script src="static/pick-work.js"></script>
|
||||||
|
|
|
||||||
51
frontend/later-and-start.js
Normal file
51
frontend/later-and-start.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
async function run(item, identity) {
|
||||||
|
const added = todayWork.add(item);
|
||||||
|
if (added === 'full') {
|
||||||
|
announce('Today is limited to 5 items. Remove one, then try Start now again.');
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
if (added !== 'added' && added !== 'exists') {
|
||||||
|
announce('Could not save Today on this device. The item remains in Later; try again.');
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
if (added === 'added' && !todaySync.enqueue('add', identity)) {
|
||||||
|
todayWork.remove(item);
|
||||||
|
announce('Today sync is unavailable. The item remains in Later; try again.');
|
||||||
|
return 'sync-unavailable';
|
||||||
|
}
|
||||||
|
if (!laterWork.restore(item)) {
|
||||||
|
if (added === 'added') {
|
||||||
|
todayWork.remove(item);
|
||||||
|
todaySync.enqueue('remove', identity);
|
||||||
|
todaySync.flush();
|
||||||
|
}
|
||||||
|
announce('Could not remove this item from Later. Nothing was started; try again.');
|
||||||
|
return 'later-unavailable';
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
if (added === 'added') todaySync.flush();
|
||||||
|
warm();
|
||||||
|
const outcome = await start(item);
|
||||||
|
if (outcome === 'gated') {
|
||||||
|
announce('Moved to Today. Choose how to handle its blocker before starting.');
|
||||||
|
return 'gated';
|
||||||
|
}
|
||||||
|
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
|
||||||
|
return 'started';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDeferred(item) {
|
||||||
|
const identity = todayWork.identity(item);
|
||||||
|
if (pending.has(identity)) return pending.get(identity);
|
||||||
|
const operation = run(item, identity).finally(() => pending.delete(identity));
|
||||||
|
pending.set(identity, operation);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: startDeferred };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;
|
||||||
|
|
@ -37,6 +37,7 @@ const SHELL = [
|
||||||
BASE + 'static/update-ownership.js',
|
BASE + 'static/update-ownership.js',
|
||||||
BASE + 'static/later-work.js',
|
BASE + 'static/later-work.js',
|
||||||
BASE + 'static/later-sync.js',
|
BASE + 'static/later-sync.js',
|
||||||
|
BASE + 'static/later-and-start.js',
|
||||||
BASE + 'static/detail-defer.js',
|
BASE + 'static/detail-defer.js',
|
||||||
BASE + 'static/later-picker.js',
|
BASE + 'static/later-picker.js',
|
||||||
BASE + 'static/pick-work.js',
|
BASE + 'static/pick-work.js',
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||||
|
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.js"
|
||||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||||
|
|
@ -1153,6 +1154,219 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_deferred_work_moves_it_to_today_before_opening_exact_item():
|
||||||
|
script = f"""
|
||||||
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||||
|
const calls = [];
|
||||||
|
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Resume me'}};
|
||||||
|
const controller = createLaterAndStart({{
|
||||||
|
todayWork: {{
|
||||||
|
identity:saved => saved.repository + '#' + saved.number,
|
||||||
|
add:saved => {{ calls.push(['today-add', saved.number]); return 'added'; }},
|
||||||
|
remove:saved => {{ calls.push(['today-remove', saved.number]); return true; }},
|
||||||
|
}},
|
||||||
|
todaySync: {{
|
||||||
|
enqueue:(action, identity) => {{ calls.push(['today-sync', action, identity]); return true; }},
|
||||||
|
flush:() => calls.push(['today-flush']),
|
||||||
|
}},
|
||||||
|
laterWork: {{restore:saved => {{ calls.push(['later-restore', saved.number]); return true; }}}},
|
||||||
|
refresh:() => calls.push(['refresh']),
|
||||||
|
warm:() => calls.push(['warm']),
|
||||||
|
start:saved => {{ calls.push(['start', saved.number]); return Promise.resolve('opened'); }},
|
||||||
|
announce:message => calls.push(['announce', message]),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.start(item);
|
||||||
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"result": "started",
|
||||||
|
"calls": [
|
||||||
|
["today-add", 17],
|
||||||
|
["today-sync", "add", "stackchain/api#17"],
|
||||||
|
["later-restore", 17],
|
||||||
|
["refresh"],
|
||||||
|
["today-flush"],
|
||||||
|
["warm"],
|
||||||
|
["start", 17],
|
||||||
|
["announce", "Moved to Today and opened."],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_deferred_work_keeps_later_when_today_is_full():
|
||||||
|
script = f"""
|
||||||
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||||
|
const calls = [];
|
||||||
|
const controller = createLaterAndStart({{
|
||||||
|
todayWork: {{identity:() => 'issue:x/y:7', add:() => 'full', remove:() => calls.push('remove')}},
|
||||||
|
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
|
||||||
|
laterWork: {{restore:() => calls.push('restore')}},
|
||||||
|
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
||||||
|
start:() => calls.push('start'), announce:message => calls.push(message),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.start({{kind:'issue',repository:'x/y',number:7}});
|
||||||
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"result": "full",
|
||||||
|
"calls": ["Today is limited to 5 items. Remove one, then try Start now again."],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_deferred_work_rolls_back_today_when_sync_admission_fails():
|
||||||
|
script = f"""
|
||||||
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||||
|
const calls = [];
|
||||||
|
const item = {{kind:'pull',repository:'x/y',number:8}};
|
||||||
|
const controller = createLaterAndStart({{
|
||||||
|
todayWork: {{
|
||||||
|
identity:() => 'pull:x/y:8', add:() => {{ calls.push('add'); return 'added'; }},
|
||||||
|
remove:() => {{ calls.push('remove'); return true; }},
|
||||||
|
}},
|
||||||
|
todaySync: {{enqueue:() => {{ calls.push('enqueue'); return false; }}, flush:() => calls.push('flush')}},
|
||||||
|
laterWork: {{restore:() => calls.push('restore')}},
|
||||||
|
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
||||||
|
start:() => calls.push('start'), announce:message => calls.push(message),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.start(item);
|
||||||
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"result": "sync-unavailable",
|
||||||
|
"calls": [
|
||||||
|
"add", "enqueue", "remove",
|
||||||
|
"Today sync is unavailable. The item remains in Later; try again.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_deferred_work_rolls_back_today_when_later_cannot_be_removed():
|
||||||
|
script = f"""
|
||||||
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||||
|
const calls = [];
|
||||||
|
const item = {{kind:'review',repository:'x/y',number:9}};
|
||||||
|
const controller = createLaterAndStart({{
|
||||||
|
todayWork: {{
|
||||||
|
identity:() => 'review:x/y:9', add:() => 'added',
|
||||||
|
remove:() => {{ calls.push('remove'); return true; }},
|
||||||
|
}},
|
||||||
|
todaySync: {{
|
||||||
|
enqueue:(action, id) => {{ calls.push(['enqueue', action, id]); return true; }},
|
||||||
|
flush:() => calls.push('flush'),
|
||||||
|
}},
|
||||||
|
laterWork: {{restore:() => false}}, refresh:() => calls.push('refresh'),
|
||||||
|
warm:() => calls.push('warm'), start:() => calls.push('start'),
|
||||||
|
announce:message => calls.push(message),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.start(item);
|
||||||
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"result": "later-unavailable",
|
||||||
|
"calls": [
|
||||||
|
["enqueue", "add", "review:x/y:9"], "remove",
|
||||||
|
["enqueue", "remove", "review:x/y:9"], "flush",
|
||||||
|
"Could not remove this item from Later. Nothing was started; try again.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_deferred_work_reuses_existing_today_item_without_duplicate_sync():
|
||||||
|
script = f"""
|
||||||
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||||
|
const calls = [];
|
||||||
|
const item = {{kind:'issue',repository:'x/y',number:10}};
|
||||||
|
const controller = createLaterAndStart({{
|
||||||
|
todayWork: {{identity:() => 'issue:x/y:10', add:() => 'exists', remove:() => calls.push('remove')}},
|
||||||
|
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
|
||||||
|
laterWork: {{restore:() => {{ calls.push('restore'); return true; }}}},
|
||||||
|
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
||||||
|
start:saved => calls.push(['start', saved.number]), announce:message => calls.push(message),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.start(item);
|
||||||
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"result": "started",
|
||||||
|
"calls": [
|
||||||
|
"restore", "refresh", "warm", ["start", 10],
|
||||||
|
"Opened the existing Today item.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_deferred_work_is_single_flight_for_repeated_taps():
|
||||||
|
script = f"""
|
||||||
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||||
|
let adds = 0;
|
||||||
|
let finishStart;
|
||||||
|
const item = {{kind:'issue',repository:'x/y',number:11}};
|
||||||
|
const controller = createLaterAndStart({{
|
||||||
|
todayWork: {{identity:() => 'issue:x/y:11', add:() => {{ adds += 1; return 'added'; }}, remove:() => true}},
|
||||||
|
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
|
||||||
|
refresh:() => {{}}, warm:() => {{}}, announce:() => {{}},
|
||||||
|
start:() => new Promise(resolve => {{ finishStart = resolve; }}),
|
||||||
|
}});
|
||||||
|
const first = controller.start(item);
|
||||||
|
const second = controller.start(item);
|
||||||
|
if (adds !== 1) throw new Error('duplicate admission');
|
||||||
|
finishStart('opened');
|
||||||
|
(async () => {{
|
||||||
|
const results = await Promise.all([first, second]);
|
||||||
|
process.stdout.write(JSON.stringify({{adds,results}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {"adds": 1, "results": ["started", "started"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_blocked_deferred_work_reports_readiness_gate_without_claiming_open():
|
||||||
|
script = f"""
|
||||||
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||||
|
const messages = [];
|
||||||
|
const controller = createLaterAndStart({{
|
||||||
|
todayWork: {{identity:() => 'issue:x/y:12', add:() => 'added', remove:() => true}},
|
||||||
|
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
|
||||||
|
refresh:() => {{}}, warm:() => {{}}, start:() => Promise.resolve('gated'),
|
||||||
|
announce:message => messages.push(message),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.start({{kind:'issue',repository:'x/y',number:12}});
|
||||||
|
process.stdout.write(JSON.stringify({{result,messages}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"result": "gated",
|
||||||
|
"messages": ["Moved to Today. Choose how to handle its blocker before starting."],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
|
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
||||||
|
|
@ -1339,6 +1553,24 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||||
assert "'Deferred until ' + fmt(until)" in html
|
assert "'Deferred until ' + fmt(until)" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_later_cards_start_exact_item_in_a_resumable_today_session():
|
||||||
|
html = await dashboard()
|
||||||
|
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
|
assert '<script src="static/later-and-start.js"></script>' in html
|
||||||
|
assert 'data-later-start' in html
|
||||||
|
assert '>Start now</button>' in html
|
||||||
|
assert 'aria-label="Deferred work actions"' in html
|
||||||
|
assert "const laterAndStart = createLaterAndStart({" in html
|
||||||
|
assert "laterAndStart.start(item)" in html
|
||||||
|
assert "qs('[data-work-filter=\"today\"]').click();" in html
|
||||||
|
assert "todayReadiness.run('start', workSession.items(), item)" in html
|
||||||
|
assert "selectedWorkFilter === 'later' ? laterActions" in html
|
||||||
|
assert ".later-actions button { min-height:44px; width:100%; }" in html
|
||||||
|
assert "BASE + 'static/later-and-start.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/update-ownership.js",
|
"/dashboard/static/update-ownership.js",
|
||||||
"/dashboard/static/later-work.js",
|
"/dashboard/static/later-work.js",
|
||||||
"/dashboard/static/later-sync.js",
|
"/dashboard/static/later-sync.js",
|
||||||
|
"/dashboard/static/later-and-start.js",
|
||||||
"/dashboard/static/detail-defer.js",
|
"/dashboard/static/detail-defer.js",
|
||||||
"/dashboard/static/later-picker.js",
|
"/dashboard/static/later-picker.js",
|
||||||
"/dashboard/static/pick-work.js",
|
"/dashboard/static/pick-work.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user