Tasks tab: NE Hub lists plus iCloud reminder lists where Apple exposes them
build-container / build (push) Successful in 1m43s
build-container / build (push) Successful in 1m43s
- Lists, quick add, due dates, notes, complete/reopen, delete; Home card for overdue/due today - iCloud VTODO lists read and written over CalDAV (If-Match, edits patch the latest copy so priority/alarms survive); NE Hub lists work regardless - Fix: text inside a nested VALARM was read as the task's / event's own description and then written back on edit; nested components are now skipped (regression tests added) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c7917e57c
commit
916882e55d
9 files changed
+307
-15
No files matched your search
+101
-4
@@ -40,6 +40,7 @@ const ICONS = {
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
|
||||
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
|
||||
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
|
||||
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||
};
|
||||
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
||||
@@ -97,9 +98,9 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
|
||||
const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
|
||||
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||
const phone = matchMedia('(max-width: 767px)');
|
||||
let activeTab = 'home';
|
||||
|
||||
@@ -122,7 +123,7 @@ function openMore() {
|
||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
}
|
||||
|
||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
||||
let editor;
|
||||
function route() {
|
||||
const [, id = 'home', ...rest] = location.hash.split('/');
|
||||
@@ -151,6 +152,7 @@ function viewHome() {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
|
||||
<a class="searchbar" href="#/search">${icon('search')}<span>Search files, music, libraries</span></a>
|
||||
${t ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
|
||||
<div id="tcard"></div>
|
||||
<div id="agenda"></div>
|
||||
${recents.length ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
|
||||
<h2>Apps</h2><div class="grid">
|
||||
@@ -170,6 +172,7 @@ function viewHome() {
|
||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
||||
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
||||
});
|
||||
renderTasksCard($('#tcard'));
|
||||
if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
|
||||
}
|
||||
|
||||
@@ -487,6 +490,100 @@ async function manageCalendars() {
|
||||
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(); });
|
||||
}
|
||||
|
||||
// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes
|
||||
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
|
||||
const taskStore = []; // tasks on screen, so a tap can open the right one
|
||||
|
||||
async function viewTasks() {
|
||||
const v = $('#view');
|
||||
v.innerHTML = '<div class="wrap"><h1>Tasks</h1><p class="empty">Loading…</p></div>';
|
||||
try {
|
||||
const { lists, icloud } = await jfetch('/api/tasks/lists');
|
||||
if (!location.hash.startsWith('#/tasks')) return;
|
||||
let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
|
||||
const list = lists.find(l => l.id === cur);
|
||||
v.innerHTML = `<div class="wrap"><h1>Tasks</h1>
|
||||
<div class="seg">${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
||||
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
||||
<div id="tl"><p class="empty">Loading…</p></div>
|
||||
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
||||
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
|
||||
: icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
|
||||
v.querySelectorAll('.seg a[data-l]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('task_list', a.dataset.l); viewTasks(); });
|
||||
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
|
||||
$('#qa').onsubmit = async e => {
|
||||
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
|
||||
catch (err) { toast(err.message); }
|
||||
};
|
||||
loadTaskList(cur, list);
|
||||
} catch (e) { v.innerHTML = `<div class="wrap"><h1>Tasks</h1><p class="err">${esc(e.message)}</p></div>`; }
|
||||
}
|
||||
|
||||
async function loadTaskList(cur, list) {
|
||||
const box = $('#tl'); if (!box) return;
|
||||
try {
|
||||
const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
|
||||
if (!$('#tl')) return;
|
||||
taskStore.length = 0;
|
||||
const open = tasks.filter(t => !t.done).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title));
|
||||
const done = tasks.filter(t => t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
|
||||
const row = t => { const i = taskStore.push(t) - 1, du = dueInfo(t.due);
|
||||
return `<div class="row it${t.done ? ' donetask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
|
||||
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
|
||||
const showDone = store.get('task_show_done', false);
|
||||
box.innerHTML = (open.length ? `<div class="list">${open.map(row).join('')}</div>` : '<p class="empty">Nothing to do. Add a task above.</p>')
|
||||
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(row).join('')}</div>` : ''}` : '');
|
||||
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
|
||||
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
|
||||
const t = taskStore[+b.dataset.t]; b.disabled = true;
|
||||
try { await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); loadTaskList(cur, list); }
|
||||
catch (err) { b.disabled = false; toast(err.message); }
|
||||
});
|
||||
box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list));
|
||||
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
}
|
||||
|
||||
function taskSheet(t, cur, list) {
|
||||
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
|
||||
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
|
||||
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
|
||||
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
|
||||
<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">Save</button></div>
|
||||
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
|
||||
el.querySelector('#tf').onsubmit = async e => {
|
||||
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked } }) }); closeSheet(); loadTaskList(cur, list); }
|
||||
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
|
||||
};
|
||||
el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title, 'Delete', async () => {
|
||||
try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); }
|
||||
catch (err) { toast(err.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function newListSheet(lists, list) {
|
||||
const mine = lists.filter(l => l.local && l.id !== 'local:inbox');
|
||||
const el = openSheet('Lists', '', `${mine.length ? `<div class="list" style="background:var(--panel2);border:0">${mine.map(l => `<div class="row"><span class="grow"><b>${esc(l.name)}</b></span><button class="btn danger" data-rm="${esc(l.id)}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
|
||||
<form id="lf"><label for="ln">New list</label><input type="text" id="ln" placeholder="Groceries" autocomplete="off" required>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Add list</button></div><div class="err" id="le"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#lf').onsubmit = async e => { e.preventDefault(); try { const r = await jfetch('/api/tasks/lists', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#ln').value }) }); store.set('task_list', r.id); closeSheet(); viewTasks(); } catch (err) { el.querySelector('#le').textContent = err.message; } };
|
||||
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
|
||||
}
|
||||
|
||||
// Home: a card only when something is overdue or due today
|
||||
async function renderTasksCard(el) {
|
||||
if (!el) return;
|
||||
try {
|
||||
const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today;
|
||||
if (!n || !document.body.contains(el)) return;
|
||||
el.innerHTML = `<h2>Tasks</h2><a class="row card2" href="#/tasks">${icon('check')}<span class="grow"><b>${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention</b><small>${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}</small></span>${icon('chev', 'chev')}</a>`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function viewMail() {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
|
||||
<p class="sub">Opens each provider's own webmail in a new tab. Nothing is stored here.</p>
|
||||
|
||||
@@ -232,3 +232,11 @@ textarea { padding: 12px 14px; resize: vertical; font-family: inherit; }
|
||||
|
||||
/* Several kept-alive frames share #audiowrap; only the active one is shown (the others stay loaded, out of sight) */
|
||||
#audiowrap iframe:not(.act) { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; border: 0; }
|
||||
|
||||
/* Tasks */
|
||||
.tick { flex: none; color: var(--muted); }
|
||||
.tick svg { width: 26px; height: 26px; }
|
||||
.donetask .rmain b { text-decoration: line-through; color: var(--muted); }
|
||||
.donetask .tick { color: var(--accent); }
|
||||
.donetask .tick circle { fill: var(--accent); stroke: var(--accent); }
|
||||
.donetask .tick path { stroke: var(--accent-t); }
|
||||
Reference in new issue
Block a user