Compare commits

...
Author SHA1 Message Date
timmy c6eabb5fc7 Merge pull request 'Search the full Find Work catalog' (#674) from timmy/673-search-find-work into main
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-12 19:27:10 +00:00
timmy 77fab85a66 feat: search the full Find Work catalog (Closes #673)
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:24:55 +00:00
timmy dcf26c896c Merge pull request 'Batch-claim available work into Today from Find Work' (#672) from timmy/671-batch-claim-find-work into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 19:02:54 +00:00
timmy fba1f4238d feat: batch-claim available work into Today (Closes #671)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:58:26 +00:00
timmy 9e301924b4 Merge pull request 'Select all matching work for mobile batch planning' (#670) from timmy/669-select-all-matching-work into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 18:25:41 +00:00
timmy 8edd542379 feat: select matching mobile work in one step (Closes #669)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:23:34 +00:00
timmy 0fbe047e03 Merge pull request 'Find work within the active mobile queue' (#668) from timmy/667-find-active-queue into main
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 17:58:48 +00:00
22 changed files with 634 additions and 34 deletions

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
function createBatchFindWork({ capacity, claim, queue, onProgress = () => {} }) {
let request = null;
function result(status, selected, available, queued = [], failed = []) {
return { status, selected, available, queued, failed };
}
function key(item) {
return String(item.repository || '') + '#' + String(item.number || '');
}
function run(items) {
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);
}
request = (async () => {
const queued = [];
const failed = [];
for (let index = 0; index < selected.length; index += 1) {
const item = selected[index];
try {
const confirmed = await claim(item);
const queueResult = await queue(confirmed);
if (queueResult === 'queued' || queueResult === 'exists') {
queued.push(key(item));
} 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, available, queued, failed);
onProgress({ ...outcome, processed: selected.length });
return outcome;
})().finally(() => { request = null; });
return request;
}
return { run };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
+13
View File
@@ -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,20 @@ 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-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; }
.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-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; }
+105 -2
View File
@@ -584,7 +584,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()); }
@@ -1375,6 +1391,21 @@
},
});
const batchFindWork = createBatchFindWork({
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
claim: item => findWorkController.claim(item),
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
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 === 'running') {
qs('#find-work-status').textContent = 'Assigning and queueing ' + progress.processed +
' of ' + progress.selected + '…';
}
},
});
let planTodayTrigger = null;
function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set';
@@ -2540,6 +2571,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 +3051,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 +4229,52 @@
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('#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 => {
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('#load-more-available').addEventListener('click', async event => {
event.currentTarget.disabled = true;
qs('#find-work-status').textContent = 'Loading more available issues…';
+17 -1
View File
@@ -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,23 @@
<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>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<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>
<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 &amp; queue Today</button>
</div>
</section>
</div>
@@ -948,6 +963,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>
+55 -6
View File
@@ -1,14 +1,24 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {} }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let loadGeneration = 0;
let query = '';
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) {
@@ -33,11 +43,14 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
onPagination({ ...pagination });
}
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 queryPart = requestedQuery ? '&q=' + encodeURIComponent(requestedQuery) : '';
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 +58,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 +74,42 @@ 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;
},
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();
},
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 +138,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.');
+2 -1
View File
@@ -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',
+25 -1
View File
@@ -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;
+1 -1
View File
@@ -31,7 +31,7 @@ FEATURE_SOURCES = {
"static/mobile-task-dock.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(
+17 -3
View File
@@ -2721,7 +2721,18 @@ 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 = ""
) -> dict:
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 {
@@ -2733,7 +2744,10 @@ def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict
@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),
) -> JSONResponse:
try:
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
@@ -2744,7 +2758,7 @@ 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)
if stale:
result["stale"] = True
if revalidating:
+216
View File
@@ -0,0 +1,216 @@
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_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.endsWith('=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_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_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&q=api",
"api/v1/available-issues?page=1&q=dashboard",
"api/v1/available-issues?page=1&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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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';",
)
)
+46
View File
@@ -215,6 +215,52 @@ 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_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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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():
+1 -1
View File
@@ -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
+64
View File
@@ -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))});
+1 -1
View File
@@ -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
+11 -10
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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