feat: plan Today by available time (#459)
This commit is contained in:
1 parent
e3c07c2dfb
commit
161977cb67
20 files changed
+570
-73
No files matched your search
@@ -89,6 +89,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
|
||||
+70
-16
@@ -163,6 +163,13 @@
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
},
|
||||
onRemotePlan: plan => {
|
||||
if (!planningOwnerLogin) return;
|
||||
todayWork.replacePlanning({
|
||||
capacity_minutes: plan.capacity_minutes ?? null,
|
||||
estimates: plan.estimates || {},
|
||||
});
|
||||
},
|
||||
onStatus: (state, detail = {}) => {
|
||||
const status = qs('#today-sync-status');
|
||||
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||||
@@ -744,8 +751,10 @@
|
||||
onProgress: state => {
|
||||
updateWorkSessionActions();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||||
const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
|
||||
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + state.index + ' of ' + state.total;
|
||||
element.textContent = 'Item ' + state.index + ' of ' + state.total + (runway?.current_minutes ?
|
||||
' · ' + formatPlanMinutes(runway.current_minutes) + ' · ' + formatPlanMinutes(runway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
|
||||
button.disabled = !state.can_previous;
|
||||
@@ -1059,21 +1068,48 @@
|
||||
});
|
||||
|
||||
let planTodayTrigger = null;
|
||||
function formatPlanMinutes(minutes) {
|
||||
if (!Number.isInteger(minutes)) return 'Not set';
|
||||
const absolute = Math.abs(minutes);
|
||||
const hours = Math.floor(absolute / 60);
|
||||
const remainder = absolute % 60;
|
||||
return [hours ? hours + 'h' : '', remainder ? remainder + 'm' : ''].filter(Boolean).join(' ') || '0m';
|
||||
}
|
||||
|
||||
function planTodayItemMarkup(item, selected, index = -1) {
|
||||
const id = todayWork.identity(item);
|
||||
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
|
||||
const title = escapeHtml(item.title || 'Untitled work');
|
||||
const state = planToday.snapshot();
|
||||
const estimate = state.estimates?.[id] || '';
|
||||
const estimateControl = selected ? '<label class="small plan-today-estimate-wrap">Estimate <input class="plan-today-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" value="' + escAttr(estimate) + '" data-plan-estimate="' + escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || key) + ' in minutes" /> min</label>' : '';
|
||||
const controls = selected ?
|
||||
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
|
||||
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === state.count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
|
||||
(item.kind === 'issue' ?
|
||||
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview to add</button></div>' :
|
||||
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>');
|
||||
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong></div>' + controls + '</article>';
|
||||
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong>' + estimateControl + '</div>' + controls + '</article>';
|
||||
}
|
||||
|
||||
function resetPlanTodayConfirmation() {
|
||||
const save = qs('#save-today-plan');
|
||||
const start = qs('#save-and-start-today');
|
||||
save.dataset.confirmOverCapacity = '';
|
||||
start.dataset.confirmOverCapacity = '';
|
||||
save.textContent = 'Save plan';
|
||||
start.textContent = 'Save & start';
|
||||
}
|
||||
|
||||
function renderPlanToday() {
|
||||
resetPlanTodayConfirmation();
|
||||
const state = planToday.snapshot();
|
||||
qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected';
|
||||
const capacityText = state.capacity_minutes === null ? 'Set available time to check fit' :
|
||||
(state.over_capacity ? formatPlanMinutes(-state.remaining_minutes) + ' over capacity' :
|
||||
formatPlanMinutes(state.remaining_minutes) + ' free');
|
||||
qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected · Planned ' +
|
||||
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
|
||||
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
|
||||
qs('#plan-today-available').value = state.capacity_minutes || '';
|
||||
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
|
||||
planTodayItemMarkup(planToday.item(id), true, index)
|
||||
).join('') : '<p class="muted">No work selected yet.</p>';
|
||||
@@ -1105,6 +1141,10 @@
|
||||
renderPlanToday();
|
||||
document.querySelector('[data-plan-id="' + CSS.escape(button.dataset.planId) + '"][data-plan-move="' + button.dataset.planMove + '"]')?.focus();
|
||||
}));
|
||||
document.querySelectorAll('[data-plan-estimate]').forEach(input => input.addEventListener('change', () => {
|
||||
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
|
||||
renderPlanToday();
|
||||
}));
|
||||
}
|
||||
|
||||
function closePlanToday(navigate = true) {
|
||||
@@ -1118,15 +1158,19 @@
|
||||
planTodayTrigger?.focus();
|
||||
}
|
||||
|
||||
function saveTodayPlan(ids) {
|
||||
function saveTodayPlan(plan) {
|
||||
const capacityAware = !Array.isArray(plan);
|
||||
const ids = capacityAware ? plan.ids : plan;
|
||||
const previous = todayWork.read();
|
||||
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
|
||||
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
|
||||
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
|
||||
if (!todayWork.replace(ids)) return false;
|
||||
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
|
||||
refreshMyWorkView();
|
||||
todaySync.flush();
|
||||
warmTodayOffline();
|
||||
qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order.' : 'Today plan cleared.';
|
||||
qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order and available time.' : 'Today plan cleared.';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1252,7 +1296,7 @@
|
||||
taskOverlayHistory.open('plan-today');
|
||||
return;
|
||||
}
|
||||
planToday.open(todayMyWork, activeMyWork);
|
||||
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
|
||||
qs('#plan-today-error').textContent = '';
|
||||
qs('#plan-today-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
@@ -4380,16 +4424,26 @@
|
||||
closeTodayReadiness();
|
||||
openPlanToday(qs('#plan-today'));
|
||||
});
|
||||
qs('#save-today-plan').addEventListener('click', () => {
|
||||
const result = planToday.commit();
|
||||
if (result === 'saved') taskOverlayHistory.leave();
|
||||
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||||
});
|
||||
qs('#save-and-start-today').addEventListener('click', () => {
|
||||
const result = planToday.commit({ start:true });
|
||||
if (result === 'saved') taskOverlayHistory.leave();
|
||||
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||||
qs('#plan-today-available').addEventListener('change', event => {
|
||||
planToday.setCapacity(Number(event.currentTarget.value));
|
||||
qs('#plan-today-error').textContent = '';
|
||||
renderPlanToday();
|
||||
});
|
||||
function commitPlanToday(start, button) {
|
||||
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
||||
if (result === 'saved') {
|
||||
button.dataset.confirmOverCapacity = '';
|
||||
taskOverlayHistory.leave();
|
||||
} else if (result === 'confirm-over-capacity') {
|
||||
button.dataset.confirmOverCapacity = 'true';
|
||||
qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
|
||||
button.textContent = start ? 'Save over capacity & start' : 'Save over capacity';
|
||||
} else {
|
||||
qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||||
}
|
||||
}
|
||||
qs('#save-today-plan').addEventListener('click', event => commitPlanToday(false, event.currentTarget));
|
||||
qs('#save-and-start-today').addEventListener('click', event => commitPlanToday(true, event.currentTarget));
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
if (!sessionItems.length) {
|
||||
|
||||
@@ -180,6 +180,9 @@
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
|
||||
<label class="plan-today-available" for="plan-today-available">Available today
|
||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||
</label>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
<h3 id="today-plan-heading">Today, in order</h3>
|
||||
|
||||
+52
-5
@@ -2,6 +2,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let openState = false;
|
||||
let draftIds = [];
|
||||
let itemsById = new Map();
|
||||
let capacityMinutes = null;
|
||||
let estimates = {};
|
||||
let capacityAware = false;
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
@@ -12,13 +15,20 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates) {
|
||||
function open(selectedItems, candidates, planning = null) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
|
||||
}
|
||||
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
|
||||
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
|
||||
? planning.capacity_minutes : null;
|
||||
estimates = {};
|
||||
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
}
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
@@ -50,6 +60,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
openState = false;
|
||||
draftIds = [];
|
||||
itemsById = new Map();
|
||||
capacityMinutes = null;
|
||||
estimates = {};
|
||||
capacityAware = false;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -57,10 +70,31 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false } = {}) {
|
||||
function setCapacity(minutes) {
|
||||
capacityAware = true;
|
||||
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setEstimate(id, minutes) {
|
||||
if (!openState || !draftIds.includes(id)) return false;
|
||||
capacityAware = true;
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
else delete estimates[id];
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
if (save?.(ids) === false) return 'unavailable';
|
||||
const state = snapshot();
|
||||
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
|
||||
const payload = capacityAware ? {
|
||||
ids,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
|
||||
} : ids;
|
||||
if (save?.(payload) === false) return 'unavailable';
|
||||
const first = ids.length ? itemsById.get(ids[0]) : null;
|
||||
close();
|
||||
if (startAfterSave && first) start?.(first);
|
||||
@@ -68,7 +102,20 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
if (!capacityAware) return basic;
|
||||
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
|
||||
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
|
||||
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
|
||||
return {
|
||||
...basic,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: selectedEstimates,
|
||||
planned_minutes: plannedMinutes,
|
||||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
||||
};
|
||||
}
|
||||
|
||||
function item(id) {
|
||||
@@ -79,7 +126,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, commit, snapshot, item, candidates };
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v78';
|
||||
const CACHE = 'stackchain-dashboard-shell-v79';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
||||
+33
-5
@@ -1,4 +1,4 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
@@ -56,12 +56,19 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
}));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
|
||||
onRemotePlan?.(plan);
|
||||
if (broadcast) channel?.postMessage({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -122,7 +129,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -173,6 +180,27 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
return saved;
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
const operation = {
|
||||
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
|
||||
capacity_minutes: capacityMinutes, estimates: estimates || {},
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
@@ -253,7 +281,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
+66
-6
@@ -1,5 +1,6 @@
|
||||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
const planningPrefix = 'stackchain.today-planning.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
@@ -9,9 +10,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
function ownerKey(keyPrefix) {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
return login ? keyPrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return ownerKey(prefix);
|
||||
}
|
||||
|
||||
function planningKey() {
|
||||
return ownerKey(planningPrefix);
|
||||
}
|
||||
|
||||
function read() {
|
||||
@@ -37,6 +46,40 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
}
|
||||
}
|
||||
|
||||
function planning() {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return { capacity_minutes: null, estimates: {} };
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const ids = new Set(read());
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
return { capacity_minutes: capacity, estimates };
|
||||
} catch (_error) {
|
||||
return { capacity_minutes: null, estimates: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function replacePlanning(value) {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return false;
|
||||
const ids = new Set(read());
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
@@ -55,14 +98,18 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
return write(unique);
|
||||
const saved = write(unique);
|
||||
if (saved && planningKey()) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
return next.length !== ids.length && write(next);
|
||||
const saved = next.length !== ids.length && write(next);
|
||||
if (saved) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
@@ -81,7 +128,10 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const retired = ids.filter(id => !retained.includes(id));
|
||||
if (retired.length) {
|
||||
const reported = onPrune?.(retired);
|
||||
if (reported !== false) write(retained);
|
||||
if (reported !== false) {
|
||||
write(retained);
|
||||
replacePlanning(planning());
|
||||
}
|
||||
}
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
@@ -99,7 +149,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
|
||||
function runway(items, currentIndex = 0) {
|
||||
const estimates = planning().estimates;
|
||||
const remaining = (items || []).slice(Math.max(0, currentIndex));
|
||||
const minutes = remaining.map(item => estimates[identity(item)] || null);
|
||||
return {
|
||||
current_minutes: minutes[0] || null,
|
||||
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
Reference in new issue
Block a user