'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 `
`; return ``; }; 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 = `Loading…
No calendars yet. Add a private calendar link to see your events here.
'; $('#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 = `${esc(e.message)}
`; } } 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 = (keys.map(k => `Nothing coming up
') + errors.map(e => `${esc(e)}
`).join(''); } 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(``); } $('#cbody').innerHTML = `${esc(e)}
`).join('')}`; const showDay = () => { const k = calState.sel, evs = byDay.get(k) || []; $('#dayev').innerHTML = `${e.shared ? esc(e.owner) + ' shared this calendar with you to view only.' : 'This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.'}
`); 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.mode !== 'view') || c.icloud); if (!cals.length) return toast('No calendar here can take new events'); 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: (cals.find(c => c.id === store.get('cal_default', 'local')) || cals[0]).id, 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 ? `Times are in ${esc(f.tz)}.
` : ''}`; 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', '', ``); 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; } }; } const CAL_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#e8559a', '#14a3b8', '#7a8a3a', '#6b7280']; const swatches = cur => `Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.
` : `Read and add events in your iCloud calendars. Use an app-specific password (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.
`}Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).
`); el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar; el.querySelector('#nc').onclick = () => calendarSheet(null); el.querySelectorAll('[data-ed]').forEach(b => b.onclick = () => calendarSheet(mine.find(c => c.id === b.dataset.ed))); el.querySelectorAll('[data-col]').forEach(b => b.onclick = () => colorSheet(cals.find(c => c.id === b.dataset.col) || { id: b.dataset.col, name: 'Calendar', color: b.style.background })); 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(); }); }