diff --git a/frontend/batch-find-work.js b/frontend/batch-find-work.js index db9eb06..299a466 100644 --- a/frontend/batch-find-work.js +++ b/frontend/batch-find-work.js @@ -5,8 +5,28 @@ function createBatchFindWork({ 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 }; @@ -16,6 +36,48 @@ function createBatchFindWork({ 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() : []; @@ -46,39 +108,46 @@ function createBatchFindWork({ 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)); - if (Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0) { - persistEstimate(confirmed, estimates[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; }); + 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; } - return { run }; + 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; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e015c0b..f99d6df 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -468,6 +468,7 @@ textarea { resize: vertical; min-height: 120px; } .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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ee223d1..093466e 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -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( @@ -1393,7 +1391,7 @@ const batchFindWork = createBatchFindWork({ capacity: () => Math.max(0, todayWork.limit - todayWork.read().length), - timeBudget: () => { + owner:()=>planningOwnerLogin,timeBudget:()=>{ const plan = todayWork.planning(); return { capacity_minutes: plan.capacity_minutes, @@ -4310,6 +4308,7 @@ 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()); diff --git a/frontend/index.html b/frontend/index.html index 302d595..6fb934c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -544,6 +544,8 @@