stackchain-dashboard/frontend/today-wrap-up.js
timmy c99d03b9bc
All checks were successful
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 3m56s
CI / release-candidate (pull_request) Has been skipped
feat: carry wrap-up work into Tomorrow (Closes #1174)
2026-08-20 09:49:08 +00:00

160 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
let items = [];
const selected = new Set();
function open(candidates) {
const current = new Set(todayWork.read());
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
items = todayWork.read().flatMap(identity =>
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
);
selected.clear();
return snapshot();
}
function choose(identity, schedule) {
if (!items.some(item => todayWork.identity(item) === identity)) return false;
if (schedule) selected.add(identity);
else selected.delete(identity);
return true;
}
function snapshot() {
return items.map(item => ({
item,
identity:todayWork.identity(item),
schedule:selected.has(todayWork.identity(item)),
}));
}
async function finish() {
const loaded = await tomorrowPlan.load();
const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : [];
const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item));
if (!carried.length) {
return {
scheduled:0,
left:items.filter(item => todayWork.contains(item)).length,
plan_count:currentIds.length,
plan_date:loaded?.plan_date,
sync_pending:Boolean(loaded?.sync_pending),
};
}
const combinedIds = [...currentIds];
for (const item of carried) {
const identity = todayWork.identity(item);
if (!combinedIds.includes(identity)) combinedIds.push(identity);
}
if (combinedIds.length > limit) {
throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`);
}
const estimates = {...(loaded?.estimates || {})};
const capacityMinutes = loaded?.capacity_minutes ?? null;
const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0);
if (capacityMinutes !== null && plannedMinutes > capacityMinutes) {
throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.');
}
const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates});
if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.');
let scheduled = 0;
for (const item of carried) {
const identity = todayWork.identity(item);
if (!todaySync.enqueue('remove', identity)) {
throw new Error('Today sync could not be queued. The item remains in Today.');
}
if (todayWork.contains(item) && !todayWork.remove(item)) {
throw new Error('Today could not be updated. Retry wrap-up.');
}
scheduled += 1;
}
await todaySync.flush();
return {
scheduled,
left:items.length - scheduled,
plan_count:combinedIds.length,
plan_date:staged.plan_date,
sync_pending:Boolean(staged.sync_pending),
};
}
return { open, choose, snapshot, finish };
}
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
let actualMinutes = {};
let workedItems = [];
function close() {
qs('#today-wrap-up-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
onClose();
}
function render() {
const rows = controller.snapshot();
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
const title = String(row.item.title || row.identity).slice(0, 180);
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
'> Carry to Tomorrow</label></div>';
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
});
}
function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
actualMinutes = { ...recapActualMinutes };
workedItems = recapWorkedItems.map(item => ({...item}));
controller.open(items);
qs('#today-wrap-up-status').textContent = '';
render();
qs('#today-wrap-up-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
}
async function finish(button) {
button.disabled = true;
qs('#today-wrap-up-status').textContent = 'Saving tomorrows plan…';
try {
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
identity:row.identity,
title:String(row.item.title || row.identity).slice(0, 180),
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
}));
const result = await controller.finish();
qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left +
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
close();
onComplete(result, actualMinutes, workedItems, tomorrowItems);
} catch (error) {
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
} finally {
button.disabled = false;
}
}
function bind() {
qs('#close-today-wrap-up').addEventListener('click', close);
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
}
return { open, close, finish, render, bind };
}
function setupTodayWrapUp(options) {
const controller = createTodayWrapUp(options);
const view = createTodayWrapUpView({ ...options, controller });
view.bind();
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayWrapUp.createView = createTodayWrapUpView;
createTodayWrapUp.setup = setupTodayWrapUp;
module.exports = createTodayWrapUp;
}