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')}
-
-
+
+
`;
$('#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, ``);
- 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);