Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b1d8670f | ||
|
|
e5c5c8ec31 | ||
|
|
87e0d74414 | ||
|
|
f031a37e8d | ||
|
|
057bec7dc2 | ||
|
|
654ae8714e | ||
|
|
a0dd044995 | ||
|
|
c93a682dfb | ||
|
|
5675ca5d41 | ||
|
|
0767ccaa9d | ||
|
|
c6eabb5fc7 | ||
|
|
77fab85a66 | ||
|
|
dcf26c896c | ||
|
|
fba1f4238d | ||
|
|
9e301924b4 | ||
|
|
8edd542379 | ||
|
|
0fbe047e03 |
No files matched your search
@@ -0,0 +1,153 @@
|
||||
function createBatchFindWork({
|
||||
capacity,
|
||||
claim,
|
||||
queue,
|
||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||
persistEstimate = () => {},
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeJournal(value) {
|
||||
if (!storage || !String(owner() || '')) return;
|
||||
storage.setItem(journalKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearJournal() {
|
||||
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||
}
|
||||
|
||||
function result(status, selected, available, queued = [], failed = []) {
|
||||
return { status, selected, available, queued, failed };
|
||||
}
|
||||
|
||||
function key(item) {
|
||||
return String(item.repository || '') + '#' + String(item.number || '');
|
||||
}
|
||||
|
||||
async function processJournal(journal) {
|
||||
const selected = journal.items;
|
||||
const queued = [];
|
||||
const failed = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const entry = selected[index];
|
||||
const item = entry.item;
|
||||
if (entry.state === 'queued') {
|
||||
queued.push(key(item));
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const confirmed = entry.confirmed || await claim(item);
|
||||
if (!entry.confirmed) {
|
||||
entry.confirmed = confirmed;
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||
persistEstimate(confirmed, entry.estimate);
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
}
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
}
|
||||
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||
if (!failed.length) clearJournal();
|
||||
else writeJournal(journal);
|
||||
onProgress({ ...outcome, processed: selected.length });
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
if (selected.length > available) {
|
||||
const outcome = result('full', selected.length, available);
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
const budget = timeBudget() || {};
|
||||
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
|
||||
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
|
||||
const invalid = selected.map(key).filter(id =>
|
||||
!Number.isInteger(estimates[id]) || estimates[id] <= 0
|
||||
);
|
||||
const requested = selected.reduce((sum, item) => sum +
|
||||
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
|
||||
if (invalid.length) {
|
||||
const outcome = { ...result('estimates-required', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, invalid };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
if (requested > remaining) {
|
||||
const outcome = { ...result('over-budget', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (request) return request;
|
||||
const journal = readJournal();
|
||||
if (!journal) return Promise.resolve(null);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
button.hidden = !journal;
|
||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||
};
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const outcome = await resume();
|
||||
button.disabled = false;
|
||||
show();
|
||||
if (outcome) {
|
||||
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||
}
|
||||
});
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
@@ -241,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
|
||||
.selection-scope-actions button { min-height:44px; max-width:100%; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
@@ -451,9 +453,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.find-work-search input, .find-work-search button { min-height:44px; }
|
||||
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
|
||||
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
|
||||
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
|
||||
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
|
||||
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
|
||||
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
#clear-find-work-filters { min-height:44px; width:100%; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||
.find-work-select input { width:22px; height:22px; }
|
||||
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.find-work-batch-actions[hidden] { display:none; }
|
||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||
.find-work-batch-actions button { min-height:44px; }
|
||||
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-estimate-review[hidden] { display:none; }
|
||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.find-work-estimate-review input { min-height:44px; width:76px; }
|
||||
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-estimate-actions button { min-height:44px; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
@@ -545,6 +576,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
@@ -618,6 +650,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
@@ -627,6 +660,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
+202
-7
@@ -36,9 +36,7 @@
|
||||
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
||||
try {
|
||||
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
console.warn('Panel state save failed', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
const mobileTaskButtons = Object.fromEntries(
|
||||
@@ -75,12 +73,24 @@
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
openFallback: openMobileWorkFallback,
|
||||
});
|
||||
let mobileQueueCounts = {};
|
||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
getCounts: () => mobileQueueCounts,
|
||||
openFindWork: () => qs('#find-work').click(),
|
||||
});
|
||||
function showMobileQueueCompletion(completedName) {
|
||||
const next = mobileQueueLauncher.recommend();
|
||||
qs('#mobile-queue-heading').textContent = completedName + ' cleared';
|
||||
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
|
||||
const row = qs('[data-mobile-queue="' + next.name + '"]');
|
||||
row.setAttribute('data-recommended', 'true');
|
||||
qs('#mobile-queue-sheet').showModal();
|
||||
row.focus();
|
||||
}
|
||||
const mobileTaskDock = createMobileTaskDock({
|
||||
nav: qs('#mobile-task-dock'),
|
||||
sessionHud: qs('[data-mobile-today-hud]'),
|
||||
@@ -95,7 +105,8 @@
|
||||
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
|
||||
),
|
||||
queueBadge: qs('#mobile-queue-count'),
|
||||
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name),
|
||||
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() :
|
||||
name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name),
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: () => mobileWorkEntry.open(),
|
||||
@@ -584,7 +595,23 @@
|
||||
qs('#load-more-available').hidden = !pagination.has_more;
|
||||
},
|
||||
onStatus: message => { qs('#find-work-status').textContent = message; },
|
||||
onSelection: state => {
|
||||
qs('#batch-find-work-actions').hidden = !state.active;
|
||||
qs('#select-find-work').hidden = state.active;
|
||||
qs('#find-work-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
|
||||
qs('#claim-selected-work').disabled = state.count === 0;
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
},
|
||||
});
|
||||
let findWorkSearchTimer = null;
|
||||
|
||||
function updateFindWorkMatchStatus() {
|
||||
const query = findWorkController.query();
|
||||
qs('#find-work-match-status').textContent = query ?
|
||||
availablePagination.total + ' match' + (availablePagination.total === 1 ? '' : 'es') +
|
||||
' for “' + query + '”.' : '';
|
||||
}
|
||||
|
||||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||
@@ -868,7 +895,10 @@
|
||||
onStatus: message => {
|
||||
qs('#update-sheet-status').textContent = message;
|
||||
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
||||
if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message;
|
||||
if (message === 'Inbox cleared.') {
|
||||
qs('#my-work-action-status').textContent = message;
|
||||
showMobileQueueCompletion('Updates');
|
||||
}
|
||||
},
|
||||
onClose: () => closeUpdateSheet(false),
|
||||
});
|
||||
@@ -1375,6 +1405,68 @@
|
||||
},
|
||||
});
|
||||
|
||||
const batchFindWork = createBatchFindWork({
|
||||
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
|
||||
owner:()=>planningOwnerLogin,timeBudget:()=>{
|
||||
const plan = todayWork.planning();
|
||||
return {
|
||||
capacity_minutes: plan.capacity_minutes,
|
||||
planned_minutes: Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0),
|
||||
};
|
||||
},
|
||||
claim: item => findWorkController.claim(item),
|
||||
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
|
||||
persistEstimate: (confirmed, minutes) => {
|
||||
const plan = todayWork.planning();
|
||||
plan.estimates[todayWork.identity(confirmed)] = minutes;
|
||||
todayWork.replacePlanning(plan);
|
||||
todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates);
|
||||
todaySync.flush();
|
||||
},
|
||||
onProgress: progress => {
|
||||
if (progress.status === 'full') {
|
||||
qs('#find-work-status').textContent = 'Today has ' + progress.available +
|
||||
' open slot' + (progress.available === 1 ? '' : 's') + '. Reduce the selection before assigning.';
|
||||
} else if (progress.status === 'estimates-required') {
|
||||
qs('#find-work-estimate-summary').textContent = 'Add an estimate for every issue.';
|
||||
} else if (progress.status === 'over-budget') {
|
||||
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(progress.over_minutes) +
|
||||
' over Today’s remaining time. Reduce an estimate or selection.';
|
||||
} else if (progress.status === 'running') {
|
||||
qs('#find-work-status').textContent = 'Assigning and queueing ' + progress.processed +
|
||||
' of ' + progress.selected + '…';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function findWorkEstimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-find-work-estimate]')).map(input =>
|
||||
[input.dataset.findWorkEstimate, Number(input.value)]
|
||||
));
|
||||
}
|
||||
|
||||
function closeFindWorkEstimateReview() {
|
||||
qs('#find-work-estimate-review').hidden = true;
|
||||
qs('#batch-find-work-actions').hidden = !findWorkController.selection().active;
|
||||
}
|
||||
|
||||
function openFindWorkEstimateReview(items) {
|
||||
const plan = todayWork.planning();
|
||||
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0);
|
||||
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
|
||||
' remaining. Estimate selected work before assigning it.';
|
||||
qs('#find-work-estimate-list').innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
return '<div class="find-work-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
|
||||
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
|
||||
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label></div>';
|
||||
}).join('');
|
||||
qs('#batch-find-work-actions').hidden = true;
|
||||
qs('#find-work-estimate-review').hidden = false;
|
||||
qs('[data-find-work-estimate]')?.focus();
|
||||
}
|
||||
|
||||
let planTodayTrigger = null;
|
||||
function formatPlanMinutes(minutes) {
|
||||
if (!Number.isInteger(minutes)) return 'Not set';
|
||||
@@ -2082,6 +2174,7 @@
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileQueueCounts = counts;
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
@@ -2540,6 +2633,21 @@
|
||||
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
|
||||
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
|
||||
qs('#cancel-work-selection').hidden = !workSelectionState.active;
|
||||
const selectionScopeActions = qs('#selection-scope-actions');
|
||||
selectionScopeActions.hidden = !workSelectionState.active;
|
||||
qs('#select-matching-work').disabled = visible.length === 0 || workSelectionState.count >= workSelection.limit;
|
||||
qs('#clear-work-selection').disabled = workSelectionState.count === 0;
|
||||
qs('#select-matching-work').onclick = () => {
|
||||
const result = workSelection.selectMany(visible);
|
||||
qs('#my-work-action-status').textContent = result.status === 'limit' ?
|
||||
result.count + ' matches selected. Selection is capped at ' + result.limit + ' items.' :
|
||||
result.count + ' matches selected from the current queue and search.';
|
||||
};
|
||||
qs('#clear-work-selection').onclick = () => {
|
||||
workSelection.clear();
|
||||
qs('#my-work-action-status').textContent = 'Selection cleared. Choose new matches or individual work.';
|
||||
qs('#select-matching-work').focus();
|
||||
};
|
||||
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
|
||||
qs('#cancel-update-selection').hidden = !selection.active;
|
||||
qs('#update-selection-status').textContent = workSelectionState.active ?
|
||||
@@ -3005,23 +3113,34 @@
|
||||
|
||||
function renderAvailableIssues(items) {
|
||||
const list = qs('#find-work-list');
|
||||
const selection = findWorkController.selection();
|
||||
list.innerHTML = items.length ? items.map((item, index) => {
|
||||
const expanded = findWorkController.isPreviewed(item);
|
||||
const selected = findWorkController.isSelected(item);
|
||||
const detailId = 'find-work-detail-' + index;
|
||||
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
|
||||
'><div class="find-work-description markdown-content">' + renderMarkdown(item.body || 'No description provided.') + '</div>' +
|
||||
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
|
||||
'</div>';
|
||||
return '<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
|
||||
return '<article class="find-work-card' + (selected ? ' selected' : '') + '">' +
|
||||
(selection.active ? '<label class="find-work-select"><input type="checkbox" data-find-work-select="' + index +
|
||||
'"' + (selected ? ' checked' : '') + ' /> Select ' + escapeHtml(item.title || 'Untitled issue') + '</label>' : '') +
|
||||
'<div class="small">' + escapeHtml(item.repository) + '#' +
|
||||
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
|
||||
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
|
||||
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
|
||||
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
|
||||
detail + '<div class="find-work-claim-actions"><button type="button" data-claim-index="' + index +
|
||||
detail + '<div class="find-work-claim-actions"' + (selection.active ? ' hidden' : '') + '><button type="button" data-claim-index="' + index +
|
||||
'">Assign</button><button type="button" data-claim-queue-index="' + index +
|
||||
'">Queue Today</button><button type="button" data-claim-start-index="' + index +
|
||||
'">Start now</button></div></article>';
|
||||
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
|
||||
list.querySelectorAll('[data-find-work-select]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
const item = findWorkController.items()[Number(input.dataset.findWorkSelect)];
|
||||
if (item) findWorkController.toggleSelection(item);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('[data-preview-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
|
||||
@@ -4172,6 +4291,82 @@
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
||||
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||||
qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection());
|
||||
qs('#fill-find-work-today').addEventListener('click', () => {
|
||||
const remainingSlots = todayWork.limit - todayWork.read().length;
|
||||
const outcome = findWorkController.fillSelection(remainingSlots);
|
||||
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
|
||||
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
|
||||
if (outcome.selected && todayWork.planning().capacity_minutes !== null)
|
||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||
});
|
||||
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
|
||||
qs('#find-work-search').addEventListener('input', event => {
|
||||
const value = event.currentTarget.value;
|
||||
qs('#clear-find-work-search').hidden = !value;
|
||||
clearTimeout(findWorkSearchTimer);
|
||||
findWorkSearchTimer = setTimeout(async () => {
|
||||
qs('#find-work-status').textContent = value.trim() ?
|
||||
'Searching available issues…' : 'Loading available issues…';
|
||||
try {
|
||||
await findWorkController.search(value);
|
||||
updateFindWorkMatchStatus();
|
||||
qs('#find-work-status').textContent = availablePagination.total ?
|
||||
'Available work loaded.' : 'No matching unassigned issues.';
|
||||
} catch (error) {
|
||||
qs('#find-work-status').textContent = error.message + ' Retry search.';
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
qs('#clear-find-work-search').addEventListener('click', () => {
|
||||
const input = qs('#find-work-search');
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input', { bubbles:true }));
|
||||
input.focus();
|
||||
});
|
||||
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
|
||||
|
||||
qs('#claim-selected-work').addEventListener('click', async event => {
|
||||
if (todayWork.planning().capacity_minutes !== null) {
|
||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||
return;
|
||||
}
|
||||
event.currentTarget.disabled = true;
|
||||
const outcome = await batchFindWork.run(findWorkController.selectedItems());
|
||||
if (outcome.status === 'complete') {
|
||||
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
|
||||
const unavailable = outcome.failed.length - assignedOnly;
|
||||
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
|
||||
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
|
||||
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
|
||||
if (outcome.failed.length) {
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
event.currentTarget.disabled = false;
|
||||
} else {
|
||||
findWorkController.cancelSelection();
|
||||
}
|
||||
refreshMyWorkView();
|
||||
} else {
|
||||
event.currentTarget.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#cancel-find-work-estimates').addEventListener('click', closeFindWorkEstimateReview);
|
||||
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
|
||||
event.currentTarget.disabled = true;
|
||||
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
|
||||
event.currentTarget.disabled = false;
|
||||
if (outcome.status === 'estimates-required') {
|
||||
const first = outcome.invalid[0];
|
||||
document.querySelector('[data-find-work-estimate="' + CSS.escape(first) + '"]')?.focus();
|
||||
return;
|
||||
}
|
||||
if (outcome.status !== 'complete') return;
|
||||
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
|
||||
(outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.');
|
||||
closeFindWorkEstimateReview();
|
||||
if (!outcome.failed.length) findWorkController.cancelSelection();
|
||||
refreshMyWorkView();
|
||||
});
|
||||
qs('#load-more-available').addEventListener('click', async event => {
|
||||
event.currentTarget.disabled = true;
|
||||
qs('#find-work-status').textContent = 'Loading more available issues…';
|
||||
|
||||
+39
-1
@@ -169,6 +169,10 @@
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
|
||||
<button id="select-matching-work" type="button">Select all matches</button>
|
||||
<button id="clear-work-selection" type="button">Clear selection</button>
|
||||
</div>
|
||||
<form class="queue-finder" id="queue-finder" role="search">
|
||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||
<div class="queue-finder-row">
|
||||
@@ -528,12 +532,44 @@
|
||||
<section class="find-work-panel">
|
||||
<div class="find-work-header">
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<button id="close-find-work" type="button">Close</button>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -889,6 +925,7 @@
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -948,6 +985,7 @@
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<script src="static/later-picker.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/batch-find-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
@@ -21,5 +36,14 @@
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
return { open };
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
}
|
||||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
+115
-6
@@ -1,14 +1,26 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let loadGeneration = 0;
|
||||
let query = '';
|
||||
let filters = { repositories: [], labels: [] };
|
||||
let facetView;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
let selecting = false;
|
||||
const selected = new Map();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function emitSelection() {
|
||||
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
onSelection(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
if (result?.facets) onFacets({
|
||||
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
|
||||
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
|
||||
});
|
||||
if (result?.facets && typeof document !== 'undefined') {
|
||||
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
|
||||
String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
|
||||
next => { filters = next; loadPage(1, false); });
|
||||
facetView(result.facets);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
function loadPage(page, append, requestedQuery = query) {
|
||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||
const generation = ++loadGeneration;
|
||||
const params = new URLSearchParams();
|
||||
params.set('facets', 'true');
|
||||
if (requestedQuery) params.set('q', requestedQuery);
|
||||
filters.repositories.forEach(value => params.append('repository', value));
|
||||
filters.labels.forEach(value => params.append('label', value));
|
||||
const queryPart = params.toString() ? '&' + params.toString() : '';
|
||||
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (generation !== loadGeneration) return result;
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}).finally(() => { if (loadRequest === request) loadRequest = null; });
|
||||
loadRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
search(value) {
|
||||
query = String(value || '').trim();
|
||||
return loadPage(1, false, query);
|
||||
},
|
||||
query() {
|
||||
return query;
|
||||
},
|
||||
setFilters(next) {
|
||||
filters = {
|
||||
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
|
||||
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
|
||||
};
|
||||
return loadPage(1, false);
|
||||
},
|
||||
filters() {
|
||||
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
|
||||
},
|
||||
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
startSelection() {
|
||||
selecting = true;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
cancelSelection() {
|
||||
selecting = false;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
toggleSelection(item) {
|
||||
if (!selecting || !item) return emitSelection();
|
||||
const key = itemKey(item);
|
||||
if (selected.has(key)) selected.delete(key);
|
||||
else selected.set(key, item);
|
||||
return emitSelection();
|
||||
},
|
||||
fillSelection(limit) {
|
||||
limit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (!limit) return { selected: selected.size, added: 0, limit };
|
||||
selecting = true;
|
||||
const before = selected.size;
|
||||
available.some(item => {
|
||||
if (selected.size >= limit) return true;
|
||||
selected.set(itemKey(item), item);
|
||||
return false;
|
||||
});
|
||||
emitSelection();
|
||||
return { selected: selected.size, added: selected.size - before, limit };
|
||||
},
|
||||
isSelected(item) {
|
||||
return selected.has(itemKey(item));
|
||||
},
|
||||
selectedItems() {
|
||||
return Array.from(selected.values());
|
||||
},
|
||||
selection() {
|
||||
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
selected.delete(itemKey(item));
|
||||
emitSelection();
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function attachFindWorkFacets(root, escapeHtml, onChange) {
|
||||
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
|
||||
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
|
||||
root.querySelector('#clear-find-work-filters').onclick = () => {
|
||||
root.querySelectorAll('input').forEach(input => { input.checked = false; });
|
||||
onChange({ repositories:[], labels:[] });
|
||||
};
|
||||
return facets => {
|
||||
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
|
||||
const render = (id, choices, selected, name) => {
|
||||
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
|
||||
};
|
||||
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
|
||||
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
|
||||
const count = active.repositories.length + active.labels.length;
|
||||
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v97';
|
||||
const CACHE = 'stackchain-dashboard-shell-v98';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -55,6 +55,7 @@ const SHELL = [
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
|
||||
@@ -52,6 +52,30 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function selectMany(items) {
|
||||
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
|
||||
let added = 0;
|
||||
let capped = false;
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id || selected.has(id)) continue;
|
||||
if (selected.size >= maximum) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
selected.add(id);
|
||||
added += 1;
|
||||
}
|
||||
changed();
|
||||
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!active) return snapshot();
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
@@ -62,7 +86,7 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, toggle, retain, snapshot, limit: maximum };
|
||||
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
@@ -28,10 +28,10 @@ FEATURE_SOURCES = {
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
||||
+40
-4
@@ -2721,19 +2721,53 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
|
||||
raise
|
||||
|
||||
|
||||
def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
|
||||
def _available_issue_page(
|
||||
items: list[dict], page: int, limit: int = 50, query: str = "",
|
||||
repositories: list[str] | None = None, labels: list[str] | None = None,
|
||||
include_facets: bool = False,
|
||||
) -> dict:
|
||||
facets = {
|
||||
"repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold),
|
||||
"labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold),
|
||||
}
|
||||
repository_filter = {value.casefold() for value in (repositories or [])}
|
||||
label_filter = {value.casefold() for value in (labels or [])}
|
||||
if repository_filter:
|
||||
items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter]
|
||||
if label_filter:
|
||||
items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}]
|
||||
normalized_query = query.strip().casefold()
|
||||
if normalized_query:
|
||||
number_query = normalized_query.removeprefix("#")
|
||||
items = [
|
||||
item for item in items
|
||||
if normalized_query in str(item.get("repository") or "").casefold()
|
||||
or normalized_query in str(item.get("title") or "").casefold()
|
||||
or (number_query.isdigit() and number_query == str(item.get("number") or ""))
|
||||
]
|
||||
start = (page - 1) * limit
|
||||
page_items = items[start:start + limit]
|
||||
return {
|
||||
result = {
|
||||
"items": page_items,
|
||||
"page": page,
|
||||
"total": len(items),
|
||||
"has_more": start + len(page_items) < len(items),
|
||||
}
|
||||
if include_facets or repository_filter or label_filter:
|
||||
result["facets"] = facets
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/available-issues")
|
||||
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
|
||||
async def available_issues(
|
||||
page: int = Query(default=1, ge=1, le=100),
|
||||
q: str = Query(default="", max_length=100),
|
||||
repository: list[str] = Query(default=[], max_length=100),
|
||||
label: list[str] = Query(default=[], max_length=100),
|
||||
facets: bool = Query(default=False),
|
||||
) -> JSONResponse:
|
||||
if len(repository) > 10 or len(label) > 10:
|
||||
return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422)
|
||||
try:
|
||||
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
|
||||
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||
@@ -2744,7 +2778,9 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
||||
)
|
||||
result = _available_issue_page(items, page)
|
||||
result = _available_issue_page(
|
||||
items, page, query=q, repositories=repository, labels=label, include_facets=facets
|
||||
)
|
||||
if stale:
|
||||
result["stale"] = True
|
||||
if revalidating:
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_batch_preflights_today_capacity_before_claiming_any_issue():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status),
|
||||
}});
|
||||
flow.run([
|
||||
{{repository:'stackchain/dashboard',number:671}},
|
||||
{{repository:'stackchain/dashboard',number:672}},
|
||||
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "full",
|
||||
"selected": 2,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["progress:full"],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
claim:item=>{{
|
||||
calls.push('claim:'+item.number);
|
||||
return item.number===672 ? Promise.reject(new Error('already claimed')) :
|
||||
Promise.resolve({{...item,assignees:['timmy']}});
|
||||
}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
|
||||
}});
|
||||
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 3,
|
||||
"available": 3,
|
||||
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
|
||||
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": [
|
||||
"claim:671", "queue:671", "progress:running:1",
|
||||
"claim:672", "progress:running:2",
|
||||
"claim:673", "queue:673", "progress:running:3",
|
||||
"progress:complete:3",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
|
||||
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 1,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#671",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"calls": ["claim", "queue"],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
||||
const first=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
||||
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
||||
}});
|
||||
first.run([item]).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
||||
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
||||
}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult, resumed, calls, keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"firstResult": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#679",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"resumed": {
|
||||
"status": "complete", "selected": 1, "available": 1,
|
||||
"queued": ["stackchain/dashboard#679"], "failed": [],
|
||||
},
|
||||
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const key='stackchain.find-work-batch.v1.timmy';
|
||||
const values={{[key]:JSON.stringify({{
|
||||
owner:'timmy', available:2, items:[
|
||||
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
||||
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
||||
],
|
||||
}})}};
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>2, owner:()=> 'timmy',
|
||||
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 2,
|
||||
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["claim:679", "queue:679"],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
Promise.all([
|
||||
flow.run(items, {{'stackchain/dashboard#701':30}}),
|
||||
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
|
||||
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"results": [
|
||||
{
|
||||
"status": "estimates-required", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 30,
|
||||
"invalid": ["stackchain/dashboard#702"],
|
||||
},
|
||||
{
|
||||
"status": "over-budget", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 75, "over_minutes": 15,
|
||||
},
|
||||
],
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
|
||||
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
|
||||
queue:item=>'queued',
|
||||
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
|
||||
}});
|
||||
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
|
||||
'stackchain/dashboard#701':30,
|
||||
'stackchain/dashboard#702':45,
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 3,
|
||||
"queued": ["stackchain/dashboard#701"],
|
||||
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": ["701:30"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-estimate-review"' in html
|
||||
assert 'id="find-work-estimate-list"' in html
|
||||
assert 'id="confirm-find-work-estimates"' in html
|
||||
assert 'inputmode="numeric"' in dashboard
|
||||
assert "timeBudget:" in dashboard
|
||||
assert "persistEstimate:" in dashboard
|
||||
assert ".find-work-estimate-review" in css
|
||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pages={{
|
||||
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
|
||||
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
|
||||
}};
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
|
||||
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
return controller.loadMore();
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
return controller.claim(controller.items()[0]);
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
selecting:controller.selection().active,
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"selected": [672], "selecting": True}
|
||||
|
||||
|
||||
def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const states=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[
|
||||
{{id:1,repository:'stackchain/dashboard',number:701}},
|
||||
{{id:2,repository:'stackchain/dashboard',number:702}},
|
||||
{{id:3,repository:'stackchain/dashboard',number:703}},
|
||||
{{id:4,repository:'stackchain/dashboard',number:704}},
|
||||
],page:1,total:4,has_more:false}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
|
||||
onSelection:state=>states.push(state),
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
const outcome=controller.fillSelection(3);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
outcome,
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
lastState:states.at(-1),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 3, "added": 2, "limit": 3},
|
||||
"selected": [702, 701, 703],
|
||||
"lastState": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"stackchain/dashboard#702",
|
||||
"stackchain/dashboard#701",
|
||||
"stackchain/dashboard#703",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fill_today_with_no_capacity_does_not_change_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
const outcome=controller.fillSelection(0);
|
||||
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 0, "added": 0, "limit": 0},
|
||||
"selection": {"active": False, "count": 0, "ids": []},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/batch-find-work.js'" in worker
|
||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="resume-find-work-batch"' in html
|
||||
assert 'aria-describedby="find-work-status"' in html
|
||||
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
||||
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
||||
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pending=[];
|
||||
const rendered=[];
|
||||
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
|
||||
const controller=createFindWork({{
|
||||
fetchJson,
|
||||
onItems:items=>rendered.push(items.map(item=>item.number)),
|
||||
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
const first=controller.search('api');
|
||||
const second=controller.search('dashboard');
|
||||
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
|
||||
second.then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
const third=controller.search('worker');
|
||||
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
|
||||
return third;
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
|
||||
return first;
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls:pending.map(entry=>entry.path),
|
||||
rendered,
|
||||
items:controller.items().map(item=>item.number),
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"api/v1/available-issues?page=1&facets=true&q=api",
|
||||
"api/v1/available-issues?page=1&facets=true&q=dashboard",
|
||||
"api/v1/available-issues?page=1&facets=true&q=worker",
|
||||
],
|
||||
"rendered": [[673], [674]],
|
||||
"items": [674],
|
||||
"selected": [673, 674],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-search"' in html
|
||||
assert 'id="clear-find-work-search"' in html
|
||||
assert 'id="find-work-match-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "findWorkController.search(" in dashboard
|
||||
assert "#find-work-search" in dashboard
|
||||
assert ".find-work-search" in css
|
||||
assert ".find-work-search button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_facets_serialize_filters_and_preserve_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const calls=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
|
||||
}});
|
||||
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
|
||||
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
|
||||
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
|
||||
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
|
||||
"selected": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-filters"' in html
|
||||
assert 'id="find-work-repository-filters"' in html
|
||||
assert 'id="find-work-label-filters"' in html
|
||||
assert 'id="clear-find-work-filters"' in html
|
||||
assert 'aria-label="Filter available work"' in html
|
||||
assert "setFilters(next)" in PICK_WORK.read_text()
|
||||
assert ".find-work-filters" in css
|
||||
assert ".find-work-filter-option" in css and "min-height:44px" in css
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v97';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v98';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -215,6 +215,120 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": number,
|
||||
"title": "Routine API maintenance",
|
||||
}
|
||||
for number in range(1, 52)
|
||||
] + [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
|
||||
by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
|
||||
by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
|
||||
|
||||
expected = [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
|
||||
assert by_number.json() == by_repo.json()
|
||||
assert by_title.json() == by_repo.json()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
|
||||
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
|
||||
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("repository", "stackchain/api"), ("label", "critical")],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
|
||||
"page": 1,
|
||||
"total": 1,
|
||||
"has_more": False,
|
||||
"facets": {
|
||||
"repositories": ["stackchain/api", "stackchain/dashboard"],
|
||||
"labels": ["backend", "critical", "docs", "frontend"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
|
||||
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
|
||||
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
|
||||
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
|
||||
("label", "backend"), ("label", "frontend")],
|
||||
)
|
||||
|
||||
assert [item["number"] for item in response.json()["items"]] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid facets must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("label", f"label-{index}") for index in range(11)],
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid query must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -368,6 +368,50 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention: 2, today: 1, later: 4, draft: 3}};
|
||||
const launcher = createLauncher({{
|
||||
openToday: () => calls.push('today'),
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
getCounts: () => counts,
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const first = launcher.recommend();
|
||||
counts = {{attention: 0, today: 0, later: 4, draft: 3}};
|
||||
const opened = launcher.continueWork();
|
||||
counts = {{attention: 0, today: 0, later: 0, draft: 0}};
|
||||
const fallback = launcher.recommend();
|
||||
launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{first, opened, fallback, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"first": {"name": "attention", "count": 2, "label": "Start Attention (2)"},
|
||||
"opened": "opened",
|
||||
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["filter:later", "open:later", "find"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_accessible_mobile_queue_completion_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="find"' in html
|
||||
assert "mobileQueueLauncher.recommend()" in html
|
||||
assert "showMobileQueueCompletion('Updates')" in html
|
||||
assert "row.setAttribute('data-recommended', 'true')" in html
|
||||
assert "row.focus()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
html = await dashboard()
|
||||
@@ -388,7 +432,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
|
||||
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
@@ -434,7 +478,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
|
||||
+68
-1
@@ -29,6 +29,70 @@ TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
||||
|
||||
|
||||
def test_work_selection_selects_matching_items_in_order_with_an_explicit_cap():
|
||||
script = f"""
|
||||
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = createWorkSelection({{limit:3}});
|
||||
selection.start();
|
||||
selection.select({{kind:'issue', repository:'stackchain/api', number:1}});
|
||||
const result = selection.selectMany([
|
||||
{{kind:'issue', repository:'stackchain/api', number:1}},
|
||||
{{kind:'pull', repository:'stackchain/web', number:2}},
|
||||
{{kind:'issue', repository:'stackchain/app', number:3}},
|
||||
{{kind:'issue', repository:'stackchain/overflow', number:4}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{result, snapshot:selection.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"result": {"status": "limit", "added": 2, "count": 3, "limit": 3},
|
||||
"snapshot": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"issue:stackchain/api:1:",
|
||||
"pull:stackchain/web:2:",
|
||||
"issue:stackchain/app:3:",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_work_selection_clears_matches_without_leaving_selection_mode():
|
||||
script = f"""
|
||||
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = createWorkSelection();
|
||||
selection.start();
|
||||
selection.selectMany([
|
||||
{{kind:'issue', repository:'stackchain/api', number:1}},
|
||||
{{kind:'pull', repository:'stackchain/web', number:2}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(selection.clear()));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"active": True, "count": 0, "ids": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="select-matching-work"' in html
|
||||
assert 'id="clear-work-selection"' in html
|
||||
assert "workSelection.selectMany(visible)" in html
|
||||
assert "workSelection.clear()" in html
|
||||
assert "matches selected" in html
|
||||
assert ".selection-scope-actions button { min-height:44px;" in html
|
||||
assert "max-width:100%" in html
|
||||
|
||||
|
||||
def test_queue_finder_matches_repository_number_and_title_without_reordering():
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
@@ -3281,7 +3345,10 @@ controller.load().then(() => controller.loadMore()).then(() =>
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
|
||||
assert output["calls"] == [
|
||||
"api/v1/available-issues?page=1&facets=true",
|
||||
"api/v1/available-issues?page=2&facets=true",
|
||||
]
|
||||
assert [item["id"] for item in output["items"]] == [1, 2]
|
||||
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
||||
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -169,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -193,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -654,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -718,6 +718,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/batch-find-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/comment-actions.js",
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v97';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v98';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user