function todayRecapFeedbackRows(draft, describe = () => null) { return (draft?.items || []).map(item => { const work = describe(item.identity) || {}; const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null; return { identity:item.identity, label:String(work.title || item.identity).slice(0, 180), context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180), estimate_minutes:estimate, actual_minutes:item.actual_minutes, variance_minutes:estimate === null ? null : item.actual_minutes - estimate, }; }); } function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) { 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 storageKey = () => { const login = String(getLogin() || '').trim().toLowerCase(); return login ? 'stackchain.today-recap-draft.v1.' + login : ''; }; const handoffKey = () => { const login = String(getLogin() || '').trim().toLowerCase(); return login ? 'stackchain.today-recap-handoff.v1.' + login : ''; }; const validStoredDraft = saved => { if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 || !Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false; const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 && item.identity.length <= 500 && (item.estimate_minutes === null || (Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) && Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440); if (!itemsValid) return false; const identities = new Set(saved.items.map(item => item.identity)); if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) || saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false; if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' || Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) => !identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false; return true; }; const load = () => { const key = storageKey(); if (!storage || !key) return null; try { const saved = JSON.parse(storage.getItem(key) || 'null'); if (!validStoredDraft(saved)) { if (saved !== null) storage.removeItem(key); return null; } return { ...saved, ...totals(saved.items) }; } catch (_error) { try { storage.removeItem(key); } catch (_ignored) {} return null; } }; const validHandoff = saved => { if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 || !saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false; const entries = Object.entries(saved.actual_minutes); return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) => identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440 ); }; const pendingReplan = () => { const key = handoffKey(); if (!storage || !key) return null; try { const saved = JSON.parse(storage.getItem(key) || 'null'); if (!validHandoff(saved)) { if (saved !== null) storage.removeItem(key); return null; } return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} }; } catch (_error) { try { storage.removeItem(key); } catch (_ignored) {} return null; } }; const completeReplan = () => { const key = handoffKey(); if (storage && key) storage.removeItem(key); }; let draftKey = storageKey(); let draft = load(); const persist = () => { const key = storageKey(); if (!storage || !key || !draft) return; const saved = { session_id:draft.session_id, items:draft.items }; if (draft.log_identities) saved.log_identities = draft.log_identities; if (draft.time_logs) saved.time_logs = draft.time_logs; storage.setItem(key, JSON.stringify(saved)); }; const restore = () => { const key = storageKey(); if (key !== draftKey) { draft = null; draftKey = key; } if (!draft) draft = load(); return Boolean(draft); }; const snapshot = () => { restore(); return draft ? { ...draft, items:draft.items.map(item => ({...item})), ...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}), ...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}), } : null; }; const saveConfirmed = async includeActuals => { restore(); 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 actualMinutes = includeActuals ? Object.fromEntries( payload.items.map(item => [item.identity, item.actual_minutes]) ) : null; const result = await save(payload); if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({ session_id:payload.session_id, actual_minutes:actualMinutes, })); clear(); if (storage && draftKey) storage.removeItem(draftKey); draft = null; return includeActuals ? { result, actual_minutes:actualMinutes } : result; }; const saveWithTime = async identities => { restore(); if (!draft?.items.length) throw new Error('No timed work to save.'); if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.'); const requested = new Set(Array.isArray(identities) ? identities : []); const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity)); const intended = [...new Set([...(draft.log_identities || []), ...requested])] .filter(identity => eligible.has(identity)); if (!intended.length) throw new Error('Select at least one non-zero time entry.'); draft.log_identities = intended; draft.time_logs = draft.time_logs || {}; persist(); const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity])); const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})), log_identities:logIdentities, }; const result = await saveAndLog(payload); Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status]))); persist(); const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length; const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length; const retry = intended.length - logged - verify; if (retry || verify) { if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') + ' verification in Gitea; it will not be posted again automatically.'); throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.'); } clear(); if (storage && draftKey) storage.removeItem(draftKey); draft = null; return result; }; return { restore, 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))), })); draftKey = storageKey(); draft = { session_id:makeId(), items, ...totals(items) }; persist(); return snapshot(); }, correct(identity, minutes) { restore(); 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)); persist(); return true; }, snapshot, save:() => saveConfirmed(false), saveForReplan:() => saveConfirmed(true), saveAndLog:saveWithTime, pendingReplan, completeReplan, discardReplan:completeReplan, }; } 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; } async function saveTodayRecapTime(payload) { const response = await fetch('api/v1/today/recaps/log-time', { 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 time could not be logged.'); return result; } function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml, describeWork = () => null, openWrapUp = () => {} }) { const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm'; const render = () => { const draft = recap.snapshot(); const container = qs('#today-recap-items'); const rows = todayRecapFeedbackRows(draft, describeWork); container.innerHTML = rows.map(item => { const variance = item.variance_minutes === null ? 'No estimate comparison' : (item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) + (item.variance_minutes > 0 ? ' over estimate' : ' under estimate')); const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity); const logStatus = draft?.time_logs?.[item.identity]; const logControl = logStatus === 'logged' ? 'Logged to Gitea' : logStatus === 'verify' ? 'Verify this entry in Gitea' : item.actual_minutes > 0 && canonical ? '' : 'No non-zero Gitea time to log'; return '
' + escapeHtml(item.label) + '' + escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') + ' min' + variance + '' + logControl + '
'; }).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); else recap.restore(); render(); qs('#save-today-recap').hidden = !recap.snapshot()?.items.length; qs('#save-log-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 => { const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork); button.disabled = true; qs('#today-recap-status').textContent = 'Saving recap…'; try { const handoff = await recap.saveForReplan(); qs('#today-recap-status').textContent = 'Recap saved to your account.'; await loadHistory(); render(); button.hidden = true; close(); openWrapUp(handoff.actual_minutes, workedItems); } catch (error) { qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.'; } finally { button.disabled = false; } }; const saveTimeDraft = async button => { const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked')) .map(input => input.dataset.recapLogIdentity); if (!selected.length) { qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.'; return; } const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes])); const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork); button.disabled = true; qs('#today-recap-status').textContent = 'Logging selected time…'; try { await recap.saveAndLog(selected); qs('#today-recap-status').textContent = 'Selected time logged to Gitea.'; await loadHistory(); render(); button.hidden = true; close(); openWrapUp(actualMinutes, workedItems); } catch (error) { qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.'; render(); } 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)); qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget)); }; return { open, close, saveDraft, saveTimeDraft, loadHistory, render, bind, pendingReplan:recap.pendingReplan, completeReplan:recap.completeReplan, discardReplan:recap.discardReplan, }; } 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, getLogin, describeWork, openWrapUp) { const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp }; const recap = createTodayRecap({ save:saveTodayRecap, saveAndLog:saveTodayRecapTime, clear:() => timer.clearRecap(), storage:localStorage, getLogin, }); 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; createTodayRecap.feedbackRows = todayRecapFeedbackRows; module.exports = createTodayRecap; }