Files
hub/public/calendar.js
T

255 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
async function loadRange(start, days, withTasks = true) {
const end = new Date(start); end.setDate(end.getDate() + days);
const last = new Date(end.getTime() - 1);
const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
const [{ events, errors }, due] = await Promise.all([jfetch('/api/cal/events?' + q),
withTasks && allowed('tasks') ? jfetch(`/api/tasks/due?from=${ymd(start)}&to=${ymd(last)}`).catch(() => []) : []]);
const byDay = new Map();
const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
for (const e of events) {
if (e.allDay) { // spans start..end-1 (end exclusive)
for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
} else put(ymd(new Date(e.start)), e);
}
for (const t of due) { // tasks with a due date show up on that day, after the all-day events
if (!byDay.has(t.due)) byDay.set(t.due, []);
byDay.get(t.due).push({ task: true, t, title: t.title, color: '#8a93a6', allDay: true, start: t.due, end: t.due, cal: t.listName || 'Tasks', editable: true });
}
for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || (!!a.task - !!b.task) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
return { byDay, errors, start };
}
const loadAgenda = (days, withTasks = true) => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days, withTasks); };
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evStore = []; // events currently on screen, so a tap can open the right one
const evRow = e => { const i = evStore.push(e) - 1;
if (e.task) return `<div class="row it"><button class="ibtn tick" data-tt="${i}" aria-label="Mark done">${icon('check')}</button><button class="rmain" data-ei="${i}" style="text-align:left"><span class="grow"><b>${esc(e.title)}</b><small>Task due${e.t.repeat && e.t.repeat !== 'none' ? ' · repeats' : ''} · ${esc(e.cal)}</small></span></button></div>`;
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
document.addEventListener('click', e => {
const tk = e.target.closest('[data-tt]');
if (tk) { const ev = evStore[+tk.dataset.tt]; tk.disabled = true; jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: ev.t.list, id: ev.t.id, href: ev.t.href, done: true }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); route(); }).catch(err => { tk.disabled = false; toast(err.message); }); return; }
const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]);
});
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
async function renderAgenda(el, mode) { // Home card (today only)
if (!el) return;
try {
const cals = await jfetch('/api/cal/list');
const { byDay } = await loadAgenda(1, false); // (the Tasks card on Home already covers what is due)
const list = byDay.get(ymd(new Date())) || [];
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
if (!document.body.contains(el)) return;
el.innerHTML = `<h2>Today</h2><div class="list">${list.length ? list.slice(0, 6).map(evRow).join('') : '<div class="row"><small>Nothing on the calendar today</small></div>'}</div>`;
} catch {}
}
const now0 = new Date();
const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
async function viewCalendar() {
const mode = store.get('cal_view', 'month');
const v = $('#view');
v.innerHTML = `<div class="wrap">${plannerHead('calendar', `<button class="btn" id="ne">${icon('cal')}Add event</button>`)}
<div class="seg">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
$('#mc').onclick = manageCalendars;
try {
const cals = await jfetch('/api/cal/list');
if (!cals.length) { $('#cbody').innerHTML = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#ac').onclick = addCalendar; return; }
if (mode === 'month') await renderMonth(); else if (mode === 'week') await renderTimeGrid(7); else if (mode === 'day') await renderTimeGrid(1); else await renderAgendaList();
} catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
async function renderAgendaList() {
const range = store.get('cal_range', 7);
const { byDay, errors } = await loadAgenda(range);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
const keys = [...byDay.keys()].sort();
$('#cbody').innerHTML = `<div class="seg">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<a href="#" data-n="${n}" class="${n === range ? 'on' : ''}">${l}</a>`).join('')}</div>`
+ (keys.map(k => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
+ errors.map(e => `<p class="err">${esc(e)}</p>`).join('');
$('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
}
async function renderMonth() {
const { y, m } = calState;
const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st
const { byDay, errors } = await loadRange(gridStart, 42);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
const today = ymd(new Date());
if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first);
const cells = [];
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart); d.setDate(d.getDate() + i);
const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m;
cells.push(`<button class="cday${other ? ' dim' : ''}${k === today ? ' today' : ''}${k === calState.sel ? ' sel' : ''}" data-d="${k}" aria-label="${esc(d.toDateString())}, ${evs.length} events">
<span class="dn">${d.getDate()}</span>
<span class="dots">${evs.slice(0, 4).map(e => `<i style="background:${esc(e.color)}"></i>`).join('')}</span>
<span class="chips">${evs.slice(0, 3).map(e => `<em style="--c:${esc(e.color)}">${e.task ? '☐ ' : ''}${esc(e.title)}</em>`).join('')}${evs.length > 3 ? `<small>+${evs.length - 3} more</small>` : ''}</span></button>`);
}
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="pm" aria-label="Previous month">${icon('back')}</button>
<h2 style="margin:0;flex:1;text-align:center;font-size:17px;text-transform:none;letter-spacing:0;color:var(--text)">${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}</h2>
<button class="ibtn" id="nm" aria-label="Next month" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tm" style="min-height:36px">Today</button></div>
<div class="cgrid wk">${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `<span>${d}</span>`).join('')}</div>
<div class="cgrid">${cells.join('')}</div>
<div id="dayev"></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
const showDay = () => {
const k = calState.sel, evs = byDay.get(k) || [];
$('#dayev').innerHTML = `<h2>${esc(dayLabel(k))}</h2><div class="list">${evs.length ? evs.map(evRow).join('') : '<div class="row"><small>No events</small></div>'}</div>`;
$('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k));
};
showDay();
$('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); });
const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
$('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1);
$('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
}
const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const pad2 = n => String(n).padStart(2, '0');
const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
function openEvent(e) {
if (!e) return;
if (e.task) { // a task on the calendar: the normal task sheet, and the calendar refreshes when it closes
taskSheet({ ...e.t, repeat: e.t.repeat || 'none' }, e.t.list, { name: e.cal, local: String(e.t.list).startsWith('local:') });
const sh = document.getElementById('sheet'), mo = new MutationObserver(() => { if (!document.getElementById('sheet')) { mo.disconnect(); if (/^#\/calendar/.test(location.hash)) route(); } });
if (sh) mo.observe(document.body, { childList: true });
return;
}
if (e.editable) return editEvent(e);
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
}
async function editEvent(e) {
try {
const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
const r = await jfetch('/api/cal/event?' + q);
eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
} catch (err) { toast(err.message); }
}
async function newEvent(day) {
try {
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
const d = new Date(day + 'T00:00:00'), now = new Date();
if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
const end = new Date(d.getTime() + 36e5);
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
} catch (err) { toast(err.message); }
}
function eventForm(o) {
const f = { ...o.fields }, editing = o.mode === 'edit';
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`;
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
<div id="fdt"></div>
<label for="fr">Repeat</label><select id="fr">${opts.map(([v, l]) => `<option value="${v}" ${v === f.repeat ? 'selected' : ''}>${l}</option>`).join('')}</select>
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
${calPick}
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
<div class="err" id="fe"></div></form>`);
el.querySelector('.sheet').classList.add('wide');
const draw = () => { // date-only inputs for all-day, date+time otherwise
const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
el.querySelector('#fdt').innerHTML = `<label for="fs">Starts</label><input type="${t}" id="fs" value="${cut(f.start)}" required><label for="fe2">Ends</label><input type="${t}" id="fe2" value="${cut(f.end)}" required>
${!f.allDay && f.tz !== deviceTz() ? `<p class="sub" style="margin:6px 0 0">Times are in ${esc(f.tz)}.</p>` : ''}`;
const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
s.onchange = () => { // moving the start keeps the length
const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
};
en.onchange = () => { f.end = en.value; };
};
draw();
el.querySelector('#fa').onchange = ev => {
f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
f.allDay = ev.target.checked;
if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
draw();
};
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#ef').onsubmit = async ev => {
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
const cal = editing ? o.calId : el.querySelector('#fc').value;
try {
await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
if (!editing) store.set('cal_default', cal);
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
};
const del = el.querySelector('#fd');
if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
catch (err) { toast(err.message); }
});
}
function addCalendar() {
const el = openSheet('Add a calendar link', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
<label>Private calendar link (ICS)</label><input type="url" id="cu2" placeholder="https://… or webcal://…" autocomplete="off" autocapitalize="off" required>
<p class="sub" style="margin:10px 0 0">Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.</p>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Add</button></div><div class="err" id="ce"></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); }
catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
};
}
async function manageCalendars() {
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
const links = cals.filter(c => !c.local && !c.icloud);
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div>
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')}
</div>
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div>
<h2 style="margin-top:14px">iCloud calendars</h2>
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div>
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
: `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p>
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Connect iCloud</button></div><div class="err" id="ice"></div></form>`}
<h2 style="margin-top:14px">See NE Hub events in other apps</h2>
<p class="sub">Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).</p>
<input type="text" readonly id="fu" value="${esc(feed.url)}" onfocus="this.select()">
<div class="bar"><button class="btn ghost" id="fc2" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" id="fr2" style="flex:1;justify-content:center">Make a new link</button></div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide');
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
const icf = el.querySelector('#icf');
if (icf) icf.onsubmit = async e => {
e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
};
el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
}