Skip blocked items while running the Today queue #438
|
|
@ -100,6 +100,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.plan-preview-actions button { min-height:44px; }
|
.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 { 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%; }
|
.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; }
|
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||||
.work-settings { width:100%; }
|
.work-settings { width:100%; }
|
||||||
.work-settings > summary { display:none; }
|
.work-settings > summary { display:none; }
|
||||||
|
|
|
||||||
|
|
@ -728,25 +728,105 @@
|
||||||
qs('[data-work-filter="today"]').click();
|
qs('[data-work-filter="today"]').click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function continueTodaySession() {
|
let todayReadinessTrigger = null;
|
||||||
selectTodayWork();
|
function inspectTodayDependencies(item) {
|
||||||
if (!workSession.reopen() && !workSession.reconcile()) {
|
if (offlineWorkMode) {
|
||||||
qs('#my-work-action-status').textContent = 'Current Today item is no longer available.';
|
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();
|
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() {
|
function resumeTodaySession() {
|
||||||
selectTodayWork();
|
return runTodayTransition('resume');
|
||||||
if (!workSession.resume()) {
|
|
||||||
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
|
|
||||||
updateWorkSessionActions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTodaySession() {
|
function startTodaySession() {
|
||||||
selectTodayWork();
|
return runTodayTransition('start');
|
||||||
workSession.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createAndStart = createCreateAndStart({
|
const createAndStart = createCreateAndStart({
|
||||||
|
|
@ -781,6 +861,7 @@
|
||||||
refresh: () => refreshMyWorkView({ reconcileSession:false }),
|
refresh: () => refreshMyWorkView({ reconcileSession:false }),
|
||||||
warm: warmTodayOffline,
|
warm: warmTodayOffline,
|
||||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||||
|
advance: () => runTodayTransition('complete'),
|
||||||
});
|
});
|
||||||
function setCommentNextVisibility(kind) {
|
function setCommentNextVisibility(kind) {
|
||||||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||||
|
|
@ -943,7 +1024,7 @@
|
||||||
save: saveTodayPlan,
|
save: saveTodayPlan,
|
||||||
start: () => {
|
start: () => {
|
||||||
qs('[data-work-filter="today"]').click();
|
qs('[data-work-filter="today"]').click();
|
||||||
workSession.start();
|
startTodaySession();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2996,10 +3077,12 @@
|
||||||
addPlanPreviewOverride = false;
|
addPlanPreviewOverride = false;
|
||||||
}
|
}
|
||||||
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(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 === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||||||
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
||||||
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(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 === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
||||||
|
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
taskOverlayHistory.start();
|
taskOverlayHistory.start();
|
||||||
|
|
@ -3630,9 +3713,11 @@
|
||||||
);
|
);
|
||||||
refreshMyWorkView({ reconcileSession:false });
|
refreshMyWorkView({ reconcileSession:false });
|
||||||
const continuingSession = workSession.active();
|
const continuingSession = workSession.active();
|
||||||
if (workSession.active()) workSession.complete();
|
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
|
||||||
if (continuingSession && workSession.active()) {
|
if (transitionResult === 'opened') {
|
||||||
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item 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) {
|
} else if (!continuingSession) {
|
||||||
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
||||||
}
|
}
|
||||||
|
|
@ -3722,9 +3807,11 @@
|
||||||
);
|
);
|
||||||
refreshMyWorkView({ reconcileSession:false });
|
refreshMyWorkView({ reconcileSession:false });
|
||||||
const continuingSession = workSession.active();
|
const continuingSession = workSession.active();
|
||||||
if (workSession.active()) workSession.complete();
|
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
|
||||||
if (continuingSession && workSession.active()) {
|
if (transitionResult === 'opened') {
|
||||||
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item 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) {
|
} else if (!continuingSession) {
|
||||||
qs('#my-work-action-status').textContent = merging.key + ' merged.';
|
qs('#my-work-action-status').textContent = merging.key + ' merged.';
|
||||||
}
|
}
|
||||||
|
|
@ -3891,7 +3978,7 @@
|
||||||
qs('#continue-review-to-merge').focus();
|
qs('#continue-review-to-merge').focus();
|
||||||
} else {
|
} else {
|
||||||
await load();
|
await load();
|
||||||
if (workSession.active()) workSession.complete();
|
if (workSession.active()) await runTodayTransition('complete');
|
||||||
else button.focus();
|
else button.focus();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -4083,6 +4170,17 @@
|
||||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
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', () => {
|
qs('#save-today-plan').addEventListener('click', () => {
|
||||||
const result = planToday.commit();
|
const result = planToday.commit();
|
||||||
if (result === 'saved') taskOverlayHistory.leave();
|
if (result === 'saved') taskOverlayHistory.leave();
|
||||||
|
|
@ -4099,15 +4197,10 @@
|
||||||
qs('#my-work-action-status').textContent = 'No visible work to start.';
|
qs('#my-work-action-status').textContent = 'No visible work to start.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
workSession.start();
|
if (selectedWorkFilter === 'today') runTodayTransition('start');
|
||||||
});
|
else 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('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
||||||
qs('#end-today-session').addEventListener('click', () => {
|
qs('#end-today-session').addEventListener('click', () => {
|
||||||
workSession.end();
|
workSession.end();
|
||||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||||
|
|
@ -4117,7 +4210,7 @@
|
||||||
button.addEventListener('click', () => workSession.previous())
|
button.addEventListener('click', () => workSession.previous())
|
||||||
);
|
);
|
||||||
document.querySelectorAll('[data-work-session-next]').forEach(button =>
|
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 =>
|
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
|
||||||
button.addEventListener('click', () =>
|
button.addEventListener('click', () =>
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,24 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
|
||||||
<button id="back-to-plan" type="button">Back to plan</button>
|
<button id="back-to-plan" type="button">Back to plan</button>
|
||||||
<button id="add-plan-preview" type="button">Add to Today & back</button>
|
<button id="add-plan-preview" type="button">Add to Today & back</button>
|
||||||
|
|
@ -613,6 +631,7 @@
|
||||||
<script src="static/card-planning.js"></script>
|
<script src="static/card-planning.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/today-completion.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/comment-next.js"></script>
|
||||||
<script src="static/plan-today.js"></script>
|
<script src="static/plan-today.js"></script>
|
||||||
<script src="static/plan-today-preview.js"></script>
|
<script src="static/plan-today-preview.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -566,24 +566,28 @@ function createWorkSession({
|
||||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||||
end: () => finish(),
|
end: () => finish(),
|
||||||
resumable: () => Boolean(checkpoint?.read()),
|
resumable: () => Boolean(checkpoint?.read()),
|
||||||
reopen() {
|
reopen(requested = null) {
|
||||||
if (!running) return false;
|
if (!running) return false;
|
||||||
const items = queue();
|
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;
|
if (index < 0) return false;
|
||||||
|
currentIdentity = requestedIdentity;
|
||||||
currentIndex = index;
|
currentIndex = index;
|
||||||
|
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||||
report(items, index);
|
report(items, index);
|
||||||
onOpen(items[index]);
|
onOpen(items[index]);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
resume() {
|
resume(requested = null) {
|
||||||
const saved = checkpoint?.read();
|
const saved = checkpoint?.read();
|
||||||
if (!saved) return false;
|
if (!saved) return false;
|
||||||
durable = true;
|
durable = true;
|
||||||
const items = queue();
|
const items = queue();
|
||||||
if (!items.length) return finish();
|
if (!items.length) return finish();
|
||||||
running = true;
|
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));
|
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
||||||
},
|
},
|
||||||
start(item = null) {
|
start(item = null) {
|
||||||
|
|
@ -609,21 +613,48 @@ function createWorkSession({
|
||||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||||
return index > 0 ? openAt(items, index - 1) : false;
|
return index > 0 ? openAt(items, index - 1) : false;
|
||||||
},
|
},
|
||||||
next() {
|
next(requested = null) {
|
||||||
if (!running) return false;
|
if (!running) return false;
|
||||||
const items = queue();
|
const items = queue();
|
||||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
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();
|
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
|
||||||
},
|
},
|
||||||
complete() {
|
complete(requested = null) {
|
||||||
if (!running) return false;
|
if (!running) return false;
|
||||||
const items = queue();
|
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);
|
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||||
if (stillPresent >= 0) {
|
if (stillPresent >= 0) {
|
||||||
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
|
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
|
||||||
}
|
}
|
||||||
return items.length ? openAt(items, Math.min(currentIndex, items.length - 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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const SHELL = [
|
||||||
BASE + 'static/card-planning.js',
|
BASE + 'static/card-planning.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/today-completion.js',
|
BASE + 'static/today-completion.js',
|
||||||
|
BASE + 'static/today-readiness.js',
|
||||||
BASE + 'static/comment-next.js',
|
BASE + 'static/comment-next.js',
|
||||||
BASE + 'static/plan-today.js',
|
BASE + 'static/plan-today.js',
|
||||||
BASE + 'static/plan-today-preview.js',
|
BASE + 'static/plan-today-preview.js',
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
'use strict';
|
'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 }) {
|
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||||
|
|
|
||||||
|
|
@ -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 = {}) {
|
return function completeTodayItem(item, options = {}) {
|
||||||
if (!item || !todayWork.remove(item)) {
|
if (!item || !todayWork.remove(item)) {
|
||||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
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();
|
todaySync.flush();
|
||||||
refresh();
|
refresh();
|
||||||
warm();
|
warm();
|
||||||
workSession.complete();
|
(advance || (() => workSession.complete()))();
|
||||||
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
78
frontend/today-readiness.js
Normal file
78
frontend/today-readiness.js
Normal 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 },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -180,7 +180,8 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||||
assert "startToday: startTodaySession" in html
|
assert "startToday: startTodaySession" in html
|
||||||
assert "work: () => mobileWorkEntry.open()" in html
|
assert "work: () => mobileWorkEntry.open()" in html
|
||||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" 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
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -1648,7 +1648,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||||
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
||||||
assert "qs('#resume-today-session').addEventListener('click'" in html
|
assert "qs('#resume-today-session').addEventListener('click'" in html
|
||||||
assert "qs('#end-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 "workSession.end()" in html
|
||||||
assert '.resume-today-session, .end-today-session { min-height:44px;' 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 "qs('#start-work-session').addEventListener('click'" in html
|
||||||
assert "document.querySelectorAll('[data-work-session-previous]')" in html
|
assert "document.querySelectorAll('[data-work-session-previous]')" in html
|
||||||
assert "document.querySelectorAll('[data-work-session-next]')" 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
|
assert 'workSession.reconcile();' in html
|
||||||
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
|
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
|
||||||
assert opener in html
|
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
|
"qs('#close-pull-sheet').addEventListener", 1
|
||||||
)[0]
|
)[0]
|
||||||
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
|
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(
|
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(
|
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]
|
)[0]
|
||||||
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
|
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
|
||||||
assert "const continuingSession = workSession.active();" 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 "merged. Next work item opened." in merge_handler
|
||||||
assert merge_handler.index("await pullController.merge") < merge_handler.index(
|
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(
|
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)
|
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
|
||||||
|
|
||||||
assert "lastMyWork = lastMyWork.filter" in success_path
|
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 "lastMyWork = lastMyWork.filter" not in failure_path
|
||||||
assert "workSession.complete()" 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
|
assert "The pull request remains in My Work; refresh and retry." in failure_path
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/card-planning.js",
|
"/dashboard/static/card-planning.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/today-completion.js",
|
"/dashboard/static/today-completion.js",
|
||||||
|
"/dashboard/static/today-readiness.js",
|
||||||
"/dashboard/static/comment-next.js",
|
"/dashboard/static/comment-next.js",
|
||||||
"/dashboard/static/plan-today.js",
|
"/dashboard/static/plan-today.js",
|
||||||
"/dashboard/static/plan-today-preview.js",
|
"/dashboard/static/plan-today-preview.js",
|
||||||
|
|
|
||||||
148
tests/test_today_readiness.py
Normal file
148
tests/test_today_readiness.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user