feat: guide mobile Find Work workflow (Closes #979)
All checks were successful
CI / lint (pull_request) Successful in 2m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m18s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-16 18:04:55 +00:00
parent 611b10b477
commit 53e556ddab
9 changed files with 438 additions and 85 deletions

View File

@ -617,9 +617,21 @@ textarea { resize: vertical; min-height: 120px; }
.work-session-nav button { min-height:44px; width:100%; } .work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; } .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-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; 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 { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header-actions { display:flex; gap:8px; } .find-work-header-actions { display:flex; gap:8px; }
.mobile-find-work-nav { position:sticky; top:0; z-index:4; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; padding:6px 0; background:#0b1526; }
.mobile-find-work-nav button { min-height:44px; min-width:0; padding:6px; }
.mobile-find-work-nav button[aria-current="step"] { border-color:#60a5fa; background:#17355b; color:#fff; }
.mobile-find-work-nav button:disabled { opacity:.5; }
.find-work-stage { display:grid; gap:12px; min-width:0; outline:none; }
.find-work-stage[hidden] { display:none; }
.find-work-review { padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.find-work-review > div:first-child, .find-work-review-list { display:grid; gap:8px; }
.find-work-review-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
.find-work-review-item > div { display:grid; gap:4px; min-width:0; }
.find-work-review-item button, .find-work-stage-actions button { min-height:44px; }
.find-work-stage-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 env(safe-area-inset-bottom); background:#101f36; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; } .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 { 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 > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }

View File

@ -1095,6 +1095,7 @@
} }
return offlineToday.warm(confirmedOwnerLogin, todayMyWork); return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
} }
let findWorkNavigation = null;
const findWorkController = createFindWork({ const findWorkController = createFindWork({
fetchJson: fetchReviewJson, fetchJson: fetchReviewJson,
onItems: renderAvailableIssues, onItems: renderAvailableIssues,
@ -1109,9 +1110,20 @@
qs('#find-work-selection-status').textContent = state.count ? qs('#find-work-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.'; state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
qs('#claim-selected-work').disabled = state.count === 0; qs('#claim-selected-work').disabled = state.count === 0;
findWorkNavigation?.sync({ selectedCount:state.count });
renderAvailableIssues(findWorkController.items()); renderAvailableIssues(findWorkController.items());
}, },
}); });
findWorkNavigation = createMobileFindWorkNavigation({
document,
history:window.history,
eventTarget:window,
controller:findWorkController,
todayWork,
openFit:openFindWorkEstimateReview,
});
findWorkNavigation.start();
let findWorkSearchTimer = null; let findWorkSearchTimer = null;
function updateFindWorkMatchStatus() { function updateFindWorkMatchStatus() {
@ -2192,26 +2204,22 @@
)); ));
} }
function closeFindWorkEstimateReview() {
qs('#find-work-estimate-review').hidden = true;
qs('#batch-find-work-actions').hidden = !findWorkController.selection().active;
}
function openFindWorkEstimateReview(items) { function openFindWorkEstimateReview(items) {
const plan = todayWork.planning(); const plan = todayWork.planning();
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0); 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)) + const estimatesRequired = Number.isInteger(plan.capacity_minutes) && plan.capacity_minutes > 0;
' remaining. Estimate selected work before assigning it.'; qs('#find-work-estimate-summary').textContent = estimatesRequired ?
formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
' remaining. Estimate selected work before assigning it.' :
items.length + ' selected. Confirm to assign and queue this batch in Today.';
qs('#find-work-estimate-list').innerHTML = items.map(item => { qs('#find-work-estimate-list').innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || ''); const id = String(item.repository || '') + '#' + String(item.number || '');
return '<div class="find-work-estimate-row"><div><span class="small">' + escapeHtml(id) + 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">' + '</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div>' +
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' + (estimatesRequired ? '<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>'; escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label>' : '') + '</div>';
}).join(''); }).join('');
qs('#batch-find-work-actions').hidden = true; if (estimatesRequired) qs('[data-find-work-estimate]')?.focus();
qs('#find-work-estimate-review').hidden = false;
qs('[data-find-work-estimate]')?.focus();
} }
function formatCalendarDueDate(value) { function formatCalendarDueDate(value) {
@ -4290,6 +4298,7 @@
} }
findingWork = true; findingWork = true;
qs('#find-work-sheet').classList.add('open'); qs('#find-work-sheet').classList.add('open');
findWorkNavigation.open(batchFindWork.pending() > 0);
const retainedItems = findWorkController.items(); const retainedItems = findWorkController.items();
if (retainedItems.length) renderAvailableIssues(retainedItems); if (retainedItems.length) renderAvailableIssues(retainedItems);
else qs('#find-work-list').textContent = ''; else qs('#find-work-list').textContent = '';
@ -5635,15 +5644,13 @@
}); });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet); 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', () => { qs('#fill-find-work-today').addEventListener('click', () => {
const remainingSlots = todayWork.limit - todayWork.read().length; const remainingSlots = todayWork.limit - todayWork.read().length;
const outcome = findWorkController.fillSelection(remainingSlots); const outcome = findWorkController.fillSelection(remainingSlots);
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' : qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.'; outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
if (outcome.selected && todayWork.planning().capacity_minutes !== null) if (outcome.selected) findWorkNavigation.go('review');
openFindWorkEstimateReview(findWorkController.selectedItems());
}); });
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault()); qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
qs('#find-work-search').addEventListener('input', event => { qs('#find-work-search').addEventListener('input', event => {
@ -5669,33 +5676,7 @@
input.dispatchEvent(new Event('input', { bubbles:true })); input.dispatchEvent(new Event('input', { bubbles:true }));
input.focus(); 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 => { qs('#confirm-find-work-estimates').addEventListener('click', async event => {
event.currentTarget.disabled = true; event.currentTarget.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues()); const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
@ -5706,10 +5687,18 @@
return; return;
} }
if (outcome.status !== 'complete') return; if (outcome.status !== 'complete') return;
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
const unavailable = outcome.failed.length - assignedOnly;
qs('#find-work-status').textContent = outcome.queued.length + ' queued' + qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
(outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.'); (unavailable ? ' · ' + unavailable + ' unavailable' : '') +
closeFindWorkEstimateReview(); (assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
if (!outcome.failed.length) findWorkController.cancelSelection(); if (!outcome.failed.length) {
findWorkNavigation.complete();
findWorkController.cancelSelection();
} else {
renderAvailableIssues(findWorkController.items());
findWorkNavigation.sync({ selectedCount:findWorkController.selection().count, recovery:true });
}
refreshMyWorkView(); refreshMyWorkView();
}); });
qs('#load-more-available').addEventListener('click', async event => { qs('#load-more-available').addEventListener('click', async event => {

View File

@ -843,42 +843,60 @@
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div> <div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<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 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> </div>
<p class="small">Claim an available issue and continue it in My Work.</p> <p class="small">Choose available issues, review the batch, then fit it into Today.</p>
<form class="find-work-search" id="find-work-search-form" role="search"> <nav class="mobile-find-work-nav" aria-label="Find Work steps">
<label for="find-work-search">Search repository, #number, or title</label> <button type="button" data-find-work-stage="discover">Discover</button>
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" /> <button type="button" data-find-work-stage="review" disabled>Review</button>
<button id="clear-find-work-search" type="button" hidden>Clear</button></div> <button type="button" data-find-work-stage="fit" disabled>Fit &amp; assign</button>
<span id="find-work-match-status" class="small" aria-live="polite"></span> </nav>
</form> <section class="find-work-stage" id="find-work-discover-stage" tabindex="-1" aria-labelledby="find-work-heading">
<details class="find-work-filters" id="find-work-filters"> <form class="find-work-search" id="find-work-search-form" role="search">
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary> <label for="find-work-search">Search repository, #number, or title</label>
<div class="find-work-filter-panel" aria-label="Filter available work"> <div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset> <button id="clear-find-work-search" type="button" hidden>Clear</button></div>
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset> <span id="find-work-match-status" class="small" aria-live="polite"></span>
<button id="clear-find-work-filters" type="button">Clear filters</button> </form>
</div> <details class="find-work-filters" id="find-work-filters">
</details> <summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
<button class="fill-find-work-today" id="fill-find-work-today" type="button" <div class="find-work-filter-panel" aria-label="Filter available work">
aria-describedby="find-work-selection-status">Fill remaining Today slots</button> <fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div> <fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button" <button id="clear-find-work-filters" type="button">Clear filters</button>
aria-describedby="find-work-status" hidden>Resume assigning &amp; queueing</button> </div>
<div class="find-work-list" id="find-work-list"></div> </details>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button> <button class="fill-find-work-today" id="fill-find-work-today" type="button"
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden> aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong> <div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div> <button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
<div id="find-work-estimate-list"></div> aria-describedby="find-work-status" hidden>Resume assigning &amp; queueing</button>
<div class="find-work-estimate-actions"> <div class="find-work-list" id="find-work-list"></div>
<button id="cancel-find-work-estimates" type="button">Back</button> <button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
<button id="confirm-find-work-estimates" type="button">Confirm &amp; assign</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>Review selected</button>
</div>
</section>
<section class="find-work-stage find-work-review" id="find-work-review-stage" tabindex="-1" aria-labelledby="find-work-review-heading" hidden>
<div><strong id="find-work-review-heading">Review selected work</strong>
<span class="small" id="find-work-review-summary" aria-live="polite"></span></div>
<div class="find-work-review-list" id="find-work-review-list"></div>
<div class="find-work-stage-actions">
<button id="back-find-work-discover" type="button">Back to discover</button>
<button id="continue-find-work-fit" type="button">Continue</button>
</div>
</section>
<section class="find-work-stage" id="find-work-fit-stage" tabindex="-1" aria-labelledby="find-work-estimate-heading" hidden>
<div class="find-work-estimate-review" id="find-work-estimate-review">
<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 to review</button>
<button id="confirm-find-work-estimates" type="button">Confirm &amp; assign</button>
</div>
</div> </div>
</section> </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 &amp; queue Today</button>
</div>
</section> </section>
</div> </div>
@ -1596,6 +1614,7 @@
<script src="static/mobile-review-detail-nav.js"></script> <script src="static/mobile-review-detail-nav.js"></script>
<script src="static/mobile-search-preview-nav.js"></script> <script src="static/mobile-search-preview-nav.js"></script>
<script src="static/mobile-plan-today-nav.js"></script> <script src="static/mobile-plan-today-nav.js"></script>
<script src="static/mobile-find-work-nav.js"></script>
<script src="static/dashboard.js"></script> <script src="static/dashboard.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,194 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMobileFindWorkNavigation = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const stages = ['discover', 'review', 'fit'];
return function createMobileFindWorkNavigation(options) {
const document = options.document;
const buttons = options.buttons || Object.fromEntries(stages.map(name =>
[name, document?.querySelector('[data-find-work-stage="' + name + '"]')]
));
const sections = options.sections || Object.fromEntries(stages.map(name =>
[name, document?.getElementById('find-work-' + name + '-stage')]
));
const history = options.history;
const eventTarget = options.eventTarget;
const onStageChange = options.onStageChange || (() => {});
const listeners = new Map();
let current = 'discover';
let selectedCount = 0;
let recovery = false;
let started = false;
function escape(value) {
return String(value || '').replace(/[&<>"']/g, character =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[character]
);
}
function renderReview() {
const reviewList = options.reviewList || document?.getElementById('find-work-review-list');
const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary');
if (!reviewList) return;
const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || [];
const planning = options.todayWork?.planning?.();
const today = options.todayWork ? {
limit:options.todayWork.limit, count:options.todayWork.read().length,
capacityMinutes:planning.capacity_minutes, estimates:planning.estimates,
} : options.getTodayState?.() || {};
const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0));
const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null :
Math.max(0, today.capacityMinutes - planned);
const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '',
minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m';
if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots +
' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' +
(remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.';
reviewList.innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
return '<article class="find-work-review-item"><div><span class="small">' + escape(id) +
'</span><strong>' + escape(item.title || 'Untitled work') + '</strong></div>' +
'<button type="button" data-find-work-review-remove="' + escape(id) + '">Remove</button></article>';
}).join('');
reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button =>
button.addEventListener('click', () => {
const item = items.find(candidate => String(candidate.repository || '') + '#' +
String(candidate.number || '') === button.dataset.findWorkReviewRemove);
if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item);
})
);
}
function allowed(name) {
return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery);
}
function paint(name, focus = false, notify = true) {
if (!allowed(name)) name = 'discover';
current = name;
stages.forEach(stage => {
const button = buttons[stage];
const section = sections[stage];
if (button) {
button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery;
if (stage === name) button.setAttribute('aria-current', 'step');
else button.removeAttribute('aria-current');
}
if (section) section.hidden = stage !== name;
});
if (name === 'review') renderReview();
if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []);
if (notify) onStageChange(name);
if (focus) sections[name]?.focus?.();
return name;
}
function stateFor(name) {
const state = { ...(history?.state || {}) };
if (name === 'discover') delete state.findWorkStage;
else state.findWorkStage = name;
return state;
}
function go(name) {
if (!allowed(name) || name === current) return false;
const currentIndex = stages.indexOf(current);
const nextIndex = stages.indexOf(name);
if (nextIndex < currentIndex && history?.go) {
history.go(nextIndex - currentIndex);
return true;
}
if (nextIndex === currentIndex - 1 && history?.back) {
history.back();
return true;
}
history?.pushState?.(stateFor(name), '');
paint(name, true);
return true;
}
function onPopState(event) {
if (event.state?.taskOverlay !== 'find') return;
paint(event.state?.findWorkStage || 'discover', true);
}
function sync(state = {}) {
selectedCount = Math.max(0, Number(state.selectedCount) || 0);
recovery = state.recovery === true;
if (!allowed(current)) paint('discover');
else paint(current, false, false);
}
function open(hasRecovery) {
if (options.controller) {
selectedCount = options.controller.selection().count;
recovery = hasRecovery === true;
}
const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null;
return paint(allowed(restored) ? restored : 'discover');
}
function complete() {
selectedCount = 0;
recovery = false;
history?.replaceState?.(stateFor('discover'), '');
return paint('discover');
}
function back() {
if (current === 'discover') return false;
history?.back?.();
return true;
}
return {
start() {
if (started) return;
started = true;
stages.forEach(name => {
const button = buttons[name];
if (!button) return;
const listener = event => { event.preventDefault(); go(name); };
listeners.set(button, listener);
button.addEventListener('click', listener);
});
if (document) {
const bind = (id, action) => {
const button = document.getElementById(id);
if (!button) return;
const listener = event => { event.preventDefault(); action(); };
listeners.set(button, listener);
button.addEventListener('click', listener);
};
bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); });
bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); });
bind('claim-selected-work', () => go('review'));
bind('continue-find-work-fit', () => go('fit'));
bind('back-find-work-discover', back);
bind('cancel-find-work-estimates', back);
bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1)));
}
eventTarget?.addEventListener?.('popstate', onPopState);
paint('discover');
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
eventTarget?.removeEventListener?.('popstate', onPopState);
started = false;
},
sync,
open,
go,
back,
complete,
renderReview,
stage: () => current,
};
};
});

View File

@ -82,6 +82,7 @@ const SHELL = [
BASE + 'static/mobile-review-detail-nav.js', BASE + 'static/mobile-review-detail-nav.js',
BASE + 'static/mobile-search-preview-nav.js', BASE + 'static/mobile-search-preview-nav.js',
BASE + 'static/mobile-plan-today-nav.js', BASE + 'static/mobile-plan-today-nav.js',
BASE + 'static/mobile-find-work-nav.js',
BASE + 'static/checklist-conflict.js', BASE + 'static/checklist-conflict.js',
BASE + 'static/voice-transcript-store.js', BASE + 'static/voice-transcript-store.js',
BASE + 'static/voice-issue-capture.js', BASE + 'static/voice-issue-capture.js',

View File

@ -30,7 +30,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "today-timer": (
"static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",

View File

@ -9,6 +9,7 @@ HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
FIND_WORK_NAV = Path(__file__).parents[1] / "frontend" / "mobile-find-work-nav.js"
def run_node(script): def run_node(script):
@ -437,14 +438,15 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
dashboard = DASHBOARD.read_text() dashboard = DASHBOARD.read_text()
css = CSS.read_text() css = CSS.read_text()
worker = WORKER.read_text() worker = WORKER.read_text()
navigation = FIND_WORK_NAV.read_text()
assert 'id="select-find-work"' in html assert 'id="select-find-work"' in html
assert 'id="batch-find-work-actions"' in html assert 'id="batch-find-work-actions"' in html
assert 'id="claim-selected-work"' in html assert 'id="claim-selected-work"' in html
assert 'aria-live="polite"' in html assert 'aria-live="polite"' in html
assert "createBatchFindWork({" in dashboard assert "createBatchFindWork({" in dashboard
assert "findWorkController.startSelection()" in dashboard assert "options.controller?.startSelection?.()" in navigation
assert "findWorkController.toggleSelection(item)" in dashboard assert "options.controller?.toggleSelection" in navigation
assert ".find-work-batch-actions" in css assert ".find-work-batch-actions" in css
assert "position:sticky" in css assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css assert "env(safe-area-inset-bottom)" in css
@ -472,7 +474,7 @@ def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
assert 'id="fill-find-work-today"' in html assert 'id="fill-find-work-today"' in html
assert 'aria-describedby="find-work-selection-status"' in html assert 'aria-describedby="find-work-selection-status"' in html
assert "findWorkController.fillSelection(remainingSlots)" in dashboard assert "findWorkController.fillSelection(remainingSlots)" in dashboard
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard assert "openFit:openFindWorkEstimateReview" in dashboard
assert ".fill-find-work-today" in css and "min-height:44px" in css assert ".fill-find-work-today" in css and "min-height:44px" in css

View File

@ -0,0 +1,135 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-find-work-nav.js"
FRONTEND = CONTROLLER.parent
def run_navigation(scenario: str) -> dict:
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.hidden=false; this.disabled=false; this.focused=0; }}
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
click() {{ for (const callback of this.listeners.click||[]) callback({{preventDefault(){{}}}}); }}
setAttribute(name,value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focused++; }}
}}
class FakeEvents {{
constructor() {{ this.listeners={{}}; }}
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
emit(name,event) {{ for (const callback of this.listeners[name]||[]) callback(event); }}
}}
{scenario}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_find_work_navigation_gates_review_and_fit_until_work_is_selected():
result = run_navigation("""
const names=['discover','review','fit'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const events=new FakeEvents();
const history={state:{taskOverlay:'find'}, pushes:[], backs:0,
pushState(state){this.state=state;this.pushes.push(state);}, back(){this.backs++;}};
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
navigation.start();
const initial={stage:navigation.stage(),reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled,hidden:Object.fromEntries(names.map(name=>[name,sections[name].hidden]))};
navigation.sync({selectedCount:2,recovery:false});
buttons.review.click();
buttons.fit.click();
process.stdout.write(JSON.stringify({initial,stage:navigation.stage(),pushes:history.pushes,selected:Object.fromEntries(names.map(name=>[name,buttons[name].attributes['aria-current']||null]))}));
""")
assert result == {
"initial": {
"stage": "discover",
"reviewDisabled": True,
"fitDisabled": True,
"hidden": {"discover": False, "review": True, "fit": True},
},
"stage": "fit",
"pushes": [
{"taskOverlay": "find", "findWorkStage": "review"},
{"taskOverlay": "find", "findWorkStage": "fit"},
],
"selected": {"discover": None, "review": None, "fit": "step"},
}
def test_browser_back_moves_fit_to_review_to_discover_without_losing_selection():
result = run_navigation("""
const names=['discover','review','fit'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const events=new FakeEvents(); const changes=[];
const history={state:{taskOverlay:'find'},pushState(state){this.state=state;},back(){}};
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:stage=>changes.push(stage)});
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.go('review'); navigation.go('fit');
events.emit('popstate',{state:{taskOverlay:'find',findWorkStage:'review'}});
const afterFirst=navigation.stage();
events.emit('popstate',{state:{taskOverlay:'find'}});
process.stdout.write(JSON.stringify({afterFirst,afterSecond:navigation.stage(),changes,reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled}));
""")
assert result == {
"afterFirst": "review",
"afterSecond": "discover",
"changes": ["discover", "review", "fit", "review", "discover"],
"reviewDisabled": False,
"fitDisabled": False,
}
def test_reopening_restores_progress_but_completion_resets_to_discovery():
result = run_navigation("""
const names=['discover','review','fit'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const events=new FakeEvents();
const history={state:{taskOverlay:'find',findWorkStage:'review'},replaced:[],pushState(state){this.state=state;},replaceState(state){this.state=state;this.replaced.push(state);},back(){}};
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.open();
const restored=navigation.stage();
navigation.complete();
process.stdout.write(JSON.stringify({restored,completed:navigation.stage(),state:history.state,replaced:history.replaced}));
""")
assert result == {
"restored": "review",
"completed": "discover",
"state": {"taskOverlay": "find"},
"replaced": [{"taskOverlay": "find"}],
}
def test_find_work_ships_guided_mobile_flow_in_the_offline_bundle():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
service_worker = (FRONTEND / "service-worker.js").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert '<nav class="mobile-find-work-nav"' in html
assert 'aria-label="Find Work steps"' in html
for name, label in (("discover", "Discover"), ("review", "Review"), ("fit", "Fit &amp; assign")):
assert f'data-find-work-stage="{name}"' in html
assert f">{label}</button>" in html
assert f'id="find-work-{name}-stage"' in html
assert 'id="find-work-review-list"' in html
assert 'id="continue-find-work-fit"' in html
assert '<script src="static/mobile-find-work-nav.js"></script>' in html
assert '"static/mobile-find-work-nav.js"' in bundle
assert "static/mobile-find-work-nav.js" in service_worker
assert ".mobile-find-work-nav" in css
assert "min-height:44px" in css
assert "createMobileFindWorkNavigation" in dashboard
assert "findWorkNavigation?.sync" in dashboard

View File

@ -1019,6 +1019,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-review-detail-nav.js", "/dashboard/static/mobile-review-detail-nav.js",
"/dashboard/static/mobile-search-preview-nav.js", "/dashboard/static/mobile-search-preview-nav.js",
"/dashboard/static/mobile-plan-today-nav.js", "/dashboard/static/mobile-plan-today-nav.js",
"/dashboard/static/mobile-find-work-nav.js",
"/dashboard/static/checklist-conflict.js", "/dashboard/static/checklist-conflict.js",
"/dashboard/static/voice-transcript-store.js", "/dashboard/static/voice-transcript-store.js",
"/dashboard/static/voice-issue-capture.js", "/dashboard/static/voice-issue-capture.js",