feat: start deferred work directly (#457)
All checks were successful
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-10 06:00:35 +00:00
parent f8c5c2b058
commit a610708e36
7 changed files with 316 additions and 4 deletions

View File

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

View File

@ -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 &amp; 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 &amp; 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) {

View File

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

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

View File

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

View File

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

View File

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