Reading goals for e-books and the family; subtasks on iCloud lists; watch stats in Movies & TV

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 11:30:40 -04:00
1 parent 435f442761
commit 275ef2009f
12 files changed
+74 -11

No files matched your search

+2 -1
View File
@@ -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') {
+2
View File
@@ -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);
+13 -2
View File
@@ -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;
+1 -1
View File
@@ -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'];
+1 -1
View File
@@ -73,7 +73,7 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
${list.local ? `<label for="trep">Repeat</label><select id="trep">${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `<option value="${v}" ${(t.repeat || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
<p class="sub" style="margin:4px 0 0">A repeating task moves to its next date when you complete it.</p>` : ''}
<label class="chk"><input type="checkbox" id="tdone" ${t.done ? 'checked' : ''}> Completed</label>
${list.local && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
${(list.local || list.icloud) && t.id && !t.parent ? `<label>Subtasks</label><div class="list" id="tsubs" style="background:var(--panel2);border:0">${taskStore.filter(k => k.parent === t.id).map(k => `<div class="row"><span class="grow"><b style="${k.done ? 'text-decoration:line-through;opacity:.6' : ''}">${esc(k.title)}</b></span></div>`).join('')}</div>
<div style="display:flex;gap:8px;margin-top:6px"><input type="text" id="tsub" placeholder="Add a subtask" autocomplete="off" style="flex:1"><button type="button" class="btn ghost" id="tsubadd">Add</button></div>` : ''}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div>
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
+7 -3
View File
@@ -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 = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>audiobooks finished this year</small></span></div>
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>books finished this year</small></span></div>
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}
<div id="lsfam" class="lsfam"></div><div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
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 = `<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">Set a family reading challenge</button>`; } else box.innerHTML = `<div class="lsrow" style="margin-top:12px"><span><b>${f.finished}${f.goal ? ' of ' + f.goal : ''}</b><small>family books finished in ${f.year}${f.people.length ? ' · ' + esc(f.people.map(p => p.name + ' ' + p.n).join(', ')) : ''}</small></span></div>${f.goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(f.finished / f.goal * 100))}%"></i></div>` : ''}<button class="btn ghost" id="lsfg" style="min-height:36px;margin-top:8px">${f.goal ? 'Change the family goal' : 'Set a family goal'}</button>`;
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).', `<form id="lsff"><label for="lsfn">Books</label><input type="number" id="lsfn" min="0" max="1000" value="${f.goal || ''}" placeholder="40" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); 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', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); 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); }; };
}
+23 -1
View File
@@ -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, '<p class="empty">Counting…</p>');
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 = `<div class="lsrow">${[['week', 'this week'], ['month', 'last 30 days'], ['year', 'this year']].map(([k, l]) => `<span><b>${a[k].n}</b><small>${what} ${l}${a[k].min ? ' · ' + hrs(a[k].min) : ''}</small></span>`).join('')}</div>
<div class="lsbars" role="img" aria-label="${what} watched each of the last 14 days">${a.days.map((n, i) => `<i style="height:${Math.max(3, Math.round(n / max * 100))}%" title="${n} ${what}"></i>`).join('')}</div>
${a.genres.length ? `<h3 class="sub" style="margin:12px 0 4px">Favourite genres this year</h3><div class="seg" style="flex-wrap:wrap">${a.genres.map(([t, n]) => `<a href="#" style="pointer-events:none">${esc(t)} · ${n}</a>`).join('')}</div>` : ''}
${a.year.n ? '' : '<p class="empty">Nothing watched in this library yet.</p>'}<p class="sub" style="margin-top:12px">Counts each title once, on the day you last watched it.</p>`;
} 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 => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('');
$('#vmix').innerHTML = MIX.map(m => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('') + '<a href="#" id="vstats">Your stats</a>';
$('#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.', '<p class="empty">Looking…</p>');
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 ? `<div class="vgrid">${items.map(x => vdCard(x, false)).join('')}</div>` : '<p class="empty">Nothing matches. Try another mix.</p>'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet()));
+1
View File
@@ -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');
+8 -2
View File
@@ -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') {
+4
View File
@@ -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);
+4
View File
@@ -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');
});
+8
View File
@@ -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);