'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 = `

Today

${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`; } 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'); const views = `
${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `${l}`).join('')}
` + (mode === 'agenda' ? `` : ''); v.innerHTML = `
${plannerHead('calendar', ``, views)}

Loading…

`; $('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel); if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); }; v.querySelectorAll('.sgrp 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 = '

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 => `

${esc(dayLabel(k))}

${byDay.get(k).map(evRow).join('')}
`).join('') || '

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 = `

${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}

${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `${d}`).join('')}
${cells.join('')}
${errors.map(e => `

${esc(e)}

`).join('')}`; const showDay = () => { const k = calState.sel, evs = byDay.get(k) || []; $('#dayev').innerHTML = `

${esc(dayLabel(k))}

${evs.length ? evs.map(evRow).join('') : '
No events
'}
`; $('#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, `
When${esc(when)}
${e.location ? `
Where${esc(e.location)}
` : ''}

${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 ? `
Calendar${esc(o.calName)}
` : ``; const el = openSheet(editing ? 'Edit event' : 'New event', '', `
${calPick} ${editing && o.series ? '

This repeats. Changes apply to the whole series.

' : ''}
${editing ? '
' : ''}
`); 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 = ` ${!f.allDay && f.tz !== deviceTz() ? `

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', '', `

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.

`); 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 => `
${CAL_COLORS.map(c => ``).join('')}
`; // Wires swatches() inside root; calls onPick(hex) whenever the color changes. function wireSwatches(root, onPick) { root.querySelectorAll('.sw[data-c]').forEach(b => b.onclick = () => { root.querySelectorAll('.sw').forEach(x => x.classList.toggle('on', x === b)); onPick(b.dataset.c); }); const cu = root.querySelector('.sw.custom input'); if (cu) cu.oninput = () => { root.querySelectorAll('.sw').forEach(x => x.classList.remove('on')); onPick(cu.value); }; } function colorSheet(c) { const el = openSheet(c.name, 'Calendar color', `${swatches(c.color)}
`); el.querySelector('#sx').onclick = () => { closeSheet(); viewCalendar(); }; wireSwatches(el, async hex => { try { await jfetch('/api/cal/color', { method: 'POST', body: JSON.stringify({ id: c.id, color: hex }) }); } catch (e) { toast(e.message); } }); } // Create (c = null) or edit one of your NE Hub calendars: name, color, who it is shared with (view or edit), subscribe link, delete. async function calendarSheet(c) { const people = c ? await jfetch('/api/cal/people').catch(() => []) : []; const st = { name: c ? c.name : '', color: c ? c.color : CAL_COLORS[Math.floor(Math.random() * CAL_COLORS.length)], shares: Object.fromEntries(((c && c.shares) || []).map(x => [x.user, x.mode])) }; const el = openSheet(c ? 'Edit calendar' : 'New calendar', '', `
${swatches(st.color)} ${c ? `

Sharing

Private unless you share it. For each person you choose whether they can only look, or add and change events too.

${people.length ? `
${people.map(p => `
${esc(p.name)}
`).join('')}
` : '

Nobody else has an account on this hub yet.

'}

Subscribe link

Show only this calendar in Outlook, Google or Apple Calendar (read-only there). Anyone with the link can see its events.

` : ''}
${c ? '
' : ''}
`); el.querySelector('.sheet').classList.add('wide'); wireSwatches(el, hex => { st.color = hex; }); el.querySelectorAll('[data-sh]').forEach(sel => sel.onchange = () => { if (sel.value) st.shares[sel.dataset.sh] = sel.value; else delete st.shares[sel.dataset.sh]; }); el.querySelector('#sx').onclick = () => { closeSheet(); manageCalendars(); }; el.querySelector('#cf').onsubmit = async ev => { ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#ce').textContent = ''; const name = el.querySelector('#cn').value.trim(); try { if (c) await jfetch('/api/cal/ncal/update', { method: 'POST', body: JSON.stringify({ id: c.id, name, color: st.color, shares: Object.entries(st.shares).map(([user, mode]) => ({ user, mode })) }) }); else await jfetch('/api/cal/ncal', { method: 'POST', body: JSON.stringify({ name, color: st.color }) }); closeSheet(); toast(c ? 'Calendar saved' : 'Calendar created'); viewCalendar(); } catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; } }; if (!c) return; const link = async reset => (await jfetch('/api/cal/ncal/feed', { method: 'POST', body: JSON.stringify({ id: c.id, reset }) })).url; el.querySelector('#cl').onclick = async () => { try { const u = await link(false); await navigator.clipboard.writeText(u); toast('Link copied'); } catch (e) { toast(e.message || 'Could not copy'); } }; el.querySelector('#cr').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { try { await navigator.clipboard.writeText(await link(true)); toast('New link copied'); } catch (e) { toast(e.message); } }); el.querySelector('#cd').onclick = () => confirmSheet('Delete ' + c.name + '?', 'Every event in it is deleted too' + (Object.keys(st.shares).length ? ', and the people it is shared with lose it' : '') + '. This cannot be undone.', 'Delete', async () => { try { await jfetch('/api/cal/ncal/delete', { method: 'POST', body: JSON.stringify({ id: c.id }) }); toast('Calendar deleted'); viewCalendar(); } catch (e) { toast(e.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), mine = cals.filter(c => c.local), icol = Object.fromEntries(cals.filter(c => c.icloud).map(c => [c.id, c.color])); const dot = c => ``; const el = openSheet('Calendars', '', `

NE Hub calendars

${mine.map(c => `
${dot(c)}${esc(c.name)}${c.shared ? 'Shared by ' + esc(c.owner) + ' · ' + (c.mode === 'edit' ? 'you can edit' : 'view only') : c.shares && c.shares.length ? 'Shared with ' + c.shares.length + (c.shares.length === 1 ? ' person' : ' people') : 'Private'}${c.shared ? '' : ``}
`).join('')}

Calendar links

${links.length ? links.map(c => `
${dot(c)}${esc(c.name)}Link (read-only)
`).join('') : '
None yet
'}

iCloud calendars

${ic.connected ? `
${ic.calendars.map(c => `
${dot({ id: c.id, name: c.name, color: icol[c.id] || c.color })}${esc(c.name)}
`).join('')}

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.

`}

See NE Hub events in other apps

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(); }); }