diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 785280f..01ab1f6 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -19,6 +19,7 @@ jobs: - run: npm ci - run: npm audit --omit=dev --audit-level=high - run: node tools/smoke-test.js + - run: node tools/tasks-test.js # repeating tasks and subtasks - run: node tools/users-test.js # accounts: private data, tab limits, invites, migration - run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself - run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery diff --git a/package.json b/package.json index a957dfe..22ee15f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke users plex player lyrics signal webmail; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks users plex player lyrics signal webmail; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/app.js b/public/app.js index faa87f9..f1b1d72 100644 --- a/public/app.js +++ b/public/app.js @@ -678,18 +678,23 @@ async function loadTaskList(cur, list) { 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 `
-
`; }; + 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 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 extra = [rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · '); + return `
+
`; }; const showDone = store.get('task_show_done', false); - box.innerHTML = (open.length ? `
${open.map(row).join('')}
` : '

Nothing to do. Add a task above.

') - + (done.length ? `

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

${showDone ? `
${done.map(row).join('')}
` : ''}` : ''); + 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.

') + + (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 () => { 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); } + try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); 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)); @@ -700,16 +705,27 @@ function taskSheet(t, cur, list) { const el = openSheet('Task', list.name, `
+ ${list.local ? ` +

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

` : ''} + ${list.local && t.id && !t.parent ? `
${taskStore.filter(k => k.parent === t.id).map(k => `
${esc(k.title)}
`).join('')}
+
` : ''}
`); 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); } + 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(); 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 () => { + if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => { + const inp = el.querySelector('#tsub'), title = inp.value.trim(); if (!title) return; + try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title, notes: '', due: '', done: false, parent: t.id } }) }); + el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `
${esc(title)}
`); inp.value = ''; inp.focus(); loadTaskList(cur, list); } + catch (err) { el.querySelector('#te').textContent = err.message; } + }; + if (el.querySelector('#tsub')) el.querySelector('#tsub').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); el.querySelector('#tsubadd').click(); } }; + el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title + (taskStore.some(k => k.parent === t.id) ? ' and its subtasks' : ''), '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); } }); diff --git a/public/style.css b/public/style.css index 36c0076..dd1a54f 100644 --- a/public/style.css +++ b/public/style.css @@ -501,3 +501,6 @@ body.lyropen { overflow: hidden; } .skip { position: fixed; left: 8px; top: -60px; z-index: 80; padding: 10px 14px; border-radius: 10px; background: var(--accent); color: var(--accent-t); } .skip:focus { top: calc(var(--sat) + 8px); } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.row.subtask { padding-left: 34px; } +.row.subtask .tick { transform: scale(.88); } diff --git a/tasks.js b/tasks.js index dcd113b..13d4853 100644 --- a/tasks.js +++ b/tasks.js @@ -38,6 +38,39 @@ function parseTodos(text) { } return out.map(t => ({ id: t.uid, title: t.title || '(No title)', notes: t.notes || '', due: t.due || '', done: t.status === 'COMPLETED' || t.percent === 100 || !!t.completed })); } + +// ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it. +const REPEATS = ['none', 'daily', 'weekdays', 'weekly', 'monthly', 'yearly']; +const toYmd = d => d.toISOString().slice(0, 10); +const fromYmd = s => { const [y, m, d] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)); }; +function stepDate(s, repeat) { + const d = fromYmd(s), day = d.getUTCDate(); + if (repeat === 'daily') d.setUTCDate(day + 1); + else if (repeat === 'weekdays') { do d.setUTCDate(d.getUTCDate() + 1); while ([0, 6].includes(d.getUTCDay())); } + else if (repeat === 'weekly') d.setUTCDate(day + 7); + else if (repeat === 'monthly') { d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() + 1); d.setUTCDate(Math.min(day, new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate())); } + else if (repeat === 'yearly') { d.setUTCDate(1); d.setUTCFullYear(d.getUTCFullYear() + 1); d.setUTCDate(Math.min(day, new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate())); } + return toYmd(d); +} +/** The next due date after finishing a repeating task: one step after the due date, and always after today. */ +function nextDue(due, repeat, today = toYmd(new Date())) { + let n = stepDate(due || today, repeat), guard = 0; + while (n <= today && guard++ < 4000) n = stepDate(n, repeat); + return n; +} +const cleanRepeat = r => REPEATS.includes(r) ? r : 'none'; +/** Mark a local task done; a repeating one rolls forward instead. Returns the new due date when it rolled. */ +function finishLocal(t, done) { + if (done && !t.done && t.repeat && t.repeat !== 'none') { t.due = nextDue(t.due, t.repeat); t.done = false; t.completedAt = null; t.lastDone = Date.now(); t.updated = Date.now(); return t.due; } + t.done = !!done; t.completedAt = t.done ? Date.now() : null; t.updated = Date.now(); return null; +} +function validParent(d, raw, parent, selfId) { + if (!parent) return ''; + const p = d.tasks.find(x => x.id === parent && x.list === raw); + if (!p || p.parent || parent === selfId) throw new Error('A subtask can only go under a main task in the same list'); + if (selfId && d.tasks.some(x => x.parent === selfId)) throw new Error('A task that has subtasks cannot become a subtask'); + return parent; +} const fieldsOf = t => ({ title: t.title, notes: t.notes, due: t.due, done: t.done }); async function handle(req, res) { @@ -64,7 +97,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 }))); + 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 || '' }))); } if (caldav.todoLists().some(l => l.id === list)) { const rows = await caldav.listTodoRaw(list), tasks = []; @@ -81,10 +114,15 @@ async function handle(req, res) { if (!d.lists.some(l => l.id === raw)) throw new Error('List not found'); 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; Object.assign(t, { title: f.title.trim(), notes: f.notes || '', due: f.due || '', done: !!f.done, updated: Date.now() }); - if (t.done && !wasDone) t.completedAt = Date.now(); if (!t.done) t.completedAt = null; - if (b.moveTo && isLocal(b.moveTo)) t.list = b.moveTo.slice(6); - } else 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, created: Date.now(), updated: Date.now() }); + 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() }); + 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() }); save(d); return json(res, 200, { ok: true }); } if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive @@ -97,7 +135,7 @@ async function handle(req, res) { const b = JSON.parse(await readBody(req)); if (isLocal(b.list)) { const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found'); - t.done = !!b.done; t.completedAt = t.done ? Date.now() : null; t.updated = Date.now(); save(d); return json(res, 200, { ok: true }); + const rolled = finishLocal(t, !!b.done); save(d); return json(res, 200, { ok: true, rolled }); } const cur = await caldav.getResource(b.list, b.href); const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task'); @@ -106,7 +144,7 @@ async function handle(req, res) { } if (p === '/api/tasks/delete' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); - if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id); save(d); return json(res, 200, { ok: true }); } + if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id && x.parent !== b.id); save(d); return json(res, 200, { ok: true }); } await caldav.deleteEvent(b.list, b.href, b.etag); return json(res, 200, { ok: true }); } if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today @@ -128,4 +166,4 @@ async function allTasks() { return out; } -module.exports = { handle, parseTodos, allTasks }; +module.exports = { handle, parseTodos, allTasks, nextDue, stepDate }; diff --git a/tools/tasks-test.js b/tools/tasks-test.js new file mode 100644 index 0000000..7c3cea7 --- /dev/null +++ b/tools/tasks-test.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// Tasks: repeating tasks roll forward, subtasks nest one level and go away with their parent. +const { spawn } = require('child_process'); +const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto'); +const root = path.join(__dirname, '..'), dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-tasks-')); +const port = 20000 + Math.floor(Math.random() * 20000), base = `http://127.0.0.1:${port}`; +let failed = 0; +const ok = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (cond ? '' : ' -> ' + JSON.stringify(extra))); if (!cond) failed++; }; +const SECRET = 'tasks-'.repeat(8), UID = 'utasks'; +fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] })); +const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET } }); +const exp = Date.now() + 864e5, cookie = `hub_session=${UID}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url')}`; +const call = async (p, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); return { status: r.status, json: await r.json().catch(() => ({})) }; }; +const ymd = d => d.toISOString().slice(0, 10), plus = n => { const d = new Date(); d.setUTCDate(d.getUTCDate() + n); return ymd(d); }; +const wait = ms => new Promise(r => setTimeout(r, ms)); +(async () => { + for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } } + const L = 'local:inbox', list = async () => (await call('/api/tasks?list=' + L)).json; + await call('/api/tasks', { list: L, fields: { title: 'Water plants', notes: '', due: plus(-3), done: false, repeat: 'daily' } }); + await call('/api/tasks', { list: L, fields: { title: 'Plain', notes: '', due: '', done: false } }); + let ts = await list(), rep = ts.find(t => t.title === 'Water plants'), plain = ts.find(t => t.title === 'Plain'); + ok('a task remembers that it repeats', rep.repeat === 'daily' && plain.repeat === 'none', ts); + let r = await call('/api/tasks/toggle', { list: L, id: rep.id, done: true }); + ts = await list(); rep = ts.find(t => t.title === 'Water plants'); + ok('completing a repeating task moves it to the next date (after today) and keeps it open', r.json.rolled === plus(1) && rep.due === plus(1) && !rep.done, [r.json, rep]); + r = await call('/api/tasks/toggle', { list: L, id: plain.id, done: true }); ts = await list(); + ok('a plain task is simply completed', ts.find(t => t.title === 'Plain').done === true && !r.json.rolled, r.json); + r = await call('/api/tasks', { list: L, id: rep.id, fields: { title: 'Water plants', notes: '', due: rep.due, done: false, repeat: 'bogus' } }); ts = await list(); + ok('an unknown repeat is treated as none', ts.find(t => t.id === rep.id).repeat === 'none', ts); + await call('/api/tasks', { list: L, fields: { title: 'Sub 1', notes: '', due: '', done: false, parent: rep.id } }); + ts = await list(); const sub = ts.find(t => t.title === 'Sub 1'); + ok('a subtask is stored under its parent', sub && sub.parent === rep.id, ts); + r = await call('/api/tasks', { list: L, fields: { title: 'Deep', notes: '', due: '', done: false, parent: sub.id } }); + ok('a subtask cannot have subtasks of its own', r.status === 400, r); + r = await call('/api/tasks', { list: L, fields: { title: 'Orphan', notes: '', due: '', done: false, parent: 'nope' } }); + ok('a missing parent is refused', r.status === 400, r); + r = await call('/api/tasks/delete', { list: L, id: rep.id }); + ts = await list(); ok('deleting the parent deletes its subtasks', !ts.some(t => t.id === rep.id || t.id === sub.id), ts); + 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); +})();