Compare commits
No commits in common. "057bec7dc2c1fd743888b36dacf3421ff656907a" and "a0dd044995e266c8a78f90eca1c16f2c5d668466" have entirely different histories.
057bec7dc2
...
a0dd044995
|
|
@ -5,28 +5,8 @@ function createBatchFindWork({
|
||||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||||
persistEstimate = () => {},
|
persistEstimate = () => {},
|
||||||
onProgress = () => {},
|
onProgress = () => {},
|
||||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
|
||||||
owner = () => '',
|
|
||||||
}) {
|
}) {
|
||||||
let request = null;
|
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 = []) {
|
function result(status, selected, available, queued = [], failed = []) {
|
||||||
return { status, selected, available, queued, failed };
|
return { status, selected, available, queued, failed };
|
||||||
|
|
@ -36,48 +16,6 @@ function createBatchFindWork({
|
||||||
return String(item.repository || '') + '#' + String(item.number || '');
|
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 = {}) {
|
function run(items, estimates = {}) {
|
||||||
if (request) return request;
|
if (request) return request;
|
||||||
const selected = Array.isArray(items) ? items.slice() : [];
|
const selected = Array.isArray(items) ? items.slice() : [];
|
||||||
|
|
@ -108,46 +46,39 @@ function createBatchFindWork({
|
||||||
return Promise.resolve(outcome);
|
return Promise.resolve(outcome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const journal = {
|
request = (async () => {
|
||||||
owner: String(owner() || ''), available,
|
const queued = [];
|
||||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
const failed = [];
|
||||||
};
|
for (let index = 0; index < selected.length; index += 1) {
|
||||||
writeJournal(journal);
|
const item = selected[index];
|
||||||
request = processJournal(journal).finally(() => { request = null; });
|
try {
|
||||||
return request;
|
const confirmed = await claim(item);
|
||||||
}
|
const queueResult = await queue(confirmed);
|
||||||
|
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||||
function resume() {
|
queued.push(key(item));
|
||||||
if (request) return request;
|
if (Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0) {
|
||||||
const journal = readJournal();
|
persistEstimate(confirmed, estimates[key(item)]);
|
||||||
if (!journal) return Promise.resolve(null);
|
}
|
||||||
request = processJournal(journal).finally(() => { request = null; });
|
} else {
|
||||||
return request;
|
failed.push({
|
||||||
}
|
key: key(item),
|
||||||
|
reason: 'assigned but Today sync is unavailable',
|
||||||
function mountRecovery(button, opener) {
|
assigned: true,
|
||||||
const show = () => {
|
});
|
||||||
const journal = readJournal();
|
}
|
||||||
button.hidden = !journal;
|
} catch (error) {
|
||||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||||
};
|
}
|
||||||
button.addEventListener('click', async () => {
|
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||||
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.';
|
|
||||||
}
|
}
|
||||||
});
|
const outcome = result('complete', selected.length, available, queued, failed);
|
||||||
opener.addEventListener('click', show);
|
onProgress({ ...outcome, processed: selected.length });
|
||||||
|
return outcome;
|
||||||
|
})().finally(() => { request = null; });
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof document !== 'undefined') mountRecovery(
|
return { run };
|
||||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
|
||||||
);
|
|
||||||
return { run, resume, mountRecovery };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||||
|
|
|
||||||
|
|
@ -468,7 +468,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.find-work-batch-actions[hidden] { display:none; }
|
.find-work-batch-actions[hidden] { display:none; }
|
||||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||||
.find-work-batch-actions button { min-height:44px; }
|
.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 { 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[hidden] { display:none; }
|
||||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@
|
||||||
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
console.warn('Panel state save failed', e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const mobileTaskButtons = Object.fromEntries(
|
const mobileTaskButtons = Object.fromEntries(
|
||||||
|
|
@ -1391,7 +1393,7 @@
|
||||||
|
|
||||||
const batchFindWork = createBatchFindWork({
|
const batchFindWork = createBatchFindWork({
|
||||||
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
|
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
|
||||||
owner:()=>planningOwnerLogin,timeBudget:()=>{
|
timeBudget: () => {
|
||||||
const plan = todayWork.planning();
|
const plan = todayWork.planning();
|
||||||
return {
|
return {
|
||||||
capacity_minutes: plan.capacity_minutes,
|
capacity_minutes: plan.capacity_minutes,
|
||||||
|
|
@ -4308,7 +4310,6 @@
|
||||||
input.focus();
|
input.focus();
|
||||||
});
|
});
|
||||||
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
|
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
|
||||||
|
|
||||||
qs('#claim-selected-work').addEventListener('click', async event => {
|
qs('#claim-selected-work').addEventListener('click', async event => {
|
||||||
if (todayWork.planning().capacity_minutes !== null) {
|
if (todayWork.planning().capacity_minutes !== null) {
|
||||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||||
|
|
|
||||||
|
|
@ -544,8 +544,6 @@
|
||||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</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>
|
<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>
|
<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>
|
<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>
|
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||||
|
|
|
||||||
|
|
@ -109,82 +109,6 @@ flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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():
|
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||||
|
|
@ -254,7 +178,7 @@ def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||||
assert 'id="find-work-estimate-list"' in html
|
assert 'id="find-work-estimate-list"' in html
|
||||||
assert 'id="confirm-find-work-estimates"' in html
|
assert 'id="confirm-find-work-estimates"' in html
|
||||||
assert 'inputmode="numeric"' in dashboard
|
assert 'inputmode="numeric"' in dashboard
|
||||||
assert "timeBudget:" in dashboard
|
assert "timeBudget: () =>" in dashboard
|
||||||
assert "persistEstimate:" in dashboard
|
assert "persistEstimate:" in dashboard
|
||||||
assert ".find-work-estimate-review" in css
|
assert ".find-work-estimate-review" in css
|
||||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||||
|
|
@ -368,18 +292,6 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
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():
|
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||||
html = HTML.read_text()
|
html = HTML.read_text()
|
||||||
dashboard = DASHBOARD.read_text()
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user