Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734) from timmy/733-resumable-mobile-agenda into main
Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734)
This commit is contained in:
commit
4ac5a0f499
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)];
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user