Resume interrupted Today sessions #396
|
|
@ -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
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
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,
|
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,
|
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
|
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
|
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.find-work-action { min-height:44px; }
|
.find-work-action { min-height:44px; }
|
||||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||||
.start-work-session { min-height:44px; }
|
.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 { 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[hidden] { display:none; }
|
||||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||||
|
|
|
||||||
|
|
@ -665,12 +665,30 @@
|
||||||
openRoutedWork(item, null, { replace:true });
|
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({
|
const workSession = createWorkSession({
|
||||||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
||||||
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
||||||
getMilestone: () => selectedWorkMilestone,
|
getMilestone: () => selectedWorkMilestone,
|
||||||
|
checkpoint: sessionCheckpoint,
|
||||||
|
checkpointEnabled: () => selectedWorkFilter === 'today',
|
||||||
onOpen: openWorkSessionItem,
|
onOpen: openWorkSessionItem,
|
||||||
onProgress: state => {
|
onProgress: state => {
|
||||||
|
updateWorkSessionActions();
|
||||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||||||
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
||||||
element.textContent = 'Item ' + state.index + ' of ' + state.total;
|
element.textContent = 'Item ' + state.index + ' of ' + state.total;
|
||||||
|
|
@ -686,6 +704,7 @@
|
||||||
closeOpenWorkSheets();
|
closeOpenWorkSheets();
|
||||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||||
|
updateWorkSessionActions();
|
||||||
qs('#start-work-session').focus();
|
qs('#start-work-session').focus();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1042,6 +1061,7 @@
|
||||||
updateWorkPaginationControls();
|
updateWorkPaginationControls();
|
||||||
renderMyWork();
|
renderMyWork();
|
||||||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||||||
|
updateWorkSessionActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeWorkStreams() {
|
function activeWorkStreams() {
|
||||||
|
|
@ -3576,6 +3596,18 @@
|
||||||
}
|
}
|
||||||
workSession.start();
|
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 =>
|
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
||||||
button.addEventListener('click', () => workSession.previous())
|
button.addEventListener('click', () => workSession.previous())
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@
|
||||||
<div class="my-work-actions">
|
<div class="my-work-actions">
|
||||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
<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="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="find-work-action" id="find-work" type="button">Find work</button>
|
||||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -472,10 +472,67 @@ function workIdentity(item) {
|
||||||
return [kind, repository, number, notification].join(':');
|
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 currentIdentity = '';
|
||||||
let currentIndex = -1;
|
let currentIndex = -1;
|
||||||
let running = false;
|
let running = false;
|
||||||
|
let durable = false;
|
||||||
|
|
||||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||||
const report = (items, index) => onProgress({
|
const report = (items, index) => onProgress({
|
||||||
|
|
@ -485,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||||
can_next: index < items.length - 1,
|
can_next: index < items.length - 1,
|
||||||
});
|
});
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
|
const clearCheckpoint = durable;
|
||||||
running = false;
|
running = false;
|
||||||
|
durable = false;
|
||||||
currentIdentity = '';
|
currentIdentity = '';
|
||||||
currentIndex = -1;
|
currentIndex = -1;
|
||||||
|
if (clearCheckpoint) checkpoint?.clear();
|
||||||
onFinish();
|
onFinish();
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -495,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||||
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);
|
||||||
report(items, index);
|
report(items, index);
|
||||||
onOpen(items[index]);
|
onOpen(items[index]);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -502,10 +563,23 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active: () => running,
|
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) {
|
start(item = null) {
|
||||||
const items = queue();
|
const items = queue();
|
||||||
if (!items.length) return finish();
|
if (!items.length) return finish();
|
||||||
running = true;
|
running = true;
|
||||||
|
durable = Boolean(checkpoint && 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);
|
||||||
},
|
},
|
||||||
|
|
@ -612,6 +686,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.filterMyWork = filterMyWork;
|
buildMyWork.filterMyWork = filterMyWork;
|
||||||
buildMyWork.milestoneLanes = milestoneLanes;
|
buildMyWork.milestoneLanes = milestoneLanes;
|
||||||
buildMyWork.createWorkSession = createWorkSession;
|
buildMyWork.createWorkSession = createWorkSession;
|
||||||
|
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||||
buildMyWork.workIdentity = workIdentity;
|
buildMyWork.workIdentity = workIdentity;
|
||||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" 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
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
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 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
|
||||||
|
|
|
||||||
|
|
@ -1428,6 +1428,157 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
||||||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
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
|
@pytest.mark.anyio
|
||||||
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
|
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
|
||||||
html = await dashboard()
|
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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
|
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
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
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,19 @@ async function dispatchNotificationClick(route) {{
|
||||||
return json.loads(completed.stdout)
|
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():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
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.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.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():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user