From 275ef2009fd3b3f494c9d568d356971b5aa941ca Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:30:40 -0400 Subject: [PATCH] Reading goals for e-books and the family; subtasks on iCloud lists; watch stats in Movies & TV Co-Authored-By: Claude Sonnet 5.5 --- books.js | 3 ++- ics.js | 2 ++ progress.js | 15 +++++++++++++-- public/sw.js | 2 +- public/tasks.js | 2 +- public/today.js | 10 +++++++--- public/video.js | 24 +++++++++++++++++++++++- server.js | 1 + tasks.js | 10 ++++++++-- tools/frontend-test.js | 4 ++++ tools/listening-test.js | 4 ++++ tools/tasks-test.js | 8 ++++++++ 12 files changed, 74 insertions(+), 11 deletions(-) diff --git a/books.js b/books.js index 988a740..3da2ba0 100644 --- a/books.js +++ b/books.js @@ -116,7 +116,8 @@ async function handle(req, res) { const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); } const st = state(), cur = st[id] || {}; if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() }; - saveState(st); return json(res, 200, st[id]); + saveState(st); if (st[id] && st[id].pct >= 0.98) { try { require('./progress').markFinished('ebook:' + id, 'ebook'); } catch { /* the goal count is a nicety */ } } + return json(res, 200, st[id]); } if (part === 'notes') { diff --git a/ics.js b/ics.js index 80cad87..e71b269 100644 --- a/ics.js +++ b/ics.js @@ -172,6 +172,7 @@ function todoLines(f, { completed = true } = {}) { const L = ['SUMMARY:' + esc(String(f.title).trim())]; if (f.notes) L.push('DESCRIPTION:' + esc(f.notes)); if (f.due) L.push('DUE;VALUE=DATE:' + f.due.replace(/-/g, '')); + if (f.parent) L.push('RELATED-TO;RELTYPE=PARENT:' + esc(String(f.parent))); // a subtask points at its main task if (f.done) { L.push('STATUS:COMPLETED', 'PERCENT-COMPLETE:100'); if (completed) L.push('COMPLETED:' + nowStamp()); } else L.push('STATUS:NEEDS-ACTION'); return L; @@ -199,6 +200,7 @@ function patchTodo(text, f) { const name = l.split(/[;:]/)[0].toUpperCase(); if (name === 'COMPLETED') { if (f.done) { hadCompleted = true; out.push(l); } return; } // keep the original completion time if (['SUMMARY', 'DESCRIPTION', 'DUE', 'STATUS', 'PERCENT-COMPLETE'].includes(name)) return; + if (name === 'RELATED-TO' && f.parent !== undefined && !/RELTYPE=(CHILD|SIBLING)/i.test(l)) return; // (replaced below when a parent is given; kept when none is mentioned) if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; } if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; } out.push(l); diff --git a/progress.js b/progress.js index 0b6096d..3507479 100644 --- a/progress.js +++ b/progress.js @@ -13,7 +13,7 @@ const stats = () => { const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60; let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++; const year = String(now.getFullYear()); - return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length }; + return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => day(f.t).startsWith(year)).length, audiobooksYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, ebooksYear: Object.values(o.finished).filter(f => f.kind === 'ebook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length }; }; module.exports = (name, kind) => { const file = () => path.join(users.dir(), name); @@ -31,4 +31,15 @@ module.exports = (name, kind) => { }; return { load, merge }; }; -module.exports.stats = stats; +/** A book (or e-book) finished: counts toward the yearly goals. Once only. */ +function markFinished(key, kind, t = Date.now()) { const log = loadLog(); if (log.finished[key]) return; log.finished[key] = { t, kind }; const lf = logFile(), tmp = lf + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(tmp, lf); } +// The family challenge: one yearly goal for everyone, counted across everybody's finished books. +const goalFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'readgoal.json'); +function familyGoal() { + const year = new Date().getFullYear(); let goal = 0; try { const g = JSON.parse(fs.readFileSync(goalFile(), 'utf8')); if (g.year === year) goal = +g.goal || 0; } catch { /* none set */ } + let finished = 0; const people = []; + for (const u of users.all()) { if (u.disabled) continue; let log = { finished: {} }; try { log = JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'listening.json'), 'utf8')); } catch { /* nothing yet */ } const n = Object.values(log.finished || {}).filter(f => day(f.t).startsWith(String(year))).length; finished += n; if (n) people.push({ name: u.name, n }); } + return { year, goal, finished, people: people.sort((a, b) => b.n - a.n) }; +} +function setFamilyGoal(goal) { const g = Math.max(0, Math.min(1000, Math.round(+goal) || 0)); fs.mkdirSync(path.dirname(goalFile()), { recursive: true }); fs.writeFileSync(goalFile(), JSON.stringify({ year: new Date().getFullYear(), goal: g })); return familyGoal(); } +module.exports.stats = stats; module.exports.markFinished = markFinished; module.exports.familyGoal = familyGoal; module.exports.setFamilyGoal = setFamilyGoal; diff --git a/public/sw.js b/public/sw.js index 343627f..99e61ce 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-v117'; +const CACHE = 'hub-v119'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version 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 6aebc18..eaef623 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -73,7 +73,7 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task ${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('')}
+ ${(list.local || list.icloud) && t.id && !t.parent ? `
${taskStore.filter(k => k.parent === t.id).map(k => `
${esc(k.title)}
`).join('')}
` : ''}
`); diff --git a/public/today.js b/public/today.js index ab1269c..690d547 100644 --- a/public/today.js +++ b/public/today.js @@ -34,11 +34,15 @@ const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' mi async function homeListening(after) { if (!after || !(allowed('podcast') || allowed('audio'))) return; let s; try { s = await jfetch('/api/listening/stats'); } catch { return; } - const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected || (!s.week && !goal && !s.finishedYear)) return; + const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected) return; const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening'; - card.innerHTML = `

Listening

${lsTime(s.week)}this week${s.streak}day${s.streak === 1 ? '' : 's'} in a row${s.finishedYear}${goal ? ' / ' + goal : ''}audiobooks finished this year
+ card.innerHTML = `

Listening

${lsTime(s.week)}this week${s.streak}day${s.streak === 1 ? '' : 's'} in a row${s.finishedYear}${goal ? ' / ' + goal : ''}books finished this year
- ${goal ? `
` : ''}
`; + ${goal ? `
` : ''} +
`; after.insertAdjacentElement('afterend', card); + jfetch('/api/listening/family').then(f => { const box = card.querySelector('#lsfam'); if (!box) return; if (!f.goal && !f.finished) { box.innerHTML = ``; } else box.innerHTML = `
${f.finished}${f.goal ? ' of ' + f.goal : ''}family books finished in ${f.year}${f.people.length ? ' · ' + esc(f.people.map(p => p.name + ' ' + p.n).join(', ')) : ''}
${f.goal ? `
` : ''}`; + const b = box.querySelector('#lsfg'); if (b) b.onclick = () => { const el = openSheet('Family reading challenge', 'One goal for everyone: how many books the family finishes this year (audiobooks and e-books together).', `
`); el.querySelector('#lsff').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/listening/family', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ goal: +el.querySelector('#lsfn').value || 0 }) }); closeSheet(); card.remove(); homeListening(after); } catch (er) { toast(er.message); } }; }; + }).catch(() => {}); card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `
`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; }; } diff --git a/public/video.js b/public/video.js index f2a67e0..b04110e 100644 --- a/public/video.js +++ b/public/video.js @@ -73,6 +73,27 @@ async function vdRemove(m, quiet) { vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey); try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {} } +// ---- Watch stats: what you watched lately, counted from the "last watched" dates Plex keeps for you +/** items: Plex movies or episodes with lastViewedAt (seconds) and duration (ms). Counts and minutes for the last 7, 30 and 365 days, a 14-day strip, and the top genres. */ +function vdAgg(items, now = Date.now()) { + const day = 864e5, out = { week: { n: 0, min: 0 }, month: { n: 0, min: 0 }, year: { n: 0, min: 0 }, days: Array(14).fill(0), genres: [] }, g = {}; + for (const m of items) { + const t = (m.lastViewedAt || 0) * 1000; if (!t || t > now + day) continue; const age = now - t, mins = Math.round((m.duration || 0) / 60000), add = b => { b.n++; b.min += mins; }; + if (age < 7 * day) add(out.week); if (age < 30 * day) add(out.month); if (age < 365 * day) { add(out.year); for (const x of m.Genre || []) g[x.tag] = (g[x.tag] || 0) + 1; } + const di = Math.floor(age / day); if (di >= 0 && di < 14) out.days[13 - di]++; + } + out.genres = Object.entries(g).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5); return out; +} +async function vdStatsSheet(cur) { + const isMovie = cur.type === 'movie', el = openSheet('Your watching', cur.title, '

Counting…

'); + try { + const r = await px.get(`/library/sections/${cur.key}/all?type=${isMovie ? 1 : 4}&sort=lastViewedAt:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=600`), a = vdAgg((r.Metadata || []).filter(m => m.lastViewedAt)), hrs = m => m < 60 ? m + ' min' : (m / 60).toFixed(m < 600 ? 1 : 0) + ' h', max = Math.max(1, ...a.days), what = isMovie ? 'movies' : 'episodes'; + el.querySelector('.empty').outerHTML = `
${[['week', 'this week'], ['month', 'last 30 days'], ['year', 'this year']].map(([k, l]) => `${a[k].n}${what} ${l}${a[k].min ? ' · ' + hrs(a[k].min) : ''}`).join('')}
+ + ${a.genres.length ? `

Favourite genres this year

${a.genres.map(([t, n]) => `${esc(t)} · ${n}`).join('')}
` : ''} + ${a.year.n ? '' : '

Nothing watched in this library yet.

'}

Counts each title once, on the day you last watched it.

`; + } catch (e) { el.querySelector('.empty').textContent = e.message; } +} // ---- Favorites (yours) and Movie night (the family's list to vote on) const vfApi = (p, o) => jfetch('/api/moviefam' + p, o), vfOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) }); const vfCard = x => ({ ratingKey: x.id, type: x.type, title: x.title, year: x.year || undefined, thumb: x.thumb || undefined }); @@ -114,7 +135,8 @@ async function videoHome(st, libKey) { const type = cur.type === 'movie' ? 1 : 2, size = 120; // Mixes: a handful of ways to pick something to watch, from what you have not seen yet { const MIX = [['shuffle', 'Surprise me', 'sort=random', x => true], ['family', 'Family night', 'sort=random&contentRating=' + vdEnc('G,PG,TV-Y,TV-G,TV-PG'), x => true], ...(type === 1 ? [['short', 'Under 95 minutes', 'sort=random', x => x.duration && x.duration <= 95 * 60000]] : []), ['classic', 'Classics', 'sort=random', x => x.year && x.year <= 1979]]; - $('#vmix').innerHTML = MIX.map(m => `${m[1]}`).join(''); + $('#vmix').innerHTML = MIX.map(m => `${m[1]}`).join('') + 'Your stats'; + $('#vstats').onclick = e => { e.preventDefault(); vdStatsSheet(cur); }; $('#vmix').querySelectorAll('[data-mix]').forEach(a => a.onclick = async e => { e.preventDefault(); const m = MIX.find(x => x[0] === a.dataset.mix), el = openSheet(m[1], 'Unwatched, picked at random. Tap the same mix again for a new set.', '

Looking…

'); try { const r = await px.get(`/library/sections/${cur.key}/all?type=${type}&unwatched=1&${m[2]}&X-Plex-Container-Start=0&X-Plex-Container-Size=${m[0] === 'shuffle' || m[0] === 'family' ? 24 : 200}`), items = (r.Metadata || []).filter(m[3]).slice(0, 24); el.querySelector('.empty').outerHTML = items.length ? `
${items.map(x => vdCard(x, false)).join('')}
` : '

Nothing matches. Try another mix.

'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet())); diff --git a/server.js b/server.js index a790216..3e42120 100644 --- a/server.js +++ b/server.js @@ -664,6 +664,7 @@ async function handleRequest(req, res) { podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); } if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); + if (pathname === '/api/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); } if (pathname === '/api/listening/stats') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').stats())); } if (pathname === '/api/export') return backup.handle(req, res, user.name); if (pathname === '/api/backup' || pathname === '/api/backup/restore') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); diff --git a/tasks.js b/tasks.js index eb3ee0f..588a247 100644 --- a/tasks.js +++ b/tasks.js @@ -44,9 +44,10 @@ function parseTodos(text) { else if (l.name === 'STATUS') cur.status = l.value.toUpperCase(); else if (l.name === 'PERCENT-COMPLETE') cur.percent = parseInt(l.value, 10); else if (l.name === 'COMPLETED') cur.completed = true; + else if (l.name === 'RELATED-TO' && !/^(CHILD|SIBLING)$/i.test((l.params && l.params.RELTYPE) || '')) cur.parent = cal.unesc(l.value); else if (l.name === 'DUE') { const d = cal.parseDate(l.value, l.params); if (d) cur.due = `${d.y}-${String(d.mo).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`; } } - 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 })); + 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, parent: t.parent || '' })); } // ---- Repeating tasks (NE Hub lists): finishing one moves its due date to the next occurrence instead of closing it. @@ -139,7 +140,12 @@ async function handle(req, res) { if (b.href) { // edit an iCloud task: patch the latest copy so alarms/priority/etc. survive const cur = await caldav.getResource(b.list, b.href); await caldav.putEvent(b.list, null, ics.patchTodo(cur.ics, f), { href: b.href, etag: cur.etag }); - } else { const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }])); } + } else { + if (f.parent) { // a subtask goes under a main task of the same list (and a subtask cannot have subtasks) + const mine = []; for (const r of await caldav.listTodoRaw(b.list)) mine.push(...parseTodos(r.ics)); const par = mine.find(x => x.id === f.parent); + if (!par || par.parent) throw new Error('A subtask can only go under a main task in the same list'); } + const uid = ics.newUid(); await caldav.putEvent(b.list, uid, ics.buildTodoCalendar([{ uid, fields: f }])); + } return json(res, 200, { ok: true }); } if (p === '/api/tasks/toggle' && req.method === 'POST') { diff --git a/tools/frontend-test.js b/tools/frontend-test.js index 96c8824..0572500 100644 --- a/tools/frontend-test.js +++ b/tools/frontend-test.js @@ -33,4 +33,8 @@ for (const f of listed) { assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n ')); // the pieces the app calls by name across files are there for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined'); +// the watch-stats counting (a pure function) +{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx); + const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now); + assert.deepEqual([a.week.n, a.week.min, a.month.n, a.year.n], [2, 150, 3, 4], 'week, month and year counts and minutes'); assert.deepEqual(a.genres[0], ['Drama', 3]); assert.equal(a.days[12], 1); assert.equal(a.days[10], 1); assert.equal(a.days.reduce((x, y) => x + y, 0), 2, 'the 14-day strip'); } console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0); diff --git a/tools/listening-test.js b/tools/listening-test.js index b70867d..af6979e 100644 --- a/tools/listening-test.js +++ b/tools/listening-test.js @@ -11,5 +11,9 @@ users.runAs(me, () => { let s = progress.stats(); assert.equal(Math.round(s.podcast), 30, 'only real listening counts'); assert.ok(s.audiobook > 0 && s.audiobook <= 55); assert.equal(s.finishedYear, 1); assert.equal(s.finishedAll, 1); pod.merge({ e1: { pos: 1530, dur: 3000, t: t0 + 70e3 } }); s = progress.stats(); assert.equal(s.streak, 1, 'a minute or more makes a streak day'); const old = progress('podcast-progress.json', 'podcast'); old.merge({ e1: { pos: 5, dur: 3000, t: t0 - 1e3 } }); assert.equal(progress.stats().podcast, s.podcast, 'an older report changes nothing'); + // e-books count too, and the family challenge adds everyone up + progress.markFinished('ebook:abc', 'ebook'); progress.markFinished('ebook:abc', 'ebook'); s = progress.stats(); assert.equal(s.ebooksYear, 1, 'an e-book counts once'); assert.equal(s.audiobooksYear, 1); assert.equal(s.finishedYear, 2, 'audiobooks and e-books together'); + assert.equal(progress.familyGoal().finished, 2); assert.equal(progress.familyGoal().goal, 0); assert.equal(progress.setFamilyGoal(40).goal, 40); assert.equal(progress.setFamilyGoal(-5).goal, 0, 'no negative goals'); + const bo = users.create({ name: 'Bo', role: 'user', handle: 'ZP' }); users.runAs(bo, () => progress.markFinished('ebook:zzz', 'ebook')); progress.setFamilyGoal(10); const fg = progress.familyGoal(); assert.equal(fg.finished, 3, 'everybody\'s books are counted'); assert.deepEqual(fg.people.map(p => p.name + p.n), ['Ann2', 'Bo1']); console.log('listening test ok'); }); diff --git a/tools/tasks-test.js b/tools/tasks-test.js index 6a2dfd7..b307983 100644 --- a/tools/tasks-test.js +++ b/tools/tasks-test.js @@ -48,6 +48,14 @@ const wait = ms => new Promise(r => setTimeout(r, ms)); // 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 === ''); + // subtasks on iCloud lists: a VTODO that points at its parent (RELATED-TO), kept through edits and ticking + { const ics = require('../ics'), { parseTodos } = require('../tasks'); + const main = ics.buildTodoCalendar([{ uid: 'p1@x', fields: { title: 'Trip', notes: '', due: '', done: false } }, { uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]); + let ts = parseTodos(main); ok('a subtask is written with its parent and read back with it', ts.length === 2 && ts[0].parent === '' && ts[1].parent === 'p1@x', ts); + const edited = ics.patchTodo(ics.buildTodoCalendar([{ uid: 'c1@x', fields: { title: 'Book flights', notes: '', due: '', done: false, parent: 'p1@x' } }]), { title: 'Book flights now', notes: '', due: '', done: true }); + ok('ticking or editing it keeps its parent', parseTodos(edited)[0].parent === 'p1@x' && parseTodos(edited)[0].done === true, parseTodos(edited)); + const moved = ics.patchTodo(edited, { title: 'x', notes: '', due: '', done: false, parent: '' }); ok('and an empty parent makes it a main task again', parseTodos(moved)[0].parent === '', parseTodos(moved)); + const foreign = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:k@x\r\nSUMMARY:Kid\r\nRELATED-TO;RELTYPE=CHILD:z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; ok('a link that points down (CHILD) is not a parent', parseTodos(foreign)[0].parent === ''); } 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);