360 lines
15 KiB
JavaScript
360 lines
15 KiB
JavaScript
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) => '<label class="today-summary-item"><input type="checkbox" data-summary-section="' +
|
|
section + '" data-summary-identity="' + escapeHtml(row.identity) + '"' + (row.include ? ' checked' : '') +
|
|
'><span><strong>' + escapeHtml(row.title) + '</strong><span class="small muted">' +
|
|
escapeHtml(row.context) + '</span></span></label>';
|
|
|
|
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('') ||
|
|
'<p class="small muted">No worked items selected.</p>';
|
|
qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') ||
|
|
'<p class="small muted">Nothing scheduled for tomorrow.</p>';
|
|
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;
|
|
}
|