Merge pull request 'Resume interrupted Today sessions' (#396) from timmy/395-resume-today-session into main
All checks were successful
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
rockachopa 2026-08-09 11:03:45 +00:00
commit 055a9dbb2a
13 changed files with 305 additions and 14 deletions

View File

@ -37,7 +37,11 @@ the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
five-item Today plan syncs across the operator's devices. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
offline operations. Planning edits can remain offline for up to 30 days. After that, the

View File

@ -246,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }

View File

@ -665,12 +665,30 @@
openRoutedWork(item, null, { replace:true });
}
const sessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onError: () => {
qs('#my-work-action-status').textContent =
'Session recovery could not be saved on this device. You can keep working.';
},
});
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;
}
const workSession = createWorkSession({
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkMilestone,
checkpoint: sessionCheckpoint,
checkpointEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem,
onProgress: state => {
updateWorkSessionActions();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + state.index + ' of ' + state.total;
@ -686,6 +704,7 @@
closeOpenWorkSheets();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.';
updateWorkSessionActions();
qs('#start-work-session').focus();
},
});
@ -1042,6 +1061,7 @@
updateWorkPaginationControls();
renderMyWork();
if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
}
function activeWorkStreams() {
@ -3576,6 +3596,18 @@
}
workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => {
qs('[data-work-filter="today"]').click();
if (!workSession.resume()) {
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
updateWorkSessionActions();
}
});
qs('#end-today-session').addEventListener('click', () => {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
qs('#start-work-session').focus();
});
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous())
);

View File

@ -50,6 +50,8 @@
<div class="my-work-actions">
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
</div>

View File

@ -472,10 +472,67 @@ function workIdentity(item) {
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
function createWorkSessionCheckpoint({
storage,
getLogin,
onError = () => {},
key = 'stackchain.today-session.v1',
}) {
const login = () => String(getLogin() || '').trim();
let errorReported = false;
const reportError = error => {
if (errorReported) return;
errorReported = true;
onError(error);
};
const read = () => {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
!Number.isInteger(value.index) || value.index < 0) return null;
return value;
} catch (error) {
reportError(error);
return null;
}
};
return {
read,
save(identity, index) {
const owner = login();
if (!owner) return false;
try {
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
return true;
} catch (error) {
reportError(error);
return false;
}
},
clear() {
if (!read()) return false;
try {
storage.removeItem(key);
return true;
} catch (error) {
reportError(error);
return false;
}
},
};
}
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
let durable = false;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
@ -485,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
can_next: index < items.length - 1,
});
const finish = () => {
const clearCheckpoint = durable;
running = false;
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
onFinish();
return false;
};
@ -495,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@ -502,10 +563,23 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
return {
active: () => running,
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
resume() {
const saved = checkpoint?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(checkpoint && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
@ -612,6 +686,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v59';
const CACHE = 'stackchain-dashboard-shell-v60';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v59" in worker
assert "stackchain-dashboard-shell-v60" in worker

View File

@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v59" in worker
assert "stackchain-dashboard-shell-v60" in worker

View File

@ -1428,6 +1428,157 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
assert json.loads(result.stdout) == {"finished": 1, "active": False}
def test_today_session_checkpoint_resumes_current_item_after_reload():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const items = [1, 2, 3].map(number => ({{
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
}}));
const opened = [];
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin: () => 'timmy',
}});
const first = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all',
checkpoint: firstCheckpoint,
onOpen: item => opened.push('first:' + item.number),
onProgress: () => {{}}, onFinish: () => {{}},
}});
first.start();
first.next();
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin: () => 'timmy',
}});
const resumed = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all',
checkpoint: secondCheckpoint,
onOpen: item => opened.push('resumed:' + item.number),
onProgress: () => {{}}, onFinish: () => {{}},
}});
const available = resumed.resumable();
const didResume = resumed.resume();
process.stdout.write(JSON.stringify({{
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"available": True,
"didResume": True,
"opened": ["first:1", "first:2", "resumed:2"],
"checkpoint": {
"version": 1,
"login": "timmy",
"identity": "issue:stackchain/dashboard:2:",
"index": 1,
},
}
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let login = 'timmy';
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
const session = buildMyWork.createWorkSession({{
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
getFilter: () => 'all', checkpoint,
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
}});
session.start();
login = 'alexander';
const visibleToOtherAccount = session.resumable();
const retainedForOwner = values.size;
login = 'timmy';
session.end();
process.stdout.write(JSON.stringify({{
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"visibleToOtherAccount": False,
"retainedForOwner": 1,
"active": False,
"stored": 0,
}
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
}})]]);
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
const session = buildMyWork.createWorkSession({{
getItems:() => [], getFilter:() => 'all', checkpoint,
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
}});
const resumed = session.resume();
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
def test_today_session_keeps_working_when_checkpoint_storage_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let errors = 0;
const opened = [];
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
storage:{{
getItem:() => null,
setItem:() => {{ throw new Error('quota'); }},
removeItem:() => {{ throw new Error('quota'); }},
}},
getLogin:() => 'timmy',
onError:() => {{ errors += 1; }},
}});
const session = buildMyWork.createWorkSession({{
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
getFilter:() => 'all', checkpoint,
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
}});
session.start();
session.next();
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
@pytest.mark.anyio
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
html = await dashboard()
@ -1442,6 +1593,23 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
assert 'aria-live="polite" data-work-session-progress' in html
@pytest.mark.anyio
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
html = await dashboard()
assert 'id="resume-today-session"' in html
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 "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume()" in html
assert "workSession.end()" in html
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
@pytest.mark.anyio
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
html = await dashboard()

View File

@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/plan-today.js'" in source

View File

@ -105,10 +105,19 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -116,14 +125,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -132,21 +141,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/update-ownership.js'" in source

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v60" in source
assert "BASE + 'static/today-sync.js'" in source