Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734) from timmy/733-resumable-mobile-agenda into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s

Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734)
This commit is contained in:
timmy 2026-08-13 11:29:15 +00:00
commit 4ac5a0f499
5 changed files with 118 additions and 18 deletions

View File

@ -72,6 +72,7 @@
let mobileQueueCounts = {}; let mobileQueueCounts = {};
const mobileQueueLauncher = createMobileQueueLauncher({ const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(), openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
selectFilter: selectMobileQueue, selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: message => { qs('#my-work-action-status').textContent = message; }, announce: message => { qs('#my-work-action-status').textContent = message; },
@ -1082,6 +1083,15 @@
'Session recovery could not be saved on this device. You can keep working.'; 'Session recovery could not be saved on this device. You can keep working.';
}, },
}); });
const agendaSessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
key: 'stackchain.agenda-session.v1',
onError: () => {
qs('#my-work-action-status').textContent =
'Agenda progress could not be saved on this device. You can keep working.';
},
});
const timer = createTodayTimer({ const timer = createTodayTimer({
storage: localStorage, storage: localStorage,
getLogin: () => confirmedOwnerLogin, getLogin: () => confirmedOwnerLogin,
@ -1150,8 +1160,10 @@
const active = workSession.active(); const active = workSession.active();
const startWorkSession = qs('#start-work-session'); const startWorkSession = qs('#start-work-session');
startWorkSession.hidden = active; startWorkSession.hidden = active;
startWorkSession.textContent = selectedWorkFilter === 'agenda' ? 'Start Agenda' : 'Start work'; startWorkSession.textContent = selectedWorkFilter === 'agenda' ?
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable(); (agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda') : 'Start work';
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
!todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active; qs('#end-today-session').hidden = !active;
updateDetailDeferLabels(active); updateDetailDeferLabels(active);
mobileTaskDock.updateWork(mobileWorkEntry.mode()); mobileTaskDock.updateWork(mobileWorkEntry.mode());
@ -1164,8 +1176,9 @@
getFilter: () => selectedWorkFilter === 'today' ? 'all' : getFilter: () => selectedWorkFilter === 'today' ? 'all' :
selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter, selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone, getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone,
checkpoint: sessionCheckpoint, checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint,
checkpointEnabled: () => selectedWorkFilter === 'today', checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter),
checkpointedEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem, onOpen: openWorkSessionItem,
onProgress: state => { onProgress: state => {
updateWorkSessionActions(); updateWorkSessionActions();
@ -1186,6 +1199,7 @@
updateWorkSessionActions(); updateWorkSessionActions();
if (selectedWorkFilter === 'agenda') { if (selectedWorkFilter === 'agenda') {
closeOpenWorkSheets(); closeOpenWorkSheets();
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Agenda complete.'; qs('#my-work-action-status').textContent = 'Agenda complete.';
return; return;
} }
@ -1196,6 +1210,13 @@
qs('[data-work-filter="today"]').click(); qs('[data-work-filter="today"]').click();
} }
function openAgendaSession() {
selectMobileQueue('agenda');
const opened = agendaSessionCheckpoint.read() ? workSession.resume() : workSession.start();
if (!opened) qs('#my-work-action-status').textContent = 'No deadlines are ready in Agenda.';
return opened ? 'opened' : 'empty';
}
let todayReadinessTrigger = null; let todayReadinessTrigger = null;
let todayReadinessBlockerFocus = null; let todayReadinessBlockerFocus = null;
let searchPreviewReturnKind = null; let searchPreviewReturnKind = null;
@ -5886,6 +5907,7 @@
return; return;
} }
if (selectedWorkFilter === 'today') runTodayTransition('start'); if (selectedWorkFilter === 'today') runTodayTransition('start');
else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume();
else workSession.start(); else workSession.start();
}); });
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession()); qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());

View File

@ -27,6 +27,7 @@
function open(name) { function open(name) {
if (name === 'today') return options.openToday(); if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
options.selectFilter(name); options.selectFilter(name);
const action = options.firstAction(name); const action = options.firstAction(name);
if (!action) { if (!action) {

View File

@ -675,13 +675,14 @@ function createWorkSessionCheckpoint({
function createWorkSession({ function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null, getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true, checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
onOpen, onProgress, onFinish, onOpen, onProgress, onFinish,
}) { }) {
let currentIdentity = ''; let currentIdentity = '';
let currentIndex = -1; let currentIndex = -1;
let running = false; let running = false;
let durable = false; let durable = false;
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone()); const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({ const report = (items, index) => onProgress({
@ -696,7 +697,7 @@ function createWorkSession({
durable = false; durable = false;
currentIdentity = ''; currentIdentity = '';
currentIndex = -1; currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear(); if (clearCheckpoint) activeCheckpoint()?.clear();
onFinish(); onFinish();
return false; return false;
}; };
@ -704,7 +705,7 @@ function createWorkSession({
if (!items.length || index < 0 || index >= items.length) return finish(); if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index; currentIndex = index;
currentIdentity = workIdentity(items[index]); currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex); if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index); report(items, index);
onOpen(items[index]); onOpen(items[index]);
return true; return true;
@ -712,9 +713,9 @@ function createWorkSession({
return { return {
active: () => running, active: () => running,
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity), checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
end: () => finish(), end: () => finish(),
resumable: () => Boolean(checkpoint?.read()), resumable: () => Boolean(activeCheckpoint()?.read()),
reopen(requested = null) { reopen(requested = null) {
if (!running) return false; if (!running) return false;
const items = queue(); const items = queue();
@ -723,13 +724,13 @@ function createWorkSession({
if (index < 0) return false; if (index < 0) return false;
currentIdentity = requestedIdentity; currentIdentity = requestedIdentity;
currentIndex = index; currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex); if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index); report(items, index);
onOpen(items[index]); onOpen(items[index]);
return true; return true;
}, },
resume(requested = null) { resume(requested = null) {
const saved = checkpoint?.read(); const saved = activeCheckpoint()?.read();
if (!saved) return false; if (!saved) return false;
durable = true; durable = true;
const items = queue(); const items = queue();
@ -743,7 +744,7 @@ function createWorkSession({
const items = queue(); const items = queue();
if (!items.length) return finish(); if (!items.length) return finish();
running = true; running = true;
durable = Boolean(checkpoint && checkpointEnabled()); durable = Boolean(activeCheckpoint() && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0; const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0); return openAt(items, requested >= 0 ? requested : 0);
}, },
@ -791,7 +792,7 @@ function createWorkSession({
if (!items.length) return null; if (!items.length) return null;
if (action === 'start') return items[0]; if (action === 'start') return items[0];
if (action === 'resume') { if (action === 'resume') {
const saved = checkpoint?.read(); const saved = activeCheckpoint()?.read();
if (!saved) return null; if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity); const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)]; return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];

View File

@ -388,6 +388,7 @@ const calls = [];
let counts = {{attention: 2, today: 1, agenda: 5, later: 4, draft: 3}}; let counts = {{attention: 2, today: 1, agenda: 5, later: 4, draft: 3}};
const launcher = createLauncher({{ const launcher = createLauncher({{
openToday: () => calls.push('today'), openToday: () => calls.push('today'),
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
selectFilter: name => calls.push('filter:' + name), selectFilter: name => calls.push('filter:' + name),
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null, firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
announce: message => calls.push('announce:' + message), announce: message => calls.push('announce:' + message),
@ -411,7 +412,7 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
"agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"}, "agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"},
"opened": "opened", "opened": "opened",
"fallback": {"name": "find", "count": 0, "label": "Find Work"}, "fallback": {"name": "find", "count": 0, "label": "Find Work"},
"calls": ["filter:agenda", "open:agenda", "find"], "calls": ["agenda", "find"],
} }

View File

@ -239,16 +239,91 @@ process.stdout.write(JSON.stringify({{
} }
def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = 'timmy';
const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin:() => login, key:'stackchain.agenda-session.v1',
}});
const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin:() => login, key:'stackchain.today-session.v1',
}});
let mode = 'agenda';
let items = [
{{kind:'issue',repository:'o/r',number:1,title:'Overdue'}},
{{kind:'issue',repository:'o/r',number:2,title:'Today'}},
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}},
];
const opened = [];
const makeSession = () => buildMyWork.createWorkSession({{
getItems:() => items, getFilter:() => 'all',
checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint,
checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today',
onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}},
}});
const first = makeSession();
first.start();
first.next();
const saved = JSON.parse(values.get('stackchain.agenda-session.v1'));
const todayWhileRunning = first.checkpointed();
login = 'alexander';
const hiddenFromOtherAccount = makeSession().resumable();
login = 'timmy';
items = items.filter(item => item.number !== 2);
const resumedSession = makeSession();
const resumed = resumedSession.resume();
resumedSession.end();
process.stdout.write(JSON.stringify({{
saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened,
agendaCleared:!values.has('stackchain.agenda-session.v1'),
today:values.get('stackchain.today-session.v1'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
state = json.loads(result.stdout)
assert state["saved"] == {
"version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1,
}
assert state["todayWhileRunning"] is False
assert state["hiddenFromOtherAccount"] is False
assert state["resumed"] is True
assert state["opened"] == ["Overdue", "Today", "Tomorrow"]
assert state["agendaCleared"] is True
assert state["today"] == "preserve-today"
@pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
html = await dashboard()
assert "openAgenda: openAgendaSession" in html
assert "key: 'stackchain.agenda-session.v1'" in html
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
assert "window.location.hash = '#/my-work/agenda'" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda(): async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
html = await dashboard() html = await dashboard()
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html
assert "selectedWorkFilter === 'agenda' ? 'all'" in html assert "selectedWorkFilter === 'agenda' ? 'all'" in html
assert "startWorkSession.textContent = selectedWorkFilter === 'agenda' ? 'Start Agenda' : 'Start work'" in html assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
assert "const sessionItems = workSession.items()" in html assert "const sessionItems = workSession.items()" in html
assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html
assert "checkpointEnabled: () => selectedWorkFilter === 'today'" in html assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"]) @pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
@ -3220,8 +3295,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
assert 'id="end-today-session"' in html assert 'id="end-today-session"' in html
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'checkpoint: sessionCheckpoint' in html assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
assert "qs('#resume-today-session').addEventListener('click'" in html assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume(item)" in html assert "workSession.resume(item)" in html