diff --git a/README.md b/README.md
index 99f90d8..15be821 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 912e527..2662d42 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index fc98232..a628504 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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())
);
diff --git a/frontend/index.html b/frontend/index.html
index 6e16159..6cd5efe 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -50,6 +50,8 @@
+
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 72b8818..6d67c59 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -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;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 6a3b886..7ea13d5 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -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 = [
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index decb91d..0c942d9 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -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
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 0d83837..430b4b6 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -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
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index f765675..20d61c5 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -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
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index a861f21..a3c2d06 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -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()
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index e1da8cb..124bdbc 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -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
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index fa931e4..200606a 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 8a909c7..5ffc3d4 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -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