Merge pull request 'Defer active Today work and continue to the next ready item' (#456) from timmy/455-defer-today-next into main
All checks were successful
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
timmy 2026-08-10 05:24:08 +00:00
commit f8c5c2b058
3 changed files with 105 additions and 0 deletions

View File

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

View File

@ -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.');

View File

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