Merge pull request 'Start deferred work directly from Later' (#458) from timmy/457-start-deferred-work into main
This commit is contained in:
commit
e3c07c2dfb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
'<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 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>';
|
||||
const alreadyToday = todayWork.contains(item);
|
||||
const todayPosition = todayWork.position(item);
|
||||
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"><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) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -652,6 +652,7 @@
|
|||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/later-work.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/later-picker.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/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',
|
||||
|
|
|
|||
|
|
@ -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 '<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
|
||||
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user