Skip blocked items while running the Today queue #438

Merged
timmy merged 1 commits from timmy/437-today-readiness into main 2026-08-10 00:40:45 +00:00
12 changed files with 426 additions and 45 deletions

View File

@ -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; }

View File

@ -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 = '<strong>' + escapeHtml(state.target.title || 'Untitled issue') +
'</strong><div class="small">' + escapeHtml(state.target.repository + '#' + state.target.number) + '</div>';
qs('#today-readiness-blockers').innerHTML = state.dependencies.map(blocker =>
'<a class="issue-blocker" href="' + escAttr(blocker.url || '#') + '" target="_blank" rel="noopener noreferrer"><strong>' +
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + '</span></a>'
).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', () =>

View File

@ -196,6 +196,24 @@
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel">
<div class="today-readiness-header">
<div><div class="small">Today readiness</div><h2 id="today-readiness-title">This item is blocked</h2></div>
<button id="today-readiness-close" type="button">Close</button>
</div>
<p id="today-readiness-summary" aria-live="polite"></p>
<div class="today-readiness-item" id="today-readiness-item"></div>
<div class="issue-blocker-list" id="today-readiness-blockers"></div>
<div class="today-readiness-actions">
<button id="today-readiness-next" type="button">Start next ready</button>
<button id="today-readiness-anyway" type="button">Work this item anyway</button>
<button id="today-readiness-retry" type="button">Retry status</button>
<button id="today-readiness-plan" type="button">Review Today plan</button>
</div>
</section>
</div>
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
<button id="back-to-plan" type="button">Back to plan</button>
<button id="add-plan-preview" type="button">Add to Today &amp; back</button>
@ -613,6 +631,7 @@
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-preview.js"></script>

View File

@ -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;
},
};
}

View File

@ -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',

View File

@ -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;

View File

@ -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;
};

View File

@ -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 },
};
};
});

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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 '<script src="static/today-readiness.js"></script>' 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