stackchain-dashboard/frontend/mobile-start-day.js
timmy 718f0d1209
Some checks failed
CI / lint (push) Successful in 4m11s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 8m3s
CI / release-candidate (push) Has been skipped
feat: surface Human Gates in mobile preparation (#1418)
Closes #1417
2026-08-26 03:42:45 +00:00

200 lines
7.2 KiB
JavaScript

(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileStartDay = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileStartDay(options) {
const checkpointKey = 'stackchain.mobile-start-day.v1';
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
const reviewOrder = [
['delivery', 'Delivery recovery'],
['gate', 'Human Gates'],
['agenda', 'Agenda'],
['attention', 'Attention'],
['update', 'Updates'],
['filed', 'Filed'],
['following', 'Following'],
];
const anonymousItems = new WeakMap();
let anonymousItemSequence = 0;
function count(value) {
return Math.max(0, Number(value) || 0);
}
function itemIdentity(item) {
if (item === null || item === undefined) return '';
if (typeof item !== 'object') return String(item);
const explicit = item.outbox_id || item.id || item.key || item.url;
if (explicit !== null && explicit !== undefined && String(explicit)) {
return String(item.kind || 'item') + ':' + String(explicit);
}
if (!anonymousItems.has(item)) anonymousItems.set(item, 'anonymous:' + (++anonymousItemSequence));
return anonymousItems.get(item);
}
function reviewPhases(counts) {
if (!options.getPhaseItems) {
return reviewOrder
.map(([name, label]) => ({name, label, count: count(counts[name])}))
.filter(phase => phase.count > 0);
}
const items = options.getPhaseItems() || {};
const seen = new Set();
return reviewOrder.flatMap(([name, label]) => {
let phaseCount = 0;
(items[name] || []).forEach(item => {
const identity = itemIdentity(item);
if (!identity || seen.has(identity)) return;
seen.add(identity);
phaseCount += 1;
});
return phaseCount ? [{name, label, count:phaseCount}] : [];
});
}
function localDay() {
const now = new Date();
const pad = value => String(value).padStart(2, '0');
return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
}
function identity() {
try {
return {
login: String(options.getLogin ? options.getLogin() : '').trim(),
day: String(options.getDay ? options.getDay() : localDay()),
};
} catch (_) {
return {login:'', day:''};
}
}
function checkpoint() {
if (!storage) return null;
const current = identity();
if (!current.login || !current.day) return null;
try {
const saved = JSON.parse(storage.getItem(checkpointKey) || 'null');
return saved?.login === current.login && saved?.day === current.day ? saved : null;
} catch (_) {
return null;
}
}
function saveCheckpoint(phase = null) {
if (!storage) return false;
const current = identity();
if (!current.login || !current.day) return false;
try {
storage.setItem(checkpointKey, JSON.stringify({...current, phase}));
return true;
} catch (_) {
return false;
}
}
function clearCheckpoint() {
if (!storage || !checkpoint()) return false;
try {
storage.removeItem(checkpointKey);
return true;
} catch (_) {
return false;
}
}
function briefing() {
const counts = options.getCounts ? options.getCounts() : {};
let phases = reviewPhases(counts);
const gateUnavailable = counts.gateUnavailable === true;
if (gateUnavailable) {
phases = phases.filter(phase => phase.name !== 'gate');
const afterDelivery = phases[0]?.name === 'delivery' ? 1 : 0;
phases.splice(afterDelivery, 0, {name:'gate', label:'Human Gates unavailable · retry', count:count(counts.gate)});
}
const followingUnavailable = counts.followingUnavailable === true;
if (followingUnavailable) {
phases = phases.filter(phase => phase.name !== 'following');
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
}
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today);
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
const other = total - delivery;
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
const nextLabel = next === 'delivery' ? 'Review Delivery' :
(next === 'gate' && gateUnavailable ? 'Retry Human Gates' :
(next === 'following' && followingUnavailable ? 'Retry Following' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'))));
const gateRetry = next === 'gate' && gateUnavailable;
const followingRetry = next === 'following' && followingUnavailable;
return {
total,
next,
label: nextLabel,
summary: gateRetry ? 'Human Gates need retry before Today · ' + today + ' planned' :
followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
' · ' + today + ' planned' :
total ? total + ' items before Today · ' + today + ' planned' :
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
phases,
};
}
function startNext() {
const next = briefing().next;
if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(next);
else clearCheckpoint();
options.openQueue(next);
return next;
}
function state() {
const current = briefing();
return {active:Boolean(checkpoint()), next:current.next, label:current.label};
}
function completePhase(phase) {
const saved = checkpoint();
if (!saved || (saved.phase && saved.phase !== phase)) return false;
const current = render();
saveCheckpoint();
if (options.onHandoff) options.onHandoff(current);
return true;
}
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
const saved = checkpoint();
if (!saved?.phase || (!authoritative && !authoritativePhases.includes(saved.phase))) return false;
const counts = options.getCounts ? options.getCounts() : {};
if (count(counts[saved.phase]) > 0) return false;
return completePhase(saved.phase);
}
function finish() {
const cleared = clearCheckpoint();
render();
return cleared;
}
function render() {
const current = briefing();
if (!options.elements) return current;
options.elements.summary.textContent = current.summary;
options.elements.phases.textContent = current.phases.length ?
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
'All urgent queues reviewed';
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
return current;
}
function start() {
render();
if (options.elements) options.elements.action.addEventListener('click', startNext);
}
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
});