diff --git a/README.md b/README.md
index 79a3a0a..ab9f55b 100644
--- a/README.md
+++ b/README.md
@@ -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.
Deferred items leave normal and Attention queues without marking notifications read
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
-early. Later wake times are scoped to the confirmed Gitea login and synchronize
+existing priority position at the wake time. **Start now** atomically admits a deferred
+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,
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
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 2822436..bf78336 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -915,6 +915,19 @@
refresh: refreshMyWorkView,
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 => ({
issue: selectedIssue,
@@ -1688,14 +1701,15 @@
'Read update ' : '';
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
const laterActions = selectedWorkFilter === 'later' ?
- '
Bring back now
' :
+ 'Start now Bring back now
' :
'Later today Tomorrow Choose date & time
';
const alreadyToday = todayWork.contains(item);
const todayPosition = todayWork.position(item);
const todayActions = selectedWorkFilter === 'today' ?
'Move up Move down Remove from Today
' :
'' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '
';
- const planningActions = 'Plan or defer ' + todayActions + laterActions + '
';
+ const planningActions = selectedWorkFilter === 'later' ? laterActions :
+ 'Plan or defer ' + todayActions + laterActions + '
';
if (item.is_review) {
return '' + contents + ' ' + readUpdate + markRead + planningActions + ' ';
}
@@ -1777,6 +1791,15 @@
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 => {
button.addEventListener('click', () => {
if (!planningOwnerLogin) {
diff --git a/frontend/index.html b/frontend/index.html
index 9cb69fe..9a049b5 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -652,6 +652,7 @@
+
diff --git a/frontend/later-and-start.js b/frontend/later-and-start.js
new file mode 100644
index 0000000..572d809
--- /dev/null
+++ b/frontend/later-and-start.js
@@ -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;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 1cc5c80..e54539a 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -37,6 +37,7 @@ const SHELL = [
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
+ BASE + 'static/later-and-start.js',
BASE + 'static/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index ddfec1e..bf3b26a 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -12,6 +12,7 @@ MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.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"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "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():
script = f"""
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
+@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 '' in html
+ assert 'data-later-start' in html
+ assert '>Start now' 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
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
html = await dashboard()
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 6e677bb..559c4fb 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -412,6 +412,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
"/dashboard/static/later-sync.js",
+ "/dashboard/static/later-and-start.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",