feat: resume mobile Agenda sessions (Closes #733)
All checks were successful
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-13 11:27:13 +00:00
parent 1fc70f6890
commit 715df380e0
5 changed files with 118 additions and 18 deletions

View File

@ -72,6 +72,7 @@
let mobileQueueCounts = {};
const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
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'),
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.';
},
});
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({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
@ -1150,8 +1160,10 @@
const active = workSession.active();
const startWorkSession = qs('#start-work-session');
startWorkSession.hidden = active;
startWorkSession.textContent = selectedWorkFilter === 'agenda' ? 'Start Agenda' : 'Start work';
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
startWorkSession.textContent = selectedWorkFilter === 'agenda' ?
(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;
updateDetailDeferLabels(active);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
@ -1164,8 +1176,9 @@
getFilter: () => selectedWorkFilter === 'today' ? 'all' :
selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone,
checkpoint: sessionCheckpoint,
checkpointEnabled: () => selectedWorkFilter === 'today',
checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint,
checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter),
checkpointedEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem,
onProgress: state => {
updateWorkSessionActions();
@ -1186,6 +1199,7 @@
updateWorkSessionActions();
if (selectedWorkFilter === 'agenda') {
closeOpenWorkSheets();
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Agenda complete.';
return;
}
@ -1196,6 +1210,13 @@
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 todayReadinessBlockerFocus = null;
let searchPreviewReturnKind = null;
@ -5886,6 +5907,7 @@
return;
}
if (selectedWorkFilter === 'today') runTodayTransition('start');
else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume();
else workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());

View File

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

View File

@ -675,13 +675,14 @@ function createWorkSessionCheckpoint({
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
let durable = false;
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
@ -696,7 +697,7 @@ function createWorkSession({
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
if (clearCheckpoint) activeCheckpoint()?.clear();
onFinish();
return false;
};
@ -704,7 +705,7 @@ function createWorkSession({
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@ -712,9 +713,9 @@ function createWorkSession({
return {
active: () => running,
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
resumable: () => Boolean(activeCheckpoint()?.read()),
reopen(requested = null) {
if (!running) return false;
const items = queue();
@ -723,13 +724,13 @@ function createWorkSession({
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume(requested = null) {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return false;
durable = true;
const items = queue();
@ -743,7 +744,7 @@ function createWorkSession({
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(checkpoint && checkpointEnabled());
durable = Boolean(activeCheckpoint() && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
@ -791,7 +792,7 @@ function createWorkSession({
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
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}};
const launcher = createLauncher({{
openToday: () => calls.push('today'),
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
selectFilter: name => calls.push('filter:' + name),
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
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)"},
"opened": "opened",
"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
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
html = await dashboard()
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" 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 "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"])
@ -3220,8 +3295,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
assert 'id="end-today-session"' in html
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'checkpoint: sessionCheckpoint' in html
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume(item)" in html