From 551f9707219f19cd7901fd44c79e50f0ff6f18d7 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:43:35 -0400 Subject: [PATCH] Polish: npm test runs every test file, two silent failures now log, Loading placeholders shimmer Co-Authored-By: Claude Sonnet 5.5 --- mail.js | 2 +- package.json | 2 +- public/audiobooks-view.js | 4 ++-- public/bible-view.js | 2 +- public/calendar.js | 2 +- public/chat-view.js | 2 +- public/chores-view.js | 2 +- public/contacts-view.js | 2 +- public/family-view.js | 2 +- public/file-history.js | 2 +- public/files-extra.js | 6 +++--- public/files.js | 8 ++++---- public/inbox-view.js | 2 +- public/librivox-view.js | 4 ++-- public/mail.js | 4 ++-- public/music.js | 2 +- public/notes-view.js | 4 ++-- public/office.js | 2 +- public/podcast-view.js | 2 +- public/radio-view.js | 4 ++-- public/reader.js | 6 +++--- public/recipes-view.js | 2 +- public/settings-family.js | 2 +- public/signal-view.js | 2 +- public/store-view.js | 2 +- public/style.css | 5 +++++ public/sw.js | 2 +- public/tasks.js | 6 +++--- public/vbv-view.js | 2 +- public/video.js | 6 +++--- tasks.js | 4 ++-- tools/run-tests.js | 16 ++++++++++++++++ 32 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 tools/run-tests.js diff --git a/mail.js b/mail.js index 7868a8b..86f4f65 100644 --- a/mail.js +++ b/mail.js @@ -49,7 +49,7 @@ const fail = (status, message) => Object.assign(new Error(message), { status }); const accFile = () => path.join(users.dir(), 'mail-accounts.json'); const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several const accounts = () => { - try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch {} + try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch (e) { if (e.code !== 'ENOENT') console.warn('[mail] could not read the saved mail accounts (they will look empty until this is fixed):', e.message); } try { // migrate the old single-account file const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data); if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; } diff --git a/package.json b/package.json index 2d0fef8..04e9935 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body settingskeys transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done", + "test": "node tools/run-tests.js", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/audiobooks-view.js b/public/audiobooks-view.js index 1e50580..676f77a 100644 --- a/public/audiobooks-view.js +++ b/public/audiobooks-view.js @@ -61,7 +61,7 @@ const abLoadProg = async () => { try { for (const [id, p] of Object.entries(awai async function viewBooks(args = []) { const v = $('#view'); - v.innerHTML = '

Audiobooks

Loading…

'; + v.innerHTML = '

Audiobooks

Loading…

'; try { const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]); abBooks = Array.isArray(data) ? data : (data.books || Object.values(data)); @@ -105,7 +105,7 @@ function abSheet(b) { if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); }; if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0); el.querySelector('#abc').onclick = async () => { - const box = el.querySelector('#abch'); box.innerHTML = '

Loading…

'; + const box = el.querySelector('#abch'); box.innerHTML = '

Loading…

'; const ch = await abChapters(id); box.innerHTML = ch.length ? ch.map((c, i) => ``).join('') : '

This book has no chapter list.

'; box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start)); diff --git a/public/bible-view.js b/public/bible-view.js index ede5def..602d7bc 100644 --- a/public/bible-view.js +++ b/public/bible-view.js @@ -63,7 +63,7 @@ function bbPaint(root) { // highlights, notes and picked verses, on top of the t } async function viewBible(args = []) { - const v = $('#view'); v.innerHTML = '

Bible

Loading the Bible…

'; + const v = $('#view'); v.innerHTML = '

Bible

Loading the Bible…

'; try { await bbLoad(); } catch (e) { v.innerHTML = `

Bible

${esc(e.message)}

`; $('#bbretry').onclick = () => route(); return; } if (!location.hash.startsWith('#/bible')) return; try { const s = await bbApi('/state'); bbSt = s; } catch {} diff --git a/public/calendar.js b/public/calendar.js index ba77de7..d612419 100644 --- a/public/calendar.js +++ b/public/calendar.js @@ -57,7 +57,7 @@ async function viewCalendar() { const views = `
${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `${l}`).join('')}
` + (mode === 'agenda' ? `` : ''); v.innerHTML = `
${plannerHead('calendar', ``, views)} -

Loading…

`; +

Loading…

`; $('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel); if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); }; v.querySelectorAll('.sgrp a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); }); diff --git a/public/chat-view.js b/public/chat-view.js index 9f4d3ed..adabaf8 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -36,7 +36,7 @@ function chEvent(ev) { function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); } async function chMount(root) { - ch.root = root; root.innerHTML = '

Chat

Loading…

'; + ch.root = root; root.innerHTML = '

Chat

Loading…

'; root.innerHTML = `

Chat

`; chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); }; diff --git a/public/chores-view.js b/public/chores-view.js index 21b492b..ed2e697 100644 --- a/public/chores-view.js +++ b/public/chores-view.js @@ -36,7 +36,7 @@ function crPickGoal(uid) { const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit()); async function viewChores() { const v = $('#view'); cr.day = cr.day || crYmd(new Date()); - v.innerHTML = '

Chores

Loading…

'; + v.innerHTML = '

Chores

Loading…

'; try { cr.wk = cr.wk || crYmd(new Date()); [cr.data, cr.week] = await Promise.all([crApi('/?date=' + cr.day), crApi('/week?date=' + cr.wk)]); } catch (e) { v.innerHTML = `

Chores

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/chores')) return; const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me); diff --git a/public/contacts-view.js b/public/contacts-view.js index 6d11057..885167a 100644 --- a/public/contacts-view.js +++ b/public/contacts-view.js @@ -13,7 +13,7 @@ const ctFind = (l, address) => { const a = String(address || '').toLowerCase(); async function viewContacts(args = []) { const v = $('#view'); - v.innerHTML = '

Contacts

Loading…

'; + v.innerHTML = '

Contacts

Loading…

'; try { const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return; v.innerHTML = `

Contacts

diff --git a/public/family-view.js b/public/family-view.js index db9cc2a..a2274f7 100644 --- a/public/family-view.js +++ b/public/family-view.js @@ -1,7 +1,7 @@ 'use strict'; // ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and // only shows if the person has that part of the hub, so one slow or missing piece never blanks the page. -const fmCard = (id, title, href, sub) => `

${esc(title)}

${href ? `${esc(sub || 'Open')}` : ''}

Loading…

`; +const fmCard = (id, title, href, sub) => `

${esc(title)}

${href ? `${esc(sub || 'Open')}` : ''}

Loading…

`; const fmBody = id => document.querySelector('#' + id + ' .fmb'); const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `

${esc(e && e.message || 'Could not load this')}

`; }; diff --git a/public/file-history.js b/public/file-history.js index 359a5e8..5575651 100644 --- a/public/file-history.js +++ b/public/file-history.js @@ -2,7 +2,7 @@ // Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js. async function fileHistory(repo, path, name, readOnly) { const base = `/api2/repos/${encodeURIComponent(repo)}/file`; - const el = openSheet('Version history', name, '

Loading…

'); + const el = openSheet('Version history', name, '

Loading…

'); el.querySelector('.sheet').classList.add('wide'); try { let commits = [], next = ''; diff --git a/public/files-extra.js b/public/files-extra.js index 75b0b52..8b4ec5d 100644 --- a/public/files-extra.js +++ b/public/files-extra.js @@ -83,7 +83,7 @@ async function offerUndo(repo, dir, items, afterRestore) { } catch { return false; /* no undo available */ } } async function recycleBin(repo, dir) { - const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '

Loading…

'); + const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '

Loading…

'); el.querySelector('.sheet').classList.add('wide'); try { const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time)); @@ -143,7 +143,7 @@ async function zipDownload(repo, dir, names) { // ---- Share links with a password and an expiry, and the ones that already exist async function shareSheet(repo, path, isDir) { const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' }; - const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '

Loading…

'); + const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '

Loading…

'); const draw = async () => { let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; } if (!document.body.contains(el)) return; @@ -177,7 +177,7 @@ async function shareSheet(repo, path, isDir) { const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' ')); async function textPreview(repo, path, name) { const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1'; - const el = openSheet(name, '', '

Loading…

'); el.querySelector('.sheet').classList.add('wide'); + const el = openSheet(name, '', '

Loading…

'); el.querySelector('.sheet').classList.add('wide'); const dl = `${icon('dl')}Download`; try { const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')'); diff --git a/public/files.js b/public/files.js index 5dd2000..edb7bdc 100644 --- a/public/files.js +++ b/public/files.js @@ -42,7 +42,7 @@ function viewFiles([repo, ...parts]) { if (!sf.token()) return filesLogin(); const dir = '/' + parts.join('/'); $('#view').innerHTML = `

Files

-

Loading…

`; +

Loading…

`; libsP = loadLibs(repo); if (repo) loadDir(repo, dir); } @@ -229,7 +229,7 @@ async function prefetchDl(repo, path) { async function loadDir(repo, dir) { const main = $('#fmain'); if (!main) return; - main.innerHTML = '

Loading…

'; + main.innerHTML = '

Loading…

'; await libsP.catch(() => {}); if (!document.contains(main)) return; const enc = encodeURIComponent, ro = libPerm[repo] === 'r'; @@ -243,7 +243,7 @@ async function loadDir(repo, dir) { const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); }; main.innerHTML = `
${crumbs}
-

Loading…

`; +

Loading…

`; const go = p => { location.hash = hash(p); }; const drawTop = () => { @@ -396,7 +396,7 @@ async function loadDir(repo, dir) { const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); }; if (!quiet) { if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it - else $('#items').innerHTML = '

Loading…

'; + else $('#items').innerHTML = '

Loading…

'; } try { const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`); diff --git a/public/inbox-view.js b/public/inbox-view.js index c23675b..2b4d0ee 100644 --- a/public/inbox-view.js +++ b/public/inbox-view.js @@ -14,7 +14,7 @@ setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && d const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; const INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] }; async function inboxOpen() { - const el = openSheet('Notifications', '', '

Loading…

'); + const el = openSheet('Notifications', '', '

Loading…

'); let items = []; const draw = () => { const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length; diff --git a/public/librivox-view.js b/public/librivox-view.js index 7b31d6d..581a188 100644 --- a/public/librivox-view.js +++ b/public/librivox-view.js @@ -47,7 +47,7 @@ function lvPlay(b, at) { } async function viewFreeBooks(host) { // host: { el, hash } when shown inside the Store tab - const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '

Free audiobooks

Loading…

'; + const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '

Free audiobooks

Loading…

'; try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `
${icon('back')}Audiobooks

${esc(e.message)}

`; return; } if (!here()) return; const S = { q: '', genre: '', page: 1, items: [], more: false, busy: false, seq: 0 }; let genreList = []; lvApi('/genres').then(r => { genreList = r.genres || []; gchips(); }).catch(() => {}); @@ -78,7 +78,7 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the async function lvSheet(id, after, known = new Map()) { const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' }; - const el = openSheet(sum.title, sum.author || '', '

Loading…

'); + const el = openSheet(sum.title, sum.author || '', '

Loading…

'); let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `

${esc(e.message)}

`; return; } if (!document.body.contains(el)) return; const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id); diff --git a/public/mail.js b/public/mail.js index a8b1206..e9fdece 100644 --- a/public/mail.js +++ b/public/mail.js @@ -165,7 +165,7 @@ async function viewMail(args = []) { st = saved; mail.st = saved; jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {}); } else { - v.innerHTML = '

Mail

Loading…

'; + v.innerHTML = '

Mail

Loading…

'; try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `

Mail

${esc(e.message)}

`; return; } if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers }); } @@ -241,7 +241,7 @@ async function mailLoadList(reset) { mail.items = []; mail.next = 0; mail.more = false; const saved = first && mcGet('l:' + folder); if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away - else { const rows = $('#mrows'); if (rows) rows.innerHTML = '

Loading…

'; } + else { const rows = $('#mrows'); if (rows) rows.innerHTML = '

Loading…

'; } } const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it try { diff --git a/public/music.js b/public/music.js index fe353c5..5408d6d 100644 --- a/public/music.js +++ b/public/music.js @@ -38,7 +38,7 @@ const mapTrack = (t, mc) => { async function viewMusic(args) { const v = $('#view'); - v.innerHTML = '

Music

Loading…

'; + v.innerHTML = '

Music

Loading…

'; try { const st = await jfetch('/api/plex/status'); if (!location.hash.startsWith('#/music')) return; diff --git a/public/notes-view.js b/public/notes-view.js index 2599020..dd904ca 100644 --- a/public/notes-view.js +++ b/public/notes-view.js @@ -17,7 +17,7 @@ async function ntLoad() { const r = await ntApi('/'); nt.notes = r.notes; nt.peo // ================================================================ the list of notes async function ntHome() { - const v = $('#view'); v.innerHTML = '

Notes

Loading…

'; + const v = $('#view'); v.innerHTML = '

Notes

Loading…

'; try { await ntLoad(); } catch (e) { v.innerHTML = `

Notes

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/notes')) return; const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']]; @@ -45,7 +45,7 @@ function ntNewSheet() { // ================================================================ one note or list async function ntEditor(id) { - const v = $('#view'); v.innerHTML = '

Loading…

'; + const v = $('#view'); v.innerHTML = '

Loading…

'; let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/notes/')) return; let saveT, busy = false, dirty = false; diff --git a/public/office.js b/public/office.js index 975ba4b..33249fd 100644 --- a/public/office.js +++ b/public/office.js @@ -97,7 +97,7 @@ function ofDraw(skeleton) { const row = d => { const path = ofPath(d), on = pins.has(ofId(loc.repo, path)), i = shown.indexOf(d); return `
`; }; - box.innerHTML = skeleton ? '

Loading your documents…

' : (recents.length ? `

Recently opened

${recents.map((r, i) => ``).join('')}
` : '') + box.innerHTML = skeleton ? '

Loading your documents…

' : (recents.length ? `

Recently opened

${recents.map((r, i) => ``).join('')}
` : '') + (pinned.length && !q ? `

Pinned

${pinned.map(row).join('')}
` : '') + (list.length ? `

${q ? 'Results' : ofState.kind === 'all' ? 'All documents' : OFFICE_KINDS[ofState.kind].label} ${list.length}

${shown.map(row).join('')}
${list.length > shown.length ? `
` : ''}` : `

${ofState.docs.length ? 'Nothing matches.' : 'No documents here yet. Create one above, or drop files on this page.'}

`); diff --git a/public/podcast-view.js b/public/podcast-view.js index e746f5c..6a4a31d 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -34,7 +34,7 @@ const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur async function viewPodcast(args = []) { const v = $('#view'); - v.innerHTML = '

Podcast

Loading…

'; + v.innerHTML = '

Podcast

Loading…

'; try { if (args[0] === 'show' && args[1]) return await pdShow(args[1]); return await pdHome(); diff --git a/public/radio-view.js b/public/radio-view.js index 34a6c24..267fbad 100644 --- a/public/radio-view.js +++ b/public/radio-view.js @@ -39,7 +39,7 @@ setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPla function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `Now playing${esc(n.title)}${n.artist ? ' · ' + esc(n.artist) + '' : ''}` : 'Live'; } async function viewRadio(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed - const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '

Radio

Loading…

'; + const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '

Radio

Loading…

'; let feat, mine, added; try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `

Radio

${esc(e.message)}

`; return; } if (!here()) return; @@ -83,7 +83,7 @@ async function viewRadio(host) { // host: { el, hash } when shown inside the Sto $('#rdojp').onclick = () => playStation(j); $('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); }; $('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); }; - $('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '

Loading…

'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `
${n.recent.map(x => `
${esc(x.title)}${esc(x.artist)}
`).join('')}
` : '

Nothing to show right now.

'; } catch (e) { el.querySelector('.empty').outerHTML = `

${esc(e.message)}

`; } }; + $('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '

Loading…

'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `
${n.recent.map(x => `
${esc(x.title)}${esc(x.artist)}
`).join('')}
` : '

Nothing to show right now.

'; } catch (e) { el.querySelector('.empty').outerHTML = `

${esc(e.message)}

`; } }; $('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } }; rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {}); } diff --git a/public/reader.js b/public/reader.js index 0a13d17..fc97563 100644 --- a/public/reader.js +++ b/public/reader.js @@ -29,7 +29,7 @@ async function viewReader(args = []) { if (args[0] === 'open' && args[1]) return rdOpen(args[1]); if (args[0] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore(); const v = $('#view'); - v.innerHTML = '

Books

Loading…

'; + v.innerHTML = '

Books

Loading…

'; try { await rdLibrary(); } catch (e) { if (!location.hash.startsWith('#/read')) return; v.innerHTML = `

Books

${esc(e.message)}

`; @@ -167,7 +167,7 @@ async function rdMobiOpen(u8) { // ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue async function rdStore(host) { // host: { el, hash } when shown inside the Store tab - const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '

Book store

Loading…

'; + const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '

Book store

Loading…

'; let sources, mine; let genreList = []; try { [sources, mine, genreList] = await Promise.all([rdApi('/store/sources'), rdApi('/list'), rdApi('/store/genres').catch(() => [])]); } catch (e) { v.innerHTML = `
${icon('back')}Books

${esc(e.message)}

`; return; } if (!here()) return; @@ -350,7 +350,7 @@ class RdReader { async deleteNote(n) { this.A.eraseNote?.(n); await this.pushNote({ ...n, del: true }); this.notes = this.notes.filter(x => !x.del); this.$('#rdbm').classList.toggle('on', !!this.isMarked()); this.refreshNotes?.(); } // ---- panels async showToc() { - const el = openSheet('Contents', '', '

Loading…

'); let items = []; + const el = openSheet('Contents', '', '

Loading…

'); let items = []; try { items = await this.A.toc(); } catch {} el.querySelector('.empty').outerHTML = items.length ? `
${items.map((t, i) => ``).join('')}
` : '

This book has no table of contents.

'; el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); }); diff --git a/public/recipes-view.js b/public/recipes-view.js index 8841c9e..25785e8 100644 --- a/public/recipes-view.js +++ b/public/recipes-view.js @@ -9,7 +9,7 @@ let rcQuery = '', rcPage = 1; async function viewRecipes(args = []) { const v = $('#view'); - v.innerHTML = '

Recipes

Loading…

'; + v.innerHTML = '

Recipes

Loading…

'; try { if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1])); if (args[0] === 'pantry') return await viewPantry(args[1]); diff --git a/public/settings-family.js b/public/settings-family.js index c0e763e..f13de11 100644 --- a/public/settings-family.js +++ b/public/settings-family.js @@ -39,7 +39,7 @@ async function famKids(box) { box.querySelectorAll('[data-k]').forEach(b => b.onclick = () => famChild(kids.find(u => u.id === b.dataset.k))); } async function famChild(u) { - const el = openSheet(u.name, 'Last 7 days', '

Loading…

'); let a, px; + const el = openSheet(u.name, 'Last 7 days', '

Loading…

'); let a, px; try { [a, px] = await Promise.all([jfetch('/api/kid/activity?user=' + encodeURIComponent(u.id) + '&days=7'), jfetch('/api/kid/plex-lock?user=' + encodeURIComponent(u.id))]); } catch (e) { el.querySelector('#fcb').innerHTML = `

${esc(e.message)}

`; return; } const h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min', total = a.days.reduce((n, d) => n + d.minutes, 0), by = {}; a.days.forEach(d => Object.entries(d.areas).forEach(([k, m]) => { by[k] = (by[k] || 0) + m; })); const icn = { play: '🎵', watch: '🎬', listen: '🎧', read: '📖', search: '🔎', signin: '🔑', chore: '✅', add: '➕', blocked: '⛔' }, max = Math.max(1, ...a.days.map(d => d.minutes)); diff --git a/public/signal-view.js b/public/signal-view.js index 4f7b3ff..f9baef6 100644 --- a/public/signal-view.js +++ b/public/signal-view.js @@ -64,7 +64,7 @@ function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return async function sgMount(root, dock) { sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation) sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `

Signal

${typeof sgInChat === 'function' && sgInChat() ? `

‹ Back to Chat · your Signal chats are also listed there

` : ''}`; - root.innerHTML = `
${title}

Loading…

`; + root.innerHTML = `
${title}

Loading…

`; let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `
${title}

${esc(e.message)}

`; return; } if (sg.root !== root) return; sg.st = st; if (!st.reachable) { root.innerHTML = `
${title}

The Signal service is not answering. Check that its container (signal) is running, then reload.

`; return; } diff --git a/public/store-view.js b/public/store-view.js index d0dba5a..d370e6d 100644 --- a/public/store-view.js +++ b/public/store-view.js @@ -136,7 +136,7 @@ function stPickFile() { const el = openSheet('Choose a file from Files', '', '
'); const finish = v => { done = true; closeSheet(); resolve(v); }; el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); }); const draw = async () => { - const box = el.querySelector('#stpk'); box.innerHTML = '

Loading…

'; + const box = el.querySelector('#stpk'); box.innerHTML = '

Loading…

'; try { if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name)); box.innerHTML = `
${repos.map(r => ``).join('')}
`; diff --git a/public/style.css b/public/style.css index 655242b..814c859 100644 --- a/public/style.css +++ b/public/style.css @@ -1200,3 +1200,8 @@ body.qdock #view { padding-right: 340px; } /* My Store: books ticked for "Add to people" */ .vcard.picked { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 12px; } + +/* "Loading…" placeholders: a soft shimmer so a slow tab reads as working, not stuck (still text for screen readers; static when motion is reduced) */ +.empty.loading { background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--muted) 16%, transparent) 50%, transparent 100%) 0 0 / 200% 100% no-repeat; animation: loadsweep 1.4s ease-in-out infinite; border-radius: 12px; } +@keyframes loadsweep { from { background-position: 150% 0; } to { background-position: -50% 0; } } +@media (prefers-reduced-motion: reduce) { .empty.loading { animation: none; } } diff --git a/public/sw.js b/public/sw.js index 001e531..1ba2ff5 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-v138'; +const CACHE = 'hub-v139'; 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 2fdd00f..dcde2ac 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -7,7 +7,7 @@ let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" op async function viewTasks() { const v = $('#view'); - v.innerHTML = '
' + plannerHead('tasks') + '

Loading…

'; + v.innerHTML = '
' + plannerHead('tasks') + '

Loading…

'; try { let { lists, icloud } = await jfetch('/api/tasks/lists'); 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) @@ -18,7 +18,7 @@ async function viewTasks() { v.innerHTML = `
${plannerHead('tasks')}
TodayUpcoming${lists.length > 1 ? `All` : ''}${lists.map(l => `${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}`).join('')}+ List
-

Loading…

+

Loading…

`; $('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.' : icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : ''; @@ -159,7 +159,7 @@ function chooseFolder(title, start, opts = {}) { el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); }); const finish = v => { done = true; closeSheet(); resolve(v); }; const draw = async () => { - const box = el.querySelector('#cf'); box.innerHTML = '

Loading…

'; + const box = el.querySelector('#cf'); box.innerHTML = '

Loading…

'; try { if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name)); diff --git a/public/vbv-view.js b/public/vbv-view.js index 79d1897..8e7e676 100644 --- a/public/vbv-view.js +++ b/public/vbv-view.js @@ -20,7 +20,7 @@ const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLoca const vbCover = (s, big) => ``; async function viewVbv(args = []) { - const v = $('#view'); v.innerHTML = '

Verse By Verse

Loading…

'; + const v = $('#view'); v.innerHTML = '

Verse By Verse

Loading…

'; try { await vbLoadProg(); if (args[0] === 'study' && args[1]) return await vbStudy(args[1]); return await vbHome(); } catch (e) { if (!location.hash.startsWith('#/podcast/vbv')) return; v.innerHTML = `
${icon('back')}Podcast

Verse By Verse

${esc(e.message)}

`; $('#vbretry').onclick = () => route(); } } diff --git a/public/video.js b/public/video.js index 07f3bb7..9f97626 100644 --- a/public/video.js +++ b/public/video.js @@ -29,7 +29,7 @@ async function vdNav(m, back) { } async function viewVideo(args = []) { const v = $('#view'), [kind, key] = args; - v.innerHTML = '

Movies & TV

Loading…

'; + v.innerHTML = '

Movies & TV

Loading…

'; try { const st = await jfetch('/api/plex/status'); if (!location.hash.startsWith('#/video')) return; @@ -118,7 +118,7 @@ async function videoHome(st, libKey) { v.innerHTML = `

Movies & TV

${secs.length > 1 ? `
${secs.map(s => `${esc(s.title)}`).join('')}
` : ''}

${esc(cur.title)}

-

Loading…

`; +

Loading…

`; plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person // Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once. const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k; @@ -586,7 +586,7 @@ async function videoEdit(m) { el.querySelector('#sx').onclick = closeSheet; const loaded = {}; const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link - const box = el.querySelector('#vtab-' + tab); box.innerHTML = '

Loading…

'; + const box = el.querySelector('#vtab-' + tab); box.innerHTML = '

Loading…

'; try { const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || []; box.innerHTML = `
${list.map((x, i) => ``).join('') || '

No images yet.

'}
diff --git a/tasks.js b/tasks.js index 588a247..b8df02c 100644 --- a/tasks.js +++ b/tasks.js @@ -180,7 +180,7 @@ async function handle(req, res) { const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 }; const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; }); count(load().tasks); - for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch {} } + for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } } return json(res, 200, out); } json(res, 404, { error: 'Not found' }); @@ -191,7 +191,7 @@ async function handle(req, res) { 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 || '', 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 {} } + 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 (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } } return out; } diff --git a/tools/run-tests.js b/tools/run-tests.js new file mode 100644 index 0000000..c0db4f8 --- /dev/null +++ b/tools/run-tests.js @@ -0,0 +1,16 @@ +// Runs every tools/*-test.js (or only the names given), one after another, and lists the ones that fail. +// A new test file is picked up automatically; `smoke` and `frontend` run first because they fail fastest. +const { spawnSync } = require('child_process'); +const fs = require('fs'), path = require('path'); +const FIRST = ['smoke', 'frontend']; +const all = fs.readdirSync(__dirname).filter(f => f.endsWith('-test.js')).map(f => f.slice(0, -'-test.js'.length)); +const want = process.argv.slice(2); +const names = (want.length ? want : all).sort((a, b) => (FIRST.indexOf(a) + 1 || 99) - (FIRST.indexOf(b) + 1 || 99) || a.localeCompare(b)); +const failed = []; +for (const n of names) { + if (!all.includes(n)) { console.error('No test named ' + n); process.exit(2); } + console.log('\n== ' + n); + if (spawnSync(process.execPath, [path.join(__dirname, n + '-test.js')], { stdio: 'inherit' }).status !== 0) failed.push(n); +} +console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nAll ' + names.length + ' test files passed'); +process.exit(failed.length ? 1 : 0);