diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 39ec8ee..2822436 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -714,11 +714,23 @@ 'Session recovery could not be saved on this device. You can keep working.'; }, }); + function updateDetailDeferLabels(active) { + document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => { + button.textContent = active ? 'Later today & next' : 'Later today'; + }); + document.querySelectorAll('[data-detail-defer-preset=tomorrow]').forEach(button => { + button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'; + }); + document.querySelectorAll('[data-detail-defer-custom]').forEach(button => { + button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'; + }); + } function updateWorkSessionActions() { const active = workSession.active(); qs('#start-work-session').hidden = active; qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable(); qs('#end-today-session').hidden = !active; + updateDetailDeferLabels(active); mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention); } @@ -1238,6 +1250,10 @@ const detailDefer = createDetailDefer({ laterWork, session: workSession, + continueSession: item => completeTodayItem(item, { + successMessage: 'Deferred to Later. Next Today item opened.', + failureMessage: 'Could not remove this item from Today.', + }), close: () => workRoute.close(), refresh: refreshMyWorkView, focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(), diff --git a/frontend/detail-defer.js b/frontend/detail-defer.js index b7a3ebd..4f70830 100644 --- a/frontend/detail-defer.js +++ b/frontend/detail-defer.js @@ -1,6 +1,7 @@ function createDetailDefer({ laterWork, session, + continueSession = null, close, refresh, focus, @@ -16,6 +17,13 @@ function createDetailDefer({ return false; } const inSession = session.active(); + if (continueSession && session.checkpointed?.(item)) { + if (continueSession(item) === true) return true; + laterWork.restore(item); + refresh(); + announce('Could not remove this item from Today, so it was restored from Later.'); + return false; + } if (!inSession && closeSheet) close(); refresh(); announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.'); diff --git a/tests/test_my_work.py b/tests/test_my_work.py index f911a6e..ddfec1e 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1223,6 +1223,75 @@ process.stdout.write(JSON.stringify({{saved,calls}})); } +def test_detail_defer_completes_checkpointed_today_item_after_saving_later(): + script = f""" +const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))}); +const calls = []; +const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}}; +const controller = createDetailDefer({{ + laterWork: {{ + presetUntil:() => new Date('2026-08-09T09:00:00Z'), + defer:(saved, until) => {{ calls.push(['defer', saved.title, until.toISOString()]); return 'deferred'; }}, + restore:() => calls.push(['restore']), + }}, + session: {{active:() => true, checkpointed:saved => saved === item}}, + continueSession:saved => {{ calls.push(['continue', saved.title]); return true; }}, + close:() => calls.push(['close']), refresh:() => calls.push(['refresh']), + focus:() => calls.push(['focus']), announce:message => calls.push(['announce', message]), +}}); +const saved = controller.defer(item, 'tomorrow'); +process.stdout.write(JSON.stringify({{saved,calls}})); +""" + + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "saved": True, + "calls": [ + ["defer", "Read first", "2026-08-09T09:00:00.000Z"], + ["continue", "Read first"], + ], + } + + +def test_detail_defer_rolls_back_later_when_today_cannot_be_removed(): + script = f""" +const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))}); +const calls = []; +const item = {{kind:'issue',repository:'stackchain/api',number:17}}; +const controller = createDetailDefer({{ + laterWork: {{ + presetUntil:() => new Date('2026-08-09T09:00:00Z'), + defer:() => {{ calls.push('defer'); return 'deferred'; }}, + restore:saved => {{ calls.push(['restore', saved.number]); return true; }}, + }}, + session: {{active:() => true, checkpointed:() => true}}, + continueSession:() => {{ calls.push('continue'); return false; }}, + close:() => calls.push('close'), refresh:() => calls.push('refresh'), + focus:() => calls.push('focus'), announce:message => calls.push(['announce', message]), +}}); +const saved = controller.defer(item, 'tomorrow'); +process.stdout.write(JSON.stringify({{saved,calls}})); +""" + + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "saved": False, + "calls": [ + "defer", + "continue", + ["restore", 17], + "refresh", + ["announce", "Could not remove this item from Today, so it was restored from Later."], + ], + } + + @pytest.mark.anyio async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation(): html = await dashboard() @@ -1238,6 +1307,18 @@ async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutat assert "fetch(" not in DETAIL_DEFER.read_text() +@pytest.mark.anyio +async def test_checkpointed_detail_defer_completes_today_and_advertises_next_item(): + html = await dashboard() + + assert "continueSession: item => completeTodayItem(item, {" in html + assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html + assert "advance: () => runTodayTransition('complete')" in html + assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html + assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html + assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html + + @pytest.mark.anyio async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions(): html = await dashboard()