feat: share private Today summaries (Closes #1090)
This commit is contained in:
1 parent
5f86d48e6f
commit
b3a359760c
16 files changed
+556
-11
No files matched your search
@@ -342,6 +342,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-wrap-up-item input { width:24px; min-height:24px; }
|
||||
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-wrap-up-actions button { min-height:44px; width:100%; }
|
||||
.today-summary-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-summary-sheet[hidden] { display:none; }
|
||||
.today-summary-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-summary-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-summary-header h2 { margin:.2rem 0; }
|
||||
.today-summary-header button { min-height:44px; min-width:44px; }
|
||||
.today-summary-items { display:grid; gap:8px; }
|
||||
.today-summary-item, .today-summary-option { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid #2a496e; border-radius:10px; background:#10233d; overflow-wrap:anywhere; }
|
||||
.today-summary-item input, .today-summary-option input { width:24px; min-height:24px; flex:0 0 auto; }
|
||||
.today-summary-item span { display:block; min-width:0; }
|
||||
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
|
||||
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
|
||||
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
|
||||
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-summary-actions button { min-height:44px; width:100%; }
|
||||
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
||||
|
||||
@@ -1902,18 +1902,21 @@
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
|
||||
todaySummaryView.resume();
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:() => {
|
||||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||||
todayRecapView['completeReplan']();
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
todaySummaryView.open(workedItems, tomorrowItems);
|
||||
},
|
||||
});
|
||||
const todayRecapView = setupTodayRecap(
|
||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||||
() => planningOwnerLogin,
|
||||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||||
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes)
|
||||
(actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
|
||||
@@ -507,6 +507,29 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-summary-sheet" id="today-summary-sheet" role="dialog" aria-modal="true" aria-labelledby="today-summary-title" hidden>
|
||||
<section class="today-summary-panel">
|
||||
<div class="today-summary-header">
|
||||
<div><div class="small">Ready to communicate</div><h2 id="today-summary-title">Share day summary</h2></div>
|
||||
<button id="close-today-summary" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Review exactly what will leave this device. Time stays private unless you include it.</p>
|
||||
<div id="today-summary-status" class="small" role="status" aria-live="polite"></div>
|
||||
<h3>Worked on</h3>
|
||||
<div id="today-summary-worked" class="today-summary-items"></div>
|
||||
<h3>Tomorrow</h3>
|
||||
<div id="today-summary-tomorrow" class="today-summary-items"></div>
|
||||
<label class="today-summary-option"><input id="today-summary-include-actuals" type="checkbox"> Include actual time</label>
|
||||
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
|
||||
<h3>Preview</h3>
|
||||
<pre id="today-summary-preview" class="today-summary-preview"></pre>
|
||||
<div class="today-summary-actions">
|
||||
<button id="share-today-summary" type="button">Share summary</button>
|
||||
<button id="discard-today-summary" type="button">Discard draft</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
@@ -1813,6 +1836,7 @@
|
||||
<script src="static/today-session-sync.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-wrap-up.js"></script>
|
||||
<script src="static/today-summary.js"></script>
|
||||
<script src="static/today-handoff.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
|
||||
@@ -58,6 +58,7 @@ const SHELL = [
|
||||
BASE + 'static/today-session-sync.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
BASE + 'static/today-summary.js',
|
||||
BASE + 'static/today-handoff.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
|
||||
@@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
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 {
|
||||
@@ -290,7 +291,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
openWrapUp(handoff.actual_minutes);
|
||||
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; }
|
||||
@@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
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 {
|
||||
@@ -310,7 +312,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
openWrapUp(actualMinutes);
|
||||
openWrapUp(actualMinutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
let workedItems = [];
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
@@ -75,8 +76,9 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}) {
|
||||
function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
workedItems = recapWorkedItems.map(item => ({...item}));
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
@@ -89,10 +91,15 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
|
||||
identity:row.identity,
|
||||
title:String(row.item.title || row.identity).slice(0, 180),
|
||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||
}));
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
close();
|
||||
onComplete(result, actualMinutes);
|
||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user