function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null, resolveTarget = null, enqueueDurably = null, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2), } = {}) { let draft = null; let draftKey = ''; let postPromise = null; const storageKey = () => { const login = String(getLogin() || '').trim().toLowerCase(); return login ? 'stackchain.today-summary-draft.v1.' + login : ''; }; const syncAccount = () => { const key = storageKey(); if (key !== draftKey) { draft = null; draftKey = key; } }; const copyRows = rows => rows.map(row => ({ ...row })); const snapshot = () => { syncAccount(); return draft ? { ...draft, worked:copyRows(draft.worked), tomorrow:copyRows(draft.tomorrow), target:draft.target ? { ...draft.target } : null, } : null; }; const persist = () => { const key = storageKey(); if (storage && key && draft) storage.setItem(key, JSON.stringify(draft)); }; const validRows = (rows, includeTime) => Array.isArray(rows) && rows.length <= 20 && rows.every(row => row && typeof row.identity === 'string' && row.identity.length > 0 && row.identity.length <= 500 && typeof row.title === 'string' && row.title.length <= 180 && typeof row.context === 'string' && row.context.length <= 180 && typeof row.include === 'boolean' && (!includeTime || (Number.isInteger(row.actual_minutes) && row.actual_minutes >= 0 && row.actual_minutes <= 1440)) ); const load = () => { const key = storageKey(); if (!storage || !key) return null; try { const saved = JSON.parse(storage.getItem(key) || 'null'); const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target && typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) && Number.isInteger(saved.target.number) && saved.target.number > 0 && ['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' && typeof saved.target.state === 'string')); if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) || typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 || !['string', 'undefined'].includes(typeof saved.destination) || !validTarget || !['string', 'undefined'].includes(typeof saved.operation_id)) { if (saved !== null) storage.removeItem(key); return null; } return saved; } catch (_error) { try { storage.removeItem(key); } catch (_ignored) {} return null; } }; const cleanRow = (row, includeTime) => { const cleaned = { identity:String(row.identity), title:String(row.title || row.label || row.identity).slice(0, 180), context:String(row.context || '').slice(0, 180), }; if (includeTime) cleaned.actual_minutes = Math.min(1440, Math.max(0, Number(row.actual_minutes) || 0)); cleaned.include = true; return cleaned; }; const selectedLines = (rows, showTime = false) => rows.filter(row => row.include).map(row => '- ' + row.title + (showTime ? ' (' + row.actual_minutes + 'm)' : '') ); const discard = () => { const key = storageKey(); if (storage && key) storage.removeItem(key); draft = null; }; return { begin(worked, tomorrow) { draftKey = storageKey(); draft = { worked:(worked || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, true)), tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)), include_actuals:false, note:'', destination:'', target:null, operation_id:'', }; persist(); return snapshot(); }, snapshot, restore() { draftKey = storageKey(); draft = load(); return Boolean(draft); }, choose(section, identity, include) { syncAccount(); const row = draft?.[section]?.find(candidate => candidate.identity === identity); if (!row || typeof include !== 'boolean') return false; row.include = include; persist(); return true; }, includeActuals(include) { syncAccount(); if (!draft || typeof include !== 'boolean') return false; draft.include_actuals = include; persist(); return true; }, setNote(note) { syncAccount(); if (!draft) return false; draft.note = String(note || '').trim().slice(0, 1000); persist(); return true; }, setDestination(value) { syncAccount(); if (!draft) return null; const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i); draft.destination = String(value || '').trim().slice(0, 260); draft.target = null; draft.operation_id = ''; persist(); if (!match) return null; const repository = (match[1] + '/' + match[2]).toLowerCase(); return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) }; }, async validateDestination() { syncAccount(); const parsed = this.setDestination(draft?.destination || ''); if (!parsed) throw new Error('Enter a destination like owner/repo#42.'); if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.'); const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number }); if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number || !['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.'); draft.target = { repository:parsed.repository, number:parsed.number, kind:resolved.kind, title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''), }; draft.operation_id = String(createOperationId()).slice(0, 128); persist(); return { ...draft.target }; }, postSummary() { if (postPromise) return postPromise; syncAccount(); const body = this.text(); if (!body) throw new Error('Select at least one summary item or add a note.'); if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.'); if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.'); const target = { ...draft.target }; const message = { kind:'search-reply', repository:target.repository, number:target.number, targetKind:target.kind, body, operationId:draft.operation_id, }; postPromise = (async () => { try { const admission = await enqueueDurably(message); discard(); return { status:'queued', durability:admission?.durability || 'foreground-only', target }; } finally { postPromise = null; } })(); return postPromise; }, discard, text() { syncAccount(); if (!draft) return ''; const sections = []; const worked = selectedLines(draft.worked, draft.include_actuals); const tomorrow = selectedLines(draft.tomorrow); if (worked.length) sections.push(['Today', ...worked].join('\n')); if (tomorrow.length) sections.push(['Tomorrow', ...tomorrow].join('\n')); if (draft.note) sections.push('Note\n' + draft.note); return sections.join('\n\n'); }, async shareSummary() { const text = this.text(); if (!text) throw new Error('Select at least one summary item or add a note.'); if (typeof share === 'function') { try { await share({ title:'Today summary', text }); discard(); return { status:'shared' }; } catch (error) { if (error?.name === 'AbortError') return { status:'canceled' }; throw error; } } if (typeof copy !== 'function') throw new Error('Sharing is unavailable on this device.'); await copy(text); discard(); return { status:'copied' }; }, }; } function createTodaySummaryView({ summary, qs, escapeHtml }) { const rowMarkup = (section, row) => ''; function close() { qs('#today-summary-sheet').hidden = true; document.body.classList.remove('task-overlay-open'); qs('#start-work-session')?.focus(); } function render() { const draft = summary.snapshot(); if (!draft) return; qs('#today-summary-worked').innerHTML = draft.worked.map(row => rowMarkup('worked', row)).join('') || '
No worked items selected.
'; qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') || 'Nothing scheduled for tomorrow.
'; qs('#today-summary-include-actuals').checked = draft.include_actuals; qs('#today-summary-note').value = draft.note; qs('#today-summary-preview').textContent = summary.text(); qs('#today-summary-destination').value = draft.destination || ''; qs('#today-summary-target').textContent = draft.target ? (draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : ''; qs('#post-today-summary').disabled = !draft.target; qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => { input.addEventListener('change', () => { summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked); render(); }); }); } function show() { qs('#today-summary-status').textContent = ''; render(); qs('#today-summary-sheet').hidden = false; document.body.classList.add('task-overlay-open'); requestAnimationFrame(() => (qs('#today-summary-worked input') || qs('#share-today-summary')).focus()); } function open(worked, tomorrow) { summary.begin(worked, tomorrow); show(); } async function shareDraft(button) { button.disabled = true; qs('#today-summary-status').textContent = 'Opening share options…'; try { const result = await summary.shareSummary(); if (result.status === 'canceled') { qs('#today-summary-status').textContent = 'Share canceled. Your private draft is still here.'; return; } qs('#my-work-action-status').textContent = result.status === 'shared' ? 'Today summary shared.' : 'Today summary copied to your clipboard.'; close(); } catch (error) { qs('#today-summary-status').textContent = error.message || 'Summary could not be shared. Your draft is unchanged.'; } finally { button.disabled = false; } } async function validateDestination(button) { button.disabled = true; qs('#today-summary-status').textContent = 'Checking destination…'; try { await summary.validateDestination(); render(); qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.'; } catch (error) { qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.'; } finally { button.disabled = false; } } async function postDraft(button) { button.disabled = true; qs('#today-summary-status').textContent = 'Saving comment for delivery…'; try { const result = await summary.postSummary(); qs('#my-work-action-status').textContent = 'Today summary queued for ' + result.target.repository + '#' + result.target.number + '.'; close(); } catch (error) { qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.'; button.disabled = false; } } function bind() { qs('#close-today-summary').addEventListener('click', close); qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); }); qs('#today-summary-include-actuals').addEventListener('change', event => { summary.includeActuals(event.currentTarget.checked); render(); }); qs('#today-summary-note').addEventListener('input', event => { summary.setNote(event.currentTarget.value); qs('#today-summary-preview').textContent = summary.text(); }); qs('#today-summary-destination').addEventListener('input', event => { summary.setDestination(event.currentTarget.value); qs('#today-summary-target').textContent = ''; qs('#post-today-summary').disabled = true; }); qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget)); qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget)); qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget)); } return { open, close, render, bind, shareDraft, validateDestination, postDraft, resume() { if (summary.restore()) show(); }, }; } function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) { const targetResolver = resolveTarget || (async target => { if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.'); const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number + '/preview?kind=issue'); if (String(item.repository || '').toLowerCase() !== target.repository || Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) { throw new Error('That exact issue or pull request is not visible to this account.'); } return { repository:target.repository, number:target.number, kind:item.kind, title:item.title || '', state:item.state || '' }; }); const summary = createTodaySummary({ storage:localStorage, getLogin, share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null, copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null, resolveTarget:targetResolver, enqueueDurably, }); const view = createTodaySummaryView({ summary, qs, escapeHtml }); view.bind(); return view; } if (typeof module !== 'undefined' && module.exports) { createTodaySummary.createView = createTodaySummaryView; createTodaySummary.setup = setupTodaySummary; module.exports = createTodaySummary; }