stackchain-dashboard/frontend/mobile-find-work-nav.js
timmy 53e556ddab
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
feat: guide mobile Find Work workflow (Closes #979)
2026-08-16 18:04:55 +00:00

195 lines
7.8 KiB
JavaScript

(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,
};
};
});