From fe651a8c370069319e950c93f0e1fb0f8750afff Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:19:05 -0400 Subject: [PATCH] Tasks: a due time with a reminder, and Today and Upcoming views Co-Authored-By: Claude Sonnet 5.5 --- public/sw.js | 2 +- public/tasks.js | 31 ++++++++++++++++--------------- push.js | 10 ++++++++++ tasks.js | 9 +++++---- tools/push-test.js | 8 ++++++++ tools/tasks-test.js | 3 +++ 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/public/sw.js b/public/sw.js index d6af495..7cee342 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v105'; +const CACHE = 'hub-v106'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/tasks.js b/public/tasks.js index cdb236c..6aebc18 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -1,7 +1,7 @@ 'use strict'; // ---- 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 dueInfo = (d, tm) => { if (!d) return null; const at = tm ? ' · ' + new Date('2000-01-01T' + tm).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' }); + return d < t ? { text: 'Overdue · ' + label + at, color: 'var(--danger)' } : d === t ? { text: 'Today' + at, color: 'var(--accent)' } : { text: label + at, color: 'var(--muted)' }; }; const taskStore = []; // tasks on screen, so a tap can open the right one let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" opens with its own list's options @@ -13,11 +13,11 @@ async function viewTasks() { if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first) if (!location.hash.startsWith('#/tasks')) return; taskLists = lists; - let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together) - const list = cur === 'all' ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes) + let cur = store.get('task_list'); if (!['all', 'today', 'upcoming'].includes(cur) && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together) + const list = ['all', 'today', 'upcoming'].includes(cur) ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes) v.innerHTML = `
${plannerHead('tasks')} -
${lists.length > 1 ? `All` : ''}${lists.map(l => `${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}`).join('')}+ List
-
+
TodayUpcoming${lists.length > 1 ? `All` : ''}${lists.map(l => `${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}`).join('')}+ List
+

Loading…

`; $('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.' @@ -26,7 +26,7 @@ async function 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: list.id, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); } + try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: cur === 'today' ? ymd(new Date()) : '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); } catch (err) { toast(err.message); } }; loadTaskList(cur, list); @@ -36,23 +36,23 @@ async function viewTasks() { async function loadTaskList(cur, list) { const box = $('#tl'); if (!box) return; try { - const all = cur === 'all', byId = Object.fromEntries(taskLists.map(l => [l.id, l])); + const smart = cur === 'today' || cur === 'upcoming', all = cur === 'all' || smart, today = ymd(new Date()), byId = Object.fromEntries(taskLists.map(l => [l.id, l])); const tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => [])))) .flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur)); if (!$('#tl')) return; taskStore.length = 0; const ids = new Set(tasks.map(t => t.id)), byParent = {}; tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); }); - const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title); - const open = tasks.filter(t => top(t) && !t.done).sort(byDue); - const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title)); + const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || (a.dueTime || '').localeCompare(b.dueTime || '') || a.title.localeCompare(b.title); + const open = tasks.filter(t => top(t) && !t.done && (cur === 'today' ? t.due && t.due <= today : cur === 'upcoming' ? t.due && t.due > today : true)).sort(byDue); + const done = smart ? [] : tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title)); const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' }; - const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : ''; + const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due, t.dueTime), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : ''; const extra = [all && t._ln ? t._ln : '', t.by && t.list === 'local:family' ? 'Added by ' + t.by : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · '); return `
`; }; const showDone = store.get('task_show_done', false); - box.innerHTML = (open.length ? `
${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}
` : '

Nothing to do. Add a task above.

') + box.innerHTML = (open.length ? `
${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}
` : (cur === 'today' ? '

Nothing due today. Enjoy the day.

' : cur === 'upcoming' ? '

Nothing coming up with a due date.

' : '

Nothing to do. Add a task above.

')) + (done.length ? `

${showDone ? 'Hide' : 'Show'} completed (${done.length})

${showDone ? `
${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}
` : ''}` : ''); 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 () => { @@ -68,6 +68,7 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task const redraw = () => loadTaskList(viewCur || cur, viewList || list); const el = openSheet('Task', list.name, `
+ ${list.local ? `

Optional. Needs a due date; you get a notification at that time.

` : ''} ${list.local ? `

A repeating task moves to its next date when you complete it.

` : ''} @@ -76,10 +77,10 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
` : ''}
`); - el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; }; + el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; const tt = el.querySelector('#tdt'); if (tt) tt.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, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); } + 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, ...(el.querySelector('#tdt') ? { dueTime: el.querySelector('#tdt').value } : {}), done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); } catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; } }; if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => { diff --git a/push.js b/push.js index 79c1661..ec37017 100644 --- a/push.js +++ b/push.js @@ -79,6 +79,16 @@ async function tick(now = new Date(), force = false) { } } catch (e) { console.warn('[push] tasks check failed:', e.message); } } + if (checkEvents && st.tasks && users.can(u, 'tasks')) { // a task with a due time: one reminder when that time comes (within two hours of it), once + try { + for (const t of (await tasks.allTasks()).filter(x => !x.done && x.due === lt.day && x.dueTime && x.dueTime <= lt.hm)) { + const mins = (+lt.hm.slice(0, 2) - +t.dueTime.slice(0, 2)) * 60 + (+lt.hm.slice(3) - +t.dueTime.slice(3)), key = `taskdue:${t.id}:${t.due}:${t.dueTime}`; + if (mins > 120 || st.sent.includes(key)) continue; + st.sent.push(key); st.sent = st.sent.slice(-300); dirty = true; + await send(dir, st, u.id, { title: t.title, body: 'Due now (' + t.dueTime + ')', url: '#/tasks', tag: key }); + } + } catch (e) { console.warn('[push] task times failed:', e.message); } + } if (st.tasks && users.can(u, 'contacts') && st.datesDay !== lt.day && lt.hm >= st.tasksAt) { // birthdays and anniversaries from Contacts, with the gift ideas, with the morning summary st.datesDay = lt.day; dirty = true; try { diff --git a/tasks.js b/tasks.js index 50c1dc0..569afc0 100644 --- a/tasks.js +++ b/tasks.js @@ -81,6 +81,7 @@ function validParent(d, raw, parent, selfId) { if (selfId && d.tasks.some(x => x.parent === selfId)) throw new Error('A task that has subtasks cannot become a subtask'); return parent; } +const cleanTime = v => /^([01]\d|2[0-3]):[0-5]\d$/.test(String(v || '')) ? String(v) : ''; // a time of day on the due date, for a reminder at that time const fieldsOf = t => ({ title: t.title, notes: t.notes, due: t.due, done: t.done }); async function handle(req, res) { @@ -107,7 +108,7 @@ async function handle(req, res) { const list = q.get('list'); if (isLocal(list)) { const raw = list.slice(6); - return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }))); + return json(res, 200, load().tasks.filter(t => t.list === raw).map(t => ({ id: t.id, list, title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, completedAt: t.completedAt || null, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }))); } if (caldav.todoLists().some(l => l.id === list)) { const rows = await caldav.listTodoRaw(list), tasks = []; @@ -125,14 +126,14 @@ async function handle(req, res) { if (b.id) { const t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found'); const wasDone = !!t.done, repeat = f.repeat === undefined ? t.repeat || 'none' : cleanRepeat(f.repeat); - Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', repeat, updated: Date.now() }); + Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', dueTime: f.due ? cleanTime(f.dueTime) : '', repeat, updated: Date.now() }); if (f.parent !== undefined) t.parent = validParent(d, raw, f.parent, t.id) || undefined; const rolled = finishLocal(t, !!f.done); if (wasDone && f.done) { t.done = true; t.completedAt = t.completedAt || Date.now(); } if (b.moveTo && isLocal(b.moveTo) && !t.parent) { const to = b.moveTo.slice(6); d.tasks.filter(x => x.parent === t.id).forEach(x => { x.list = to; }); t.list = to; } save(d); return json(res, 200, { ok: true, rolled }); } - d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); // (who added it, so a shared list shows whose it was) + d.tasks.push({ id: crypto.randomUUID(), list: raw, title: f.title.trim(), notes: f.notes || '', due: f.due || '', dueTime: f.due ? cleanTime(f.dueTime) : '', done: !!f.done, completedAt: f.done ? Date.now() : null, repeat: cleanRepeat(f.repeat), parent: validParent(d, raw, f.parent, null) || undefined, created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); // (who added it, so a shared list shows whose it was) save(d); return json(res, 200, { ok: true }); } if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive @@ -182,7 +183,7 @@ async function handle(req, res) { /** Every task from every list (NE Hub and iCloud), with its list name. */ async function allTasks() { const d = load(), out = []; - for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); } + for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); } for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} } return out; } diff --git a/tools/push-test.js b/tools/push-test.js index 69e9f98..ec910a8 100644 --- a/tools/push-test.js +++ b/tools/push-test.js @@ -66,6 +66,14 @@ const users = require('../users'), push = require('../push'); ok('a birthday and an anniversary today make one notification', mine.length === 1 && /2 birthdays and anniversaries/.test(mine[0].title) && /Jo's birthday/.test(mine[0].body) && /anniversary \(16\)/.test(mine[0].body), mine); await push.tick(now); ok('and not again the same day', sent.filter(x => x.sub.endpoint.endsWith('/kim')).length === 1); } + // a task with a due time gets a reminder when that time comes, once + { const l = users.create({ name: 'Lee', role: 'user', tabs: ['tasks'] }), ld = users.dirOf(l.id); sent.length = 0; + fs.writeFileSync(path.join(ld, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 'a', list: 'inbox', title: 'Call the vet', due: day, dueTime: '08:30', done: false }, { id: 'b', list: 'inbox', title: 'Later today', due: day, dueTime: '17:00', done: false }, { id: 'c', list: 'inbox', title: 'Done already', due: day, dueTime: '08:00', done: true }] })); + fs.writeFileSync(path.join(ld, 'push.json'), JSON.stringify({ tz: TZ, tasksAt: '23:00', tasks: true, events: false, subs: [{ sub: { endpoint: 'https://push.example/lee', keys: { p256dh: 'x', auth: 'y' } } }] })); + await push.tick(now, true); const lee = () => sent.filter(x => x.sub.endpoint.endsWith('/lee')); + ok('a task whose time has come is reminded, with its time', lee().length === 1 && lee()[0].title === 'Call the vet' && /08:30/.test(lee()[0].body), lee()); + await push.tick(new Date(now.getTime() + 6 * 60e3), true); ok('once only; later and finished tasks are left alone', lee().length === 1, lee()); } + // a subscription the push service says is gone is dropped dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] })); await push.tick(new Date()); diff --git a/tools/tasks-test.js b/tools/tasks-test.js index cc3315c..6a2dfd7 100644 --- a/tools/tasks-test.js +++ b/tools/tasks-test.js @@ -45,6 +45,9 @@ const wait = ms => new Promise(r => setTimeout(r, ms)); r = await call(`/api/tasks/due?from=${plus(0)}&to=${plus(7)}`); ok('the calendar gets open tasks due in the range (not done ones, subtasks or other dates)', r.status === 200 && r.json.length === 1 && r.json[0].title === 'Due soon' && r.json[0].repeat === 'weekly' && r.json[0].listName, r.json); r = await call('/api/tasks/due?from=bad&to=worse'); ok('a bad date range is refused', r.status === 400, r.status); + // a due time rides along with the due date (and only with one) + await call('/api/tasks', { list: L, fields: { title: 'At noon', notes: '', due: plus(1), dueTime: '12:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Time but no date', notes: '', due: '', dueTime: '09:00', done: false } }); await call('/api/tasks', { list: L, fields: { title: 'Bad time', notes: '', due: plus(1), dueTime: '25:99', done: false } }); + ts = await list(); ok('a due time is kept with its date', ts.find(t => t.title === 'At noon').dueTime === '12:00', ts.find(t => t.title === 'At noon')); ok('a time with no date is dropped', ts.find(t => t.title === 'Time but no date').dueTime === ''); ok('and a time that is not a time', ts.find(t => t.title === 'Bad time').dueTime === ''); ok('the server is still running', child.exitCode === null); child.kill(); fs.rmSync(dir, { recursive: true, force: true }); console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);