function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID() }) { let draft = null; const totals = items => { const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0); const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0); return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated }; }; const snapshot = () => draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null; return { begin(entries, estimates = {}) { const items = (entries || []).filter(entry => entry?.identity).map(entry => ({ identity:String(entry.identity), estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null, actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))), })); draft = { session_id:makeId(), items, ...totals(items) }; return snapshot(); }, correct(identity, minutes) { if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false; const item = draft.items.find(candidate => candidate.identity === identity); if (!item) return false; item.actual_minutes = minutes; Object.assign(draft, totals(draft.items)); return true; }, snapshot, async save() { if (!draft?.items.length) throw new Error('No timed work to save.'); const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) }; const result = await save(payload); clear(); draft = null; return result; }, }; } async function saveTodayRecap(payload) { const response = await fetch('api/v1/today/recaps', { method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' }, body:JSON.stringify(payload), }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.'); return result; } function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml }) { const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm'; const render = () => { const draft = recap.snapshot(); const container = qs('#today-recap-items'); container.innerHTML = draft?.items.map(item => '' ).join('') || ''; qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' + minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) + (draft.variance_minutes >= 0 ? ' over' : ' under') : ''; container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => { if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) { qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.'; } render(); })); }; const loadHistory = async () => { qs('#today-recap-history').innerHTML = '
Loading recent recaps…
'; try { const result = await api('api/v1/today/recaps'); qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved => '
' + new Date(saved.created_at * 1000).toLocaleString() + '' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) + (saved.variance_minutes >= 0 ? ' over' : ' under') + '
' ).join('') || '
No saved recaps yet.
'; } catch (_error) { qs('#today-recap-history').innerHTML = '
Recent recaps are unavailable. Try again.
'; } }; const open = ({ begin = false } = {}) => { if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates); render(); qs('#save-today-recap').hidden = !recap.snapshot()?.items.length; qs('#today-recap-sheet').hidden = false; document.body.classList.add('task-overlay-open'); loadHistory(); requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus()); }; const close = () => { qs('#today-recap-sheet').hidden = true; document.body.classList.remove('task-overlay-open'); qs('#open-today-recaps').focus(); }; const saveDraft = async button => { button.disabled = true; qs('#today-recap-status').textContent = 'Saving recap…'; try { await recap.save(); qs('#today-recap-status').textContent = 'Recap saved to your account.'; await loadHistory(); render(); button.hidden = true; } catch (error) { qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.'; } finally { button.disabled = false; } }; const bind = () => { qs('#open-today-recaps').addEventListener('click', () => open()); qs('#close-today-recap').addEventListener('click', close); qs('#discard-today-recap').addEventListener('click', close); qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget)); }; return { open, close, saveDraft, loadHistory, render, bind }; } function endTodaySession(workSession, qs) { workSession.end(); qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.'; } function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) { timerView.finish(); document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; }); qs('#my-work-action-status').textContent = 'Work session complete.'; if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true }); else qs('#start-work-session').focus(); } function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions) { const options = { timer, timerView, todayWork, api, qs, escapeHtml }; const recap = createTodayRecap({ save:saveTodayRecap, clear:() => timer.clearRecap() }); const view = createTodayRecapView({ ...options, recap }); view.bind(); setInterval(timerView.render, 1000); view.finish = workFilter => { closeSheets(); updateActions(); openTodayRecapAfterSession(view, timer, timerView, workFilter, qs); }; return view; } if (typeof module !== 'undefined' && module.exports) { createTodayRecap.createView = createTodayRecapView; module.exports = createTodayRecap; }