Repeating tasks and subtasks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4625848048
commit
e26b38a119
6 files changed
+119
-19
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user