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(); 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) { 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(); } 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 = {}; capacityAware = false; } function cancel() { close(); return true; } 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]; 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, planned_minutes: plannedMinutes, remaining_minutes: remainingMinutes, unestimated_count: draftIds.length - Object.keys(selectedEstimates).length, over_capacity: remainingMinutes !== null && remainingMinutes < 0, }; } 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, commit, snapshot, item, candidates }; } if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;