200 lines
7.0 KiB
JavaScript
200 lines
7.0 KiB
JavaScript
function createPlanToday({ identity, save, start, limit = 5 }) {
|
|
let openState = false;
|
|
let draftIds = [];
|
|
let itemsById = new Map();
|
|
let capacityMinutes = null;
|
|
let estimates = {};
|
|
let recommendations = {};
|
|
let recommendationAware = false;
|
|
let capacityAware = false;
|
|
let buildState = null;
|
|
let buildReadiness = null;
|
|
|
|
function cleanItems(items) {
|
|
const unique = new Map();
|
|
for (const item of items || []) {
|
|
const id = identity?.(item);
|
|
if (id && !unique.has(id)) unique.set(id, item);
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
function open(selectedItems, candidates, planning = null, actualMinutes = 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;
|
|
}
|
|
recommendations = {};
|
|
recommendationAware = actualMinutes !== null;
|
|
buildState = null;
|
|
buildReadiness = null;
|
|
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
|
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
|
estimates[id] !== minutes) recommendations[id] = minutes;
|
|
}
|
|
if (Object.keys(recommendations).length) capacityAware = true;
|
|
openState = true;
|
|
return snapshot();
|
|
}
|
|
|
|
function toggle(item) {
|
|
if (!openState) return 'closed';
|
|
const id = identity?.(item);
|
|
if (!id) return 'unavailable';
|
|
itemsById.set(id, item);
|
|
const index = draftIds.indexOf(id);
|
|
if (index >= 0) {
|
|
draftIds.splice(index, 1);
|
|
return 'removed';
|
|
}
|
|
if (draftIds.length >= limit) return 'full';
|
|
draftIds.push(id);
|
|
return 'added';
|
|
}
|
|
|
|
function move(id, direction) {
|
|
const index = draftIds.indexOf(id);
|
|
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
|
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
|
|
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
|
|
return true;
|
|
}
|
|
|
|
function close() {
|
|
openState = false;
|
|
draftIds = [];
|
|
itemsById = new Map();
|
|
capacityMinutes = null;
|
|
estimates = {};
|
|
recommendations = {};
|
|
recommendationAware = false;
|
|
capacityAware = false;
|
|
buildState = null;
|
|
buildReadiness = null;
|
|
}
|
|
|
|
function cancel() {
|
|
close();
|
|
return true;
|
|
}
|
|
|
|
function setCapacity(minutes) {
|
|
capacityAware = true;
|
|
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
|
|
return snapshot();
|
|
}
|
|
|
|
function setEstimate(id, minutes) {
|
|
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
|
|
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
|
|
capacityAware = true;
|
|
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
|
|
estimates[id] = minutes;
|
|
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
|
|
return true;
|
|
}
|
|
|
|
function applyRecommendation(id) {
|
|
const minutes = recommendations[id];
|
|
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
|
|
estimates[id] = minutes;
|
|
delete recommendations[id];
|
|
capacityAware = true;
|
|
return true;
|
|
}
|
|
|
|
function buildRecommendation(readiness = {}) {
|
|
if (!openState) return null;
|
|
buildReadiness = { ...readiness };
|
|
const selected = [];
|
|
const reasons = {};
|
|
const skipped = [];
|
|
const needsEstimate = [];
|
|
let used = 0;
|
|
for (const [id, item] of itemsById) {
|
|
if (selected.length >= limit) break;
|
|
const check = readiness[id] || { status:'unverified' };
|
|
if (check.status !== 'ready') {
|
|
skipped.push({
|
|
id,
|
|
status:check.status === 'blocked' ? 'blocked' : 'unverified',
|
|
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
|
|
});
|
|
continue;
|
|
}
|
|
const minutes = estimates[id];
|
|
if (!Number.isInteger(minutes)) {
|
|
needsEstimate.push(id);
|
|
continue;
|
|
}
|
|
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
|
|
selected.push(id);
|
|
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
|
|
used += minutes;
|
|
}
|
|
draftIds = selected;
|
|
capacityAware = true;
|
|
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
|
|
return buildState;
|
|
}
|
|
|
|
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
|
if (!openState) return 'closed';
|
|
const ids = [...draftIds];
|
|
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);
|
|
return 'saved';
|
|
}
|
|
|
|
function snapshot() {
|
|
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,
|
|
...(recommendationAware ? { recommendations:Object.fromEntries(
|
|
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
|
|
) } : {}),
|
|
planned_minutes: plannedMinutes,
|
|
remaining_minutes: remainingMinutes,
|
|
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
|
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
|
...(buildState ? { build:buildState } : {}),
|
|
};
|
|
}
|
|
|
|
function item(id) {
|
|
return itemsById.get(id) || null;
|
|
}
|
|
|
|
function candidates() {
|
|
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
|
}
|
|
|
|
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|