feat: skip blocked Today work (#437)
This commit is contained in:
parent
7b3b7dadb9
commit
8989e93c2c
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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', () =>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
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 "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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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