stackchain-dashboard/frontend/today-summary.js
timmy b3a359760c
Some checks failed
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m9s
CI / release-candidate (pull_request) Has been skipped
feat: share private Today summaries (Closes #1090)
2026-08-18 18:39:01 +00:00

224 lines
8.4 KiB
JavaScript

function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
let draft = null;
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-summary-draft.v1.' + login : '';
};
const copyRows = rows => rows.map(row => ({ ...row }));
const snapshot = () => draft ? {
...draft,
worked:copyRows(draft.worked),
tomorrow:copyRows(draft.tomorrow),
} : 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');
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
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) {
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:'',
};
persist();
return snapshot();
},
snapshot,
restore() {
draft = load();
return Boolean(draft);
},
choose(section, identity, include) {
const row = draft?.[section]?.find(candidate => candidate.identity === identity);
if (!row || typeof include !== 'boolean') return false;
row.include = include;
persist();
return true;
},
includeActuals(include) {
if (!draft || typeof include !== 'boolean') return false;
draft.include_actuals = include;
persist();
return true;
},
setNote(note) {
if (!draft) return false;
draft.note = String(note || '').trim().slice(0, 1000);
persist();
return true;
},
discard,
text() {
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-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;
}
}
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('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
}
return {
open, close, render, bind, shareDraft,
resume() { if (summary.restore()) show(); },
};
}
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
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,
});
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;
}