feat: export Agenda calendar snapshots (Closes #1088)
This commit is contained in:
1 parent
1c6f5c685f
commit
1c17fcf337
9 files changed
+425
-2
No files matched your search
@@ -0,0 +1,180 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r?\n/g, '\\n')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/;/g, '\\;');
|
||||
}
|
||||
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return match ? match.slice(1).join('') : '';
|
||||
}
|
||||
|
||||
function nextDay(day) {
|
||||
const date = new Date(Date.UTC(
|
||||
Number(day.slice(0, 4)), Number(day.slice(4, 6)) - 1, Number(day.slice(6, 8)) + 1
|
||||
));
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
function uid(item) {
|
||||
const repository = String(item.repository || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||
return `issue-${Number(item.number)}@${repository}`;
|
||||
}
|
||||
|
||||
function foldLine(line) {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [];
|
||||
let chunk = '';
|
||||
let bytes = 0;
|
||||
let limit = 75;
|
||||
for (const character of String(line)) {
|
||||
const width = encoder.encode(character).length;
|
||||
if (chunk && bytes + width > limit) {
|
||||
chunks.push(chunk);
|
||||
chunk = character;
|
||||
bytes = width;
|
||||
limit = 74;
|
||||
} else {
|
||||
chunk += character;
|
||||
bytes += width;
|
||||
}
|
||||
}
|
||||
chunks.push(chunk);
|
||||
return chunks.join('\r\n ');
|
||||
}
|
||||
|
||||
function serializeAgendaCalendar(items, { generatedOn } = {}) {
|
||||
const stampDay = String(generatedOn || new Date().toISOString().slice(0, 10).replace(/-/g, ''));
|
||||
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Stackchain//Agenda Snapshot//EN',
|
||||
'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Stackchain Agenda'];
|
||||
(items || []).forEach(item => {
|
||||
const day = calendarDay(item.due_date);
|
||||
if (!day || !Number.isInteger(Number(item.number)) || !item.repository) return;
|
||||
lines.push(
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid(item)}`,
|
||||
`DTSTAMP:${stampDay}T000000Z`,
|
||||
`DTSTART;VALUE=DATE:${day}`,
|
||||
`DTEND;VALUE=DATE:${nextDay(day)}`,
|
||||
`SUMMARY:${escapeText(item.title)}`,
|
||||
`DESCRIPTION:${escapeText(`${item.repository}#${item.number} · Stackchain Agenda snapshot`)}`,
|
||||
`URL:${String(item.url || '')}`,
|
||||
'TRANSP:TRANSPARENT',
|
||||
'END:VEVENT',
|
||||
);
|
||||
});
|
||||
lines.push('END:VCALENDAR');
|
||||
return lines.map(foldLine).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
async function deliverCalendarSnapshot({
|
||||
text,
|
||||
filename,
|
||||
navigator,
|
||||
document,
|
||||
urlApi,
|
||||
FileCtor,
|
||||
}) {
|
||||
const file = new FileCtor([text], filename, { type: 'text/calendar;charset=utf-8' });
|
||||
const sharePayload = { files: [file], title: 'Stackchain Agenda', text: 'Agenda calendar snapshot' };
|
||||
if (typeof navigator?.share === 'function' && typeof navigator?.canShare === 'function' &&
|
||||
navigator.canShare(sharePayload)) {
|
||||
await navigator.share(sharePayload);
|
||||
return 'shared';
|
||||
}
|
||||
const href = urlApi.createObjectURL(file);
|
||||
try {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
} finally {
|
||||
urlApi.revokeObjectURL(href);
|
||||
}
|
||||
return 'downloaded';
|
||||
}
|
||||
|
||||
function mountAgendaCalendarExport({
|
||||
qs,
|
||||
getItems,
|
||||
escapeHtml,
|
||||
onDone,
|
||||
windowObject = root,
|
||||
navigatorObject = root.navigator,
|
||||
documentObject = root.document,
|
||||
urlApi = root.URL,
|
||||
FileCtor = root.File,
|
||||
}) {
|
||||
const sheet = qs('#agenda-export-sheet');
|
||||
let items = [];
|
||||
let scrollY = 0;
|
||||
let trigger = null;
|
||||
const selectedItems = () => Array.from(qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]'))
|
||||
.filter(checkbox => checkbox.checked)
|
||||
.map(checkbox => items[Number(checkbox.value)])
|
||||
.filter(Boolean);
|
||||
const updateSelection = () => {
|
||||
const selected = selectedItems();
|
||||
qs('#share-agenda-export').disabled = selected.length === 0;
|
||||
qs('#agenda-export-status').textContent = selected.length + ' of ' + items.length +
|
||||
(items.length === 1 ? ' deadline selected.' : ' deadlines selected.');
|
||||
};
|
||||
const close = () => {
|
||||
sheet.close();
|
||||
windowObject.scrollTo({ top:scrollY, behavior:'instant' });
|
||||
trigger?.focus();
|
||||
};
|
||||
qs('#open-agenda-export').addEventListener('click', event => {
|
||||
items = getItems();
|
||||
scrollY = windowObject.scrollY;
|
||||
trigger = event.currentTarget;
|
||||
qs('#agenda-export-items').innerHTML = items.map((item, index) =>
|
||||
'<label class="agenda-export-item"><input type="checkbox" value="' + index + '" checked> ' +
|
||||
'<span><strong>' + escapeHtml(item.title) + '</strong><small>' +
|
||||
escapeHtml(item.repository + '#' + item.number + ' · ' + item.due_date.slice(0, 10)) +
|
||||
'</small></span></label>'
|
||||
).join('');
|
||||
qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]').forEach(checkbox =>
|
||||
checkbox.addEventListener('change', updateSelection)
|
||||
);
|
||||
updateSelection();
|
||||
sheet.showModal();
|
||||
qs('#cancel-agenda-export').focus();
|
||||
});
|
||||
qs('#cancel-agenda-export').addEventListener('click', close);
|
||||
sheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
qs('#share-agenda-export').addEventListener('click', async () => {
|
||||
const selected = selectedItems();
|
||||
if (!selected.length) return;
|
||||
const button = qs('#share-agenda-export');
|
||||
button.disabled = true;
|
||||
qs('#agenda-export-status').textContent = 'Preparing calendar snapshot…';
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const text = serializeAgendaCalendar(selected, { generatedOn:day.replace(/-/g, '') });
|
||||
const result = await deliverCalendarSnapshot({
|
||||
text, filename:'stackchain-agenda-' + day + '.ics',
|
||||
navigator:navigatorObject, document:documentObject, urlApi, FileCtor,
|
||||
});
|
||||
onDone(result);
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#agenda-export-status').textContent = error?.name === 'AbortError' ?
|
||||
'Share cancelled. Nothing was exported.' : 'Calendar export failed. Retry without leaving Agenda.';
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const api = { calendarDay, deliverCalendarSnapshot, escapeText, foldLine, mountAgendaCalendarExport, serializeAgendaCalendar };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.StackchainAgendaCalendar = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -419,6 +419,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
|
||||
.agenda-replan-actions label { grid-column:1/-1; }
|
||||
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
|
||||
.agenda-export { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.agenda-export p { margin:4px 0 0; }
|
||||
.agenda-export button { min-height:44px; flex:0 0 auto; }
|
||||
.agenda-export-sheet { box-sizing:border-box; width:min(560px,100%); max-width:none; max-height:none; height:100dvh; margin:0 0 0 auto; padding:0; border:0; color:var(--text); background:#0b1526; }
|
||||
.agenda-export-sheet::backdrop { background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.agenda-export-panel { box-sizing:border-box; min-height:100%; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow:auto; overflow-x:hidden; }
|
||||
.agenda-export-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.agenda-export-panel h2, .agenda-export-panel header p { margin:0; }
|
||||
#agenda-export-items { min-width:0; display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.agenda-export-item { min-width:0; min-height:44px; display:flex; align-items:center; gap:10px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; }
|
||||
.agenda-export-item input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.agenda-export-item span, .agenda-export-item strong, .agenda-export-item small { min-width:0; display:block; overflow-wrap:anywhere; }
|
||||
#cancel-agenda-export, #share-agenda-export { min-height:44px; }
|
||||
#share-agenda-export { position:sticky; bottom:0; width:100%; margin-top:auto; }
|
||||
@media(max-width:430px) { .agenda-export { align-items:stretch; flex-direction:column; } .agenda-export button { width:100%; } .agenda-export-panel { padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); } }
|
||||
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.protect-today p { margin:4px 0; }
|
||||
.protect-today button { min-height:44px; flex:0 0 auto; }
|
||||
|
||||
@@ -597,6 +597,16 @@
|
||||
window.location.hash = '#/my-work/agenda';
|
||||
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
||||
});
|
||||
|
||||
StackchainAgendaCalendar.mountAgendaCalendarExport({
|
||||
qs,
|
||||
getItems:() => agendaMyWork(activeMyWork),
|
||||
escapeHtml,
|
||||
onDone:result => {
|
||||
qs('#my-work-action-status').textContent = result === 'shared' ?
|
||||
'Agenda calendar snapshot shared.' : 'Agenda calendar snapshot downloaded.';
|
||||
},
|
||||
});
|
||||
let searchReplyAttachmentTarget = null;
|
||||
let searchReplyRestoreGeneration = 0;
|
||||
let restoringSearchReplyPhotos = false;
|
||||
@@ -3513,6 +3523,14 @@
|
||||
qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')';
|
||||
qs('#agenda-replan-controls').hidden = true;
|
||||
}
|
||||
const agendaExport = qs('#agenda-export');
|
||||
const agendaExportButton = qs('#open-agenda-export');
|
||||
agendaExport.hidden = selectedWorkFilter !== 'agenda';
|
||||
agendaExportButton.disabled = queueItems.length === 0;
|
||||
qs('#agenda-export-empty').textContent = queueItems.length ?
|
||||
'Review ' + queueItems.length + (queueItems.length === 1 ? ' deadline' : ' deadlines') +
|
||||
' before sharing a calendar snapshot.' :
|
||||
'No deadlines are available to export from this Agenda.';
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
const visible = findQueueItems(queueItems, queueFindQuery);
|
||||
const emptyWorkStart = qs('#empty-work-start');
|
||||
|
||||
@@ -277,6 +277,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="agenda-export" id="agenda-export" aria-labelledby="agenda-export-heading" hidden>
|
||||
<div>
|
||||
<strong id="agenda-export-heading">Take deadlines with you</strong>
|
||||
<p class="small muted" id="agenda-export-empty">Review this Agenda before sharing a calendar snapshot.</p>
|
||||
</div>
|
||||
<button id="open-agenda-export" type="button">Export to calendar</button>
|
||||
</section>
|
||||
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
|
||||
<div>
|
||||
<strong id="protect-today-heading">Deadlines need a plan</strong>
|
||||
@@ -312,6 +319,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<dialog class="agenda-export-sheet" id="agenda-export-sheet" aria-labelledby="agenda-export-title">
|
||||
<section class="agenda-export-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Calendar handoff</p><h2 id="agenda-export-title">Review Agenda export</h2></div>
|
||||
<button id="cancel-agenda-export" type="button">Cancel</button>
|
||||
</header>
|
||||
<p>This exports a snapshot, not ongoing synchronization. Only checked deadline details leave Stackchain.</p>
|
||||
<fieldset id="agenda-export-items" aria-describedby="agenda-export-privacy"></fieldset>
|
||||
<p class="small muted" id="agenda-export-privacy">Your issue titles, repositories, dates, and forge links are included in the calendar file.</p>
|
||||
<p class="small" id="agenda-export-status" role="status" aria-live="polite"></p>
|
||||
<button id="share-agenda-export" type="button">Share calendar file</button>
|
||||
</section>
|
||||
</dialog>
|
||||
<section class="insights-shell" id="insights-sheet" aria-labelledby="insights-heading">
|
||||
<div class="insights-header">
|
||||
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
|
||||
@@ -1780,6 +1800,7 @@
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/agenda-calendar.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
|
||||
@@ -45,6 +45,7 @@ const SHELL = [
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
|
||||
Reference in new issue
Block a user