diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 8145d72..5f1f18a 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -100,6 +100,14 @@ textarea { resize: vertical; min-height: 120px; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
+.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
+.today-readiness-sheet[hidden] { display:none; }
+.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
+.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
+.today-readiness-header h2 { margin:.2rem 0; }
+.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
+.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
+.today-readiness-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-settings { width:100%; }
.work-settings > summary { display:none; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 1a80761..d844d88 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -728,25 +728,105 @@
qs('[data-work-filter="today"]').click();
}
- function continueTodaySession() {
- selectTodayWork();
- if (!workSession.reopen() && !workSession.reconcile()) {
- qs('#my-work-action-status').textContent = 'Current Today item is no longer available.';
+ let todayReadinessTrigger = null;
+ function inspectTodayDependencies(item) {
+ if (offlineWorkMode) {
+ const login = confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim();
+ const detail = offlineWorkStore.loadDetail(login, item);
+ return Promise.resolve({
+ available:detail?.dependencies_available === true,
+ dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
+ });
+ }
+ const [owner, repo] = String(item.repository || '').split('/');
+ return api('api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
+ '/issues/' + encodeURIComponent(item.number) + '/detail').then(detail => ({
+ available:detail?.dependencies_available === true,
+ dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
+ }));
+ }
+
+ function closeTodayReadiness(navigate = true) {
+ qs('#today-readiness-sheet').hidden = true;
+ document.body.classList.remove('task-overlay-open');
+ if (navigate) taskOverlayHistory.close();
+ else {
+ todayReadiness.cancel();
+ requestAnimationFrame(() => todayReadinessTrigger?.focus());
+ }
+ }
+
+ function renderTodayReadiness(state) {
+ if (!state?.target) return;
+ const unknown = state.status === 'unknown';
+ qs('#today-readiness-title').textContent = unknown ? 'Blocker status unavailable' : 'This item is blocked';
+ qs('#today-readiness-summary').textContent = unknown
+ ? 'Stackchain could not confirm whether this issue is ready. Retry or choose an explicit override.'
+ : state.dependencies.length + (state.dependencies.length === 1 ? ' open dependency must finish first.' : ' open dependencies must finish first.');
+ qs('#today-readiness-item').innerHTML = '' + escapeHtml(state.target.title || 'Untitled issue') +
+ '
' + escapeHtml(state.target.repository + '#' + state.target.number) + '
';
+ qs('#today-readiness-blockers').innerHTML = state.dependencies.map(blocker =>
+ '' +
+ escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
+ 'State: ' + escapeHtml(blocker.state || 'open') + ''
+ ).join('');
+ qs('#today-readiness-next').hidden = !state.nextReady;
+ if (state.nextReady) qs('#today-readiness-next').textContent = 'Start next ready · ' +
+ (state.nextReady.title || state.nextReady.key || 'work item');
+ qs('#today-readiness-retry').hidden = !unknown;
+ qs('#today-readiness-sheet').hidden = false;
+ document.body.classList.add('task-overlay-open');
+ requestAnimationFrame(() => (state.nextReady ? qs('#today-readiness-next') : qs('#today-readiness-anyway')).focus());
+ }
+
+ function performTodayTransition(action, item = null) {
+ qs('#today-readiness-sheet').hidden = true;
+ document.body.classList.remove('task-overlay-open');
+ if (taskOverlayHistory.current?.() === 'today-readiness') taskOverlayHistory.close();
+ const transitioned = ({
+ start: () => workSession.start(item),
+ resume: () => workSession.resume(item),
+ continue: () => workSession.reopen(item),
+ next: () => workSession.next(item),
+ complete: () => workSession.complete(item),
+ })[action]?.();
+ if (!transitioned && action !== 'next' && action !== 'complete') {
+ qs('#my-work-action-status').textContent = 'Saved Today item is no longer available.';
updateWorkSessionActions();
}
}
+ const todayReadiness = createTodayReadiness({
+ inspect:inspectTodayDependencies,
+ onOpen:performTodayTransition,
+ onGate:state => {
+ todayReadinessTrigger = document.activeElement;
+ renderTodayReadiness(state);
+ taskOverlayHistory.open('today-readiness');
+ },
+ });
+
+ function runTodayTransition(action) {
+ selectTodayWork();
+ const target = workSession.target(action);
+ if (!target) {
+ performTodayTransition(action);
+ return Promise.resolve('empty');
+ }
+ qs('#my-work-action-status').textContent = 'Checking Today readiness…';
+ return todayReadiness.run(action, workSession.items(), target);
+ }
+
+ function continueTodaySession() {
+ return runTodayTransition('continue');
+ }
+
function resumeTodaySession() {
- selectTodayWork();
- if (!workSession.resume()) {
- qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
- updateWorkSessionActions();
- }
+ return runTodayTransition('resume');
}
function startTodaySession() {
- selectTodayWork();
- workSession.start();
+ return runTodayTransition('start');
}
const createAndStart = createCreateAndStart({
@@ -781,6 +861,7 @@
refresh: () => refreshMyWorkView({ reconcileSession:false }),
warm: warmTodayOffline,
announce: message => { qs('#my-work-action-status').textContent = message; },
+ advance: () => runTodayTransition('complete'),
});
function setCommentNextVisibility(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
@@ -943,7 +1024,7 @@
save: saveTodayPlan,
start: () => {
qs('[data-work-filter="today"]').click();
- workSession.start();
+ startTodaySession();
},
});
@@ -2996,10 +3077,12 @@
addPlanPreviewOverride = false;
}
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
+ if (previous === 'today-readiness' && kind !== 'today-readiness') closeTodayReadiness(false);
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
+ if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
},
});
taskOverlayHistory.start();
@@ -3630,9 +3713,11 @@
);
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
- if (workSession.active()) workSession.complete();
- if (continuingSession && workSession.active()) {
+ const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
+ if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
+ } else if (transitionResult === 'gated') {
+ qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = closing.key + ' closed.';
}
@@ -3722,9 +3807,11 @@
);
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
- if (workSession.active()) workSession.complete();
- if (continuingSession && workSession.active()) {
+ const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
+ if (transitionResult === 'opened') {
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
+ } else if (transitionResult === 'gated') {
+ qs('#my-work-action-status').textContent = merging.key + ' merged. Choose the next ready Today item.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = merging.key + ' merged.';
}
@@ -3891,7 +3978,7 @@
qs('#continue-review-to-merge').focus();
} else {
await load();
- if (workSession.active()) workSession.complete();
+ if (workSession.active()) await runTodayTransition('complete');
else button.focus();
}
} catch (error) {
@@ -4083,6 +4170,17 @@
qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday();
});
+ qs('#today-readiness-close').addEventListener('click', () => closeTodayReadiness());
+ qs('#today-readiness-sheet').addEventListener('click', event => {
+ if (event.target === qs('#today-readiness-sheet')) closeTodayReadiness();
+ });
+ qs('#today-readiness-next').addEventListener('click', () => todayReadiness.startNextReady());
+ qs('#today-readiness-anyway').addEventListener('click', () => todayReadiness.workAnyway());
+ qs('#today-readiness-retry').addEventListener('click', () => todayReadiness.retry());
+ qs('#today-readiness-plan').addEventListener('click', () => {
+ closeTodayReadiness();
+ openPlanToday(qs('#plan-today'));
+ });
qs('#save-today-plan').addEventListener('click', () => {
const result = planToday.commit();
if (result === 'saved') taskOverlayHistory.leave();
@@ -4099,15 +4197,10 @@
qs('#my-work-action-status').textContent = 'No visible work to start.';
return;
}
- 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();
- }
+ if (selectedWorkFilter === 'today') runTodayTransition('start');
+ else workSession.start();
});
+ qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
qs('#end-today-session').addEventListener('click', () => {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
@@ -4117,7 +4210,7 @@
button.addEventListener('click', () => workSession.previous())
);
document.querySelectorAll('[data-work-session-next]').forEach(button =>
- button.addEventListener('click', () => workSession.next())
+ button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
);
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
button.addEventListener('click', () =>
diff --git a/frontend/index.html b/frontend/index.html
index 09d0535..813ac2c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -196,6 +196,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -613,6 +631,7 @@
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 884ef1a..0c0ed35 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -566,24 +566,28 @@ function createWorkSession({
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
- reopen() {
+ reopen(requested = null) {
if (!running) return false;
const items = queue();
- const index = items.findIndex(item => workIdentity(item) === currentIdentity);
+ const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
+ const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
if (index < 0) return false;
+ currentIdentity = requestedIdentity;
currentIndex = index;
+ if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
- resume() {
+ resume(requested = null) {
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);
+ const exact = items.findIndex(item => workIdentity(item) ===
+ (requested ? workIdentity(requested) : saved.identity));
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
@@ -609,21 +613,48 @@ function createWorkSession({
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
- next() {
+ next(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
+ if (requested) {
+ const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
+ return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
+ }
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
- complete() {
+ complete(requested = null) {
if (!running) return false;
const items = queue();
+ if (requested) {
+ const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
+ return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
+ }
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
+ items: () => queue().slice(),
+ target(action) {
+ const items = queue();
+ if (!items.length) return null;
+ if (action === 'start') return items[0];
+ if (action === 'resume') {
+ const saved = checkpoint?.read();
+ if (!saved) return null;
+ const exact = items.findIndex(item => workIdentity(item) === saved.identity);
+ return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
+ }
+ const current = items.findIndex(item => workIdentity(item) === currentIdentity);
+ if (action === 'continue') return current >= 0 ? items[current] : null;
+ if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
+ if (action === 'complete') {
+ return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
+ }
+ return null;
+ },
};
}
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index b338b74..c53e623 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -29,6 +29,7 @@ const SHELL = [
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-completion.js',
+ BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js',
diff --git a/frontend/task-overlay-history.js b/frontend/task-overlay-history.js
index de1d5b2..f82d7de 100644
--- a/frontend/task-overlay-history.js
+++ b/frontend/task-overlay-history.js
@@ -5,7 +5,7 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
- const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
+ const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
diff --git a/frontend/today-completion.js b/frontend/today-completion.js
index 2494a0c..7732da3 100644
--- a/frontend/today-completion.js
+++ b/frontend/today-completion.js
@@ -1,4 +1,4 @@
-function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce }) {
+function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
@@ -8,7 +8,7 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
todaySync.flush();
refresh();
warm();
- workSession.complete();
+ (advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
return true;
};
diff --git a/frontend/today-readiness.js b/frontend/today-readiness.js
new file mode 100644
index 0000000..dea83a2
--- /dev/null
+++ b/frontend/today-readiness.js
@@ -0,0 +1,78 @@
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.createTodayReadiness = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {} }) {
+ let pending = null;
+ let generation = 0;
+
+ async function classify(item) {
+ if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
+ try {
+ const detail = await inspect(item);
+ if (detail?.available !== true) return { status:'unknown', dependencies:[] };
+ const dependencies = Array.isArray(detail.dependencies)
+ ? detail.dependencies.filter(dependency => dependency?.state === 'open')
+ : [];
+ return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
+ } catch (_error) {
+ return { status:'unknown', dependencies:[] };
+ }
+ }
+
+ async function run(action, items, target = null) {
+ const request = ++generation;
+ const ordered = Array.isArray(items) ? items.slice() : [];
+ const preferred = target || ordered[0] || null;
+ if (!preferred) return 'empty';
+ const preferredIndex = ordered.indexOf(preferred);
+ const candidates = preferredIndex >= 0
+ ? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
+ : [preferred].concat(ordered);
+ const states = [];
+ for (const item of candidates) states.push({ item, ...(await classify(item)) });
+ if (request !== generation) return 'superseded';
+ const targetState = states[0];
+ if (targetState.status === 'ready') {
+ pending = null;
+ onOpen(action, preferred);
+ return 'opened';
+ }
+ const nextReadyState = states.slice(1).find(state => state.status === 'ready');
+ pending = {
+ action,
+ items:ordered,
+ target:preferred,
+ status:targetState.status,
+ dependencies:targetState.dependencies,
+ nextReady:nextReadyState?.item || null,
+ };
+ onGate({ ...pending, items:pending.items.slice() });
+ return 'gated';
+ }
+
+ function open(item) {
+ if (!pending || !item) return false;
+ const action = pending.action;
+ pending = null;
+ generation += 1;
+ onOpen(action, item);
+ return true;
+ }
+
+ return {
+ run,
+ retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
+ startNextReady: () => open(pending?.nextReady),
+ workAnyway: () => open(pending?.target),
+ cancel() {
+ generation += 1;
+ pending = null;
+ },
+ snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
+ };
+ };
+});
diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py
index 87a4bf3..7eb7d58 100644
--- a/tests/test_mobile_task_dock.py
+++ b/tests/test_mobile_task_dock.py
@@ -180,7 +180,8 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
assert "startToday: startTodaySession" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
- assert "workSession.reopen()" in html
+ assert "workSession.reopen(item)" in html
+ assert "runTodayTransition('continue')" in html
@pytest.mark.anyio
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index db29a77..e0b7cb8 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -1648,7 +1648,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
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.resume(item)" in html
+ assert "runTodayTransition('resume')" in html
assert "workSession.end()" in html
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
@@ -1662,7 +1663,7 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
assert "qs('#start-work-session').addEventListener('click'" in html
assert "document.querySelectorAll('[data-work-session-previous]')" in html
assert "document.querySelectorAll('[data-work-session-next]')" in html
- assert 'workSession.complete();' in html
+ assert "runTodayTransition('complete')" in html
assert 'workSession.reconcile();' in html
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
assert opener in html
@@ -1678,12 +1679,12 @@ async def test_closing_issue_advances_active_session_once_and_exposes_close_and_
"qs('#close-pull-sheet').addEventListener", 1
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
- assert "if (workSession.active()) workSession.complete();" in close_handler
+ assert "workSession.active() ? await runTodayTransition('complete') : null" in close_handler
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
- "if (workSession.active()) workSession.complete();"
+ "await runTodayTransition('complete')"
)
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
- "if (workSession.active()) workSession.complete();"
+ "await runTodayTransition('complete')"
)
@@ -1697,13 +1698,13 @@ async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_n
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
assert "const continuingSession = workSession.active();" in merge_handler
- assert "if (workSession.active()) workSession.complete();" in merge_handler
+ assert "workSession.active() ? await runTodayTransition('complete') : null" in merge_handler
assert "merged. Next work item opened." in merge_handler
assert merge_handler.index("await pullController.merge") < merge_handler.index(
- "if (workSession.active()) workSession.complete();"
+ "await runTodayTransition('complete')"
)
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
- "if (workSession.active()) workSession.complete();"
+ "await runTodayTransition('complete')"
)
@@ -1730,7 +1731,7 @@ async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
assert "lastMyWork = lastMyWork.filter" in success_path
- assert "workSession.complete()" in success_path
+ assert "runTodayTransition('complete')" in success_path
assert "lastMyWork = lastMyWork.filter" not in failure_path
assert "workSession.complete()" not in failure_path
assert "The pull request remains in My Work; refresh and retry." in failure_path
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 42c76b7..f4f6cd5 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -397,6 +397,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-completion.js",
+ "/dashboard/static/today-readiness.js",
"/dashboard/static/comment-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js",
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
new file mode 100644
index 0000000..a7c6cb6
--- /dev/null
+++ b/tests/test_today_readiness.py
@@ -0,0 +1,148 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from tests.dashboard_bundle import dashboard
+
+
+ROOT = Path(__file__).parents[1]
+READINESS = ROOT / "frontend" / "today-readiness.js"
+SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
+
+
+def run_node(script):
+ return subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout
+
+
+def test_readiness_opens_ready_target_without_showing_a_gate():
+ script = f"""
+const createTodayReadiness = require({json.dumps(str(READINESS))});
+const calls = [];
+const item = (number, kind='issue') => ({{kind, repository:'stackchain/dashboard', number, title:'Item ' + number}});
+const readiness = createTodayReadiness({{
+ inspect: async candidate => ({{available:true, dependencies:[]}}),
+ onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
+ onGate: state => calls.push(['gate', state.status]),
+}});
+readiness.run('start', [item(1), item(2)]).then(result =>
+ process.stdout.write(JSON.stringify({{result, calls}}))
+);
+"""
+ assert json.loads(run_node(script)) == {
+ "result": "opened",
+ "calls": [["open", "start", 1]],
+ }
+
+
+def test_readiness_gates_blocked_target_and_offers_earliest_ready_without_reordering():
+ script = f"""
+const createTodayReadiness = require({json.dumps(str(READINESS))});
+const calls = [];
+const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number, title:'Issue ' + number}}));
+const readiness = createTodayReadiness({{
+ inspect: async candidate => candidate.number === 1
+ ? {{available:true, dependencies:[{{repository:'r', number:99, title:'Prerequisite', state:'open', url:'https://forge.example/r/issues/99'}}]}}
+ : {{available:true, dependencies:[]}},
+ onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
+ onGate: state => calls.push(['gate', state.status, state.target.number, state.nextReady.number, state.items.map(item => item.number)]),
+}});
+readiness.run('resume', items, items[0]).then(async result => {{
+ const opened = readiness.startNextReady();
+ process.stdout.write(JSON.stringify({{result, opened, calls, order:items.map(item => item.number)}}));
+}});
+"""
+ assert json.loads(run_node(script)) == {
+ "result": "gated",
+ "opened": True,
+ "calls": [
+ ["gate", "blocked", 1, 2, [1, 2, 3]],
+ ["open", "resume", 2],
+ ],
+ "order": [1, 2, 3],
+ }
+
+
+def test_readiness_never_calls_unknown_ready_and_supports_retry_or_explicit_override():
+ script = f"""
+const createTodayReadiness = require({json.dumps(str(READINESS))});
+const calls = [];
+const target = {{kind:'issue', repository:'r', number:1}};
+let available = false;
+const readiness = createTodayReadiness({{
+ inspect: async () => available ? {{available:true, dependencies:[]}} : {{available:false, dependencies:[]}},
+ onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
+ onGate: state => calls.push(['gate', state.status]),
+}});
+readiness.run('next', [target], target).then(async result => {{
+ available = true;
+ const retried = await readiness.retry();
+ available = false;
+ await readiness.run('continue', [target], target);
+ const overridden = readiness.workAnyway();
+ process.stdout.write(JSON.stringify({{result, retried, overridden, calls}}));
+}});
+"""
+ assert json.loads(run_node(script)) == {
+ "result": "gated",
+ "retried": "opened",
+ "overridden": True,
+ "calls": [
+ ["gate", "unknown"],
+ ["open", "next", 1],
+ ["gate", "unknown"],
+ ["open", "continue", 1],
+ ],
+ }
+
+
+def test_readiness_treats_non_issue_work_as_runnable_without_dependency_io():
+ script = f"""
+const createTodayReadiness = require({json.dumps(str(READINESS))});
+let inspections = 0;
+const pull = {{kind:'pull', repository:'r', number:7}};
+const readiness = createTodayReadiness({{
+ inspect: async () => {{ inspections += 1; throw new Error('not expected'); }},
+ onOpen: () => undefined,
+ onGate: () => undefined,
+}});
+readiness.run('start', [pull]).then(result =>
+ process.stdout.write(JSON.stringify({{result, inspections}}))
+);
+"""
+ assert json.loads(run_node(script)) == {"result": "opened", "inspections": 0}
+
+
+@pytest.mark.anyio
+async def test_dashboard_exposes_mobile_today_readiness_gate_and_runtime():
+ html = await dashboard()
+
+ assert '' in html
+ assert 'id="today-readiness-sheet"' in html
+ assert 'id="today-readiness-next"' in html
+ assert 'id="today-readiness-anyway"' in html
+ assert 'id="today-readiness-retry"' in html
+ assert 'id="today-readiness-plan"' in html
+ assert "createTodayReadiness({" in html
+ assert "runTodayTransition('start'" in html
+ assert "runTodayTransition('resume'" in html
+ assert "runTodayTransition('continue'" in html
+ assert "runTodayTransition('next'" in html
+ assert "runTodayTransition('complete'" in html
+
+
+def test_readiness_runtime_is_available_in_offline_shell():
+ assert "BASE + 'static/today-readiness.js'" in SERVICE_WORKER.read_text()
+
+
+def test_mobile_readiness_gate_has_touch_safe_wrapping_actions():
+ css = (ROOT / "frontend" / "dashboard.css").read_text()
+
+ assert ".today-readiness-actions button" in css
+ assert "min-height:44px" in css
+ assert ".today-readiness-item" in css
+ assert "overflow-wrap:anywhere" in css
+ assert "env(safe-area-inset-bottom)" in css