diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eebde8..7990a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ The long form, with what was wrong before and how it was found, is in ### Changed +- The server's settings, the accounts and the log are on their own admin page, /admin, reached by + the wrench in the header. Only an admin is sent the page, its script, or the link to it. + Settings is now yours alone: your theme and your subscriptions. - A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone saw during a scan of every feed. A folder holding a failing feed has its triangle turn red. diff --git a/CLAUDE.md b/CLAUDE.md index aa5c264..cc48253 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,11 +61,15 @@ the shell running the command and kills the session (exit 144). This has happene ## Before you touch the page -The page is markup and CSS in `web/index.html` and TypeScript in `web/src/`. `build.rs` runs +There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to +admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet, +`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files +make up each page's script. `build.rs` runs `web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and `login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page -loads its script as `/app.js?v=`: the page is served `no-cache` and the -script `immutable`, so a browser keeps the script until a deploy changes it and its name. So **every page change needs a +loads its script as `/app.js?v=`, and `/app.css` the same way: the page is +served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a +deploy changes them and their names. So **every page change needs a rebuild** before it is visible, and building needs node and `npm ci` run once. The files in `web/src` are not modules. They are one script split up, concatenated in the order diff --git a/docs/architecture.md b/docs/architecture.md index 4f953db..a5357dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,9 @@ it to a running daemon. | `src/auth.rs` | Argon2id hashing, session tokens, header names | — | | `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — | | `src/logbuf.rs` | Ring buffer behind the UI's Log view | — | -| `web/index.html` | The page's markup and CSS | — | +| `web/index.html` | The app's markup | — | +| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — | +| `web/app.css` | The stylesheet both pages share | — | | `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — | | `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — | | `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — | diff --git a/src/web.rs b/src/web.rs index b072afc..9b3fcfc 100644 --- a/src/web.rs +++ b/src/web.rs @@ -59,6 +59,8 @@ pub fn router(state: WebState) -> Router { .route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/logs", get(logs)) .route("/api/events", get(events)) + .route("/admin", get(admin_page)) + .route("/admin.js", get(admin_js)) .route("/media/{id}", get(media)) .layer(middleware::from_fn_with_state(state.clone(), auth)) // Signing in cannot require being signed in, so these sit outside the auth layer. @@ -69,6 +71,7 @@ pub fn router(state: WebState) -> Router { .route("/favicon.png", get(favicon)) .route("/apple-touch-icon.png", get(touch_icon)) .route("/app.js", get(app_js)) + .route("/app.css", get(app_css)) .route("/login.js", get(login_js)) .route("/inter.woff2", get(inter)) .layer(middleware::from_fn(access_log)) @@ -493,6 +496,32 @@ async fn app_js() -> impl IntoResponse { script(include_str!(concat!(env!("OUT_DIR"), "/app.js"))) } +/// The stylesheet the app and admin pages share, named by hash in each as the scripts are. +async fn app_css() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], + include_str!(concat!(env!("OUT_DIR"), "/app.css")), + ) +} + +/// The admin page and its script go to admins only: not just hidden from everyone else, never +/// sent. Anyone else asking for the page is sent back to the app. +async fn admin_page(State(state): State, user: crate::db::User) -> Response { + if !user.is_admin { + return Redirect::to("/").into_response(); + } + let theme = state.ctx.db.theme(user.id).unwrap_or_default(); + let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme); + ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response() +} + +async fn admin_js(user: crate::db::User) -> Response { + if !user.is_admin { + return (StatusCode::FORBIDDEN, "only an admin").into_response(); + } + script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response() +} + async fn login_js() -> impl IntoResponse { script(include_str!(concat!(env!("OUT_DIR"), "/login.js"))) } @@ -548,9 +577,9 @@ fn constant_time_eq(a: &str, b: &str) -> bool { } // Built by build.rs from web/index.html and web/src, and minified: attribute values lose their -// quotes, so LOG_BUTTON is spelled the way the minifier leaves it. +// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them. const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html")); -const LOG_BUTTON: &str = " - +
diff --git a/web/src/admin.ts b/web/src/admin.ts new file mode 100644 index 0000000..eaf4b8b --- /dev/null +++ b/web/src/admin.ts @@ -0,0 +1,204 @@ +/* ---------------- admin page ---------------- */ +// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's +// hash so a link can go straight to one. The server sends this page and this script to admins +// only, and refuses every call below to anyone else; they were parts of Settings and the header, +// shown or hidden by the main page's script (issue #19). + +let me: {name: string} | null = null; +api('/api/me').then(u => { me = u; }).catch(() => {}); + +const SECTIONS: Record void> = {server: drawServer, accounts: drawAccounts, log: drawLogView}; + +function showSection(){ + const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server'; + for(const s of $$('#admin > section')) s.hidden = s.id !== t; + for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t); + // The log polls every two seconds while it is showing, and not otherwise. + if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; } + SECTIONS[t](); +} +window.addEventListener('hashchange', showSection); + +/* ---------------- server ---------------- */ +async function drawServer(){ + const box = $('#server'); + const g = await api('/api/settings'); + const gs = splitEvery(g.every_mins); + box.innerHTML = `

Server

+

These apply to everyone. Each person's own choices, such as keywords or how + many items a feed downloads for them, are in that feed's settings.

+
+
+ + +
+ Applies to every feed that does not set its own. A feed's suggested + interval (its ttl) is still honoured when it asks to be polled less often.
+
+ + Applies to any feed that does not set its own — including every feed + inside an OPML subscription. 0 means unlimited, which will pull a whole back + catalogue the first time a feed is scanned.
+
+ + Anything else is still listed and can be downloaded by hand — blog feeds + put article images in enclosures, and those are not worth keeping. Empty takes everything.
+
+ + Over this, the oldest played items are deleted first. Pinned + items are never touched.
+
+
+
+ ${esc(g.download_dir)}
+
`; + $('#gsave').onclick = async () => { + try{ + await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ + schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, + max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), + media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean), + max_total_gb: Number($('#gquota').value) || 0, + max_age_days: Number($('#gage').value) || 0})}); + toast('Settings saved'); + }catch(e){ toast(e.message, true); } + }; +} + +/* ---------------- accounts ---------------- */ +async function drawAccounts(){ + const box = $('#accounts'); + const users = await api('/api/users') || []; + box.innerHTML = `

Accounts

+ ${users.map(u => `
+
${esc(u.name)} + ${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${ + u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}
+ ${u.password ? '' : 'Proxy'} + +
`).join('')} +
+
+ + +
+ + At least 8 characters. Leave the password empty for someone who signs in + through the proxy. New people start with no feeds.
+
`; + const change = async (u, opts) => { + try{ + await api(`/api/users/${u.id}`, opts); + // Demoting yourself takes this page away; go back to the app rather than stay on a page + // the server no longer answers. Only on success: a refusal's toast has to stay readable. + if(u.name === me?.name){ location.href = '/'; return; } + }catch(e){ toast(e.message, true); } + drawAccounts(); // on a refusal, this puts the checkbox back where the server left it + }; + for(const row of $$('#accounts [data-id]')){ + const u = users.find(x => String(x.id) === row.dataset.id); + $('[data-a="admin"]', row).onchange = e => + change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})}); + $('[data-a="rm"]', row).onclick = () => { + if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) + change(u, {method: 'DELETE'}); + }; + } + $('#uadd').onclick = async () => { + try{ + await api('/api/users', {method: 'POST', body: JSON.stringify({ + name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})}); + toast('Added'); drawAccounts(); + }catch(e){ toast(e.message, true); } // keep what was typed + }; +} + +/* ---------------- log ---------------- */ +let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all'; +// Which sources belong to each tab. "daemon" is the control protocol itself: every +// command in and every event out, whatever sent it. +const LOG_TABS = { + all: null, + daemon: t => t === 'ipx::io', + scan: t => t === 'ipx::scan', + web: t => t === 'ipx::http', +}; +const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; + +function drawLogView(){ + logSeq = 0; logLines = []; + $('#log').innerHTML = `

Log

+
+
+ ${Object.keys(LOG_TABS).map(t => + ``).join('')} +
+ + + + +
+

Loading…

+ Daemon I/O is the control protocol itself — every command in and + every event out. Scans is feed and download activity, HTTP is web requests. + The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes + what it captures.`; + for(const b of $$('#logtabs button')) b.onclick = () => { + logTab = b.dataset.t; + for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); + drawLog(); + }; + $('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); }; + $('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); }; + $('#logcopy').onclick = () => copyText(visibleLog().map(l => + `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); + pollLog(); + if(!logTimer) logTimer = setInterval(pollLog, 2000); +} + +async function pollLog(){ + try{ + const r = await api(`/api/logs?after=${logSeq}&limit=500`); + if(r.lines.length){ + logLines = logLines.concat(r.lines).slice(-2000); + logSeq = r.latest; + drawLog(); + }else if(!logLines.length){ drawLog(); } + }catch(e){ + const box = $('#logbox'); + if(box) box.innerHTML = `

Lost contact with the daemon: ${esc(e.message)}

`; + } +} +function visibleLog(){ + const min = logLevel ? LEVELS[logLevel] : -1; + const tab = LOG_TABS[logTab]; + return logLines.filter(l => + (!tab || tab(l.target)) && + (LEVELS[l.level] ?? 1) >= min && + (!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter))); +} +function drawLog(){ + const box = $('#logbox'); if(!box) return; + const follow = $('#logfollow')?.checked; + const rows = visibleLog(); + box.innerHTML = rows.length ? rows.map(l => { + const t = new Date(l.ts * 1000).toLocaleTimeString(); + if(logTab === 'daemon'){ + const out = l.msg.startsWith('<-'); + return `
` + + `${out ? 'out' : 'in'}` + + `${esc(l.msg.replace(/^[<-]+\s*/, ''))}
`; + } + return `
${esc(l.level)}` + + `${esc(l.target.replace(/^ipx::?/, ''))}${esc(l.msg)}
`; + }).join('') : '

Nothing matches.

'; + if(follow) box.scrollTop = box.scrollHeight; +} + +showSection(); diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 1eee651..023f802 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -6,99 +6,8 @@ function openModal(html: string, wide?: boolean){ } function closeModal(){ $('#modal').classList.remove('on'); - if(logTimer){ clearInterval(logTimer); logTimer=null; } } -/* ---------------- log view ---------------- */ -let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel='', logTab='all'; -// Which sources belong to each tab. "daemon" is the control protocol itself: every -// command in and every event out, whatever sent it. -const LOG_TABS={ - all: null, - daemon: t=>t==='ipx::io', - scan: t=>t==='ipx::scan', - web: t=>t==='ipx::http', -}; -const LEVELS={ERROR:3,WARN:2,INFO:1,DEBUG:0,TRACE:0}; - -function logsModal(){ - logSeq=0; logLines=[]; - openModal(`

Log

-
-
- ${Object.keys(LOG_TABS).map(t=> - ``).join('')} -
- - - - - -
-

Loading…

- Daemon I/O is the control protocol itself — every command in and - every event out. Scans is feed and download activity, HTTP is web requests. - The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes - what it captures.`, true); - - $$('#logtabs button').forEach(b=>b.onclick=()=>{ - logTab=b.dataset.t; - $$('#logtabs button').forEach(x=>x.classList.toggle('on',x.dataset.t===logTab)); - drawLog(); - }); - $('#loglevel').onchange=e=>{ logLevel=e.target.value; drawLog(); }; - $('#logq').oninput=e=>{ logFilter=e.target.value.toLowerCase(); drawLog(); }; - $('#logcopy').onclick=()=>copyText(visibleLog().map(l=> - `${new Date(l.ts*1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); - - pollLog(); - logTimer=setInterval(pollLog,2000); -} - -async function pollLog(){ - try{ - const r=await api(`/api/logs?after=${logSeq}&limit=500`); - if(r.lines.length){ - logLines=logLines.concat(r.lines).slice(-2000); - logSeq=r.latest; - drawLog(); - }else if(!logLines.length){ drawLog(); } - }catch(e){ - const box=$('#logbox'); - if(box) box.innerHTML=`

Lost contact with the daemon: ${esc(e.message)}

`; - } -} -function visibleLog(){ - const min=logLevel?LEVELS[logLevel]:-1; - const tab=LOG_TABS[logTab]; - return logLines.filter(l=> - (!tab || tab(l.target)) && - (LEVELS[l.level]??1)>=min && - (!logFilter || (l.msg+' '+l.target).toLowerCase().includes(logFilter))); -} -function drawLog(){ - const box=$('#logbox'); if(!box) return; - const follow=$('#logfollow')?.checked; - const rows=visibleLog(); - box.innerHTML = rows.length ? rows.map(l=>{ - const t=new Date(l.ts*1000).toLocaleTimeString(); - if(logTab==='daemon'){ - const out=l.msg.startsWith('<-'); - return `
`+ - `${out?'out':'in'}`+ - `${esc(l.msg.replace(/^[<-]+\s*/,''))}
`; - } - return `
${esc(l.level)}`+ - `${esc(l.target.replace(/^ipx::?/,''))}${esc(l.msg)}
`; - }).join('') : '

Nothing matches.

'; - if(follow) box.scrollTop=box.scrollHeight; -} $('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; $('#addFeed').onclick=()=>{ @@ -295,25 +204,6 @@ function toggleGroup(id){ renderFeeds(); } let globalMax = 3; -const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; -const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; - -/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". -function splitEvery(m){ - if(!m) return {n:1, u:'h'}; - for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; - return {n:m, u:'m'}; -} -function unitOptions(sel){ - return UNITS.map(([v,l]) => - ``).join(''); -} -function everyText(m){ - if(!m) return '\u2014'; - const {n,u} = splitEvery(m); - const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; - return n + ' ' + name + (u!=='m' && n!==1 ? 's' : ''); -} function due(ts){ const d = ts - Date.now()/1000; if(d <= 0) return 'due now'; @@ -322,14 +212,11 @@ function due(ts){ return 'in '+Math.round(d/86400)+'d'; } -/// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the -/// Users screen behind it, are the operator's alone (the server refuses both from anyone -/// else). A non-admin gets the same modal minus those two parts, not no settings at all -- -/// Export/Import are theirs regardless, and seeing the schedule and quota explains why a -/// feed is polled when it is. +/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does +/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an +/// admin is sent (issue #19); this used to hold them too, shown to admins only. async function prefsModal(){ const g = await api('/api/settings'); - const gs = splitEvery(g.every_mins); const admin = !!(S.me&&S.me.admin); openModal(`

Settings

@@ -339,33 +226,6 @@ async function prefsModal(){ Auto follows your system's light/dark setting.
-
- ${admin?`
- - -
- Applies to every feed that does not set its own. A feed's suggested - interval (its ttl) is still honoured when it asks to be polled less often.` - :`${everyText(g.every_mins)}, for every feed that does not set its - own. Only an admin changes this.`}
- ${admin?`
- - Applies to any feed that does not set its own — including every feed - inside an OPML subscription. 0 means unlimited, which will pull a whole back - catalogue the first time a feed is scanned.
-
- - Anything else is still listed and can be downloaded by hand — blog feeds - put article images in enclosures, and those are not worth keeping. Empty takes everything.
-
- - Over this, the oldest played items are deleted first. Pinned - items are never touched.
-
-
`:''} -
- ${esc(g.download_dir)}
@@ -374,76 +234,15 @@ async function prefsModal(){
Export saves your subscriptions as OPML for another podcast app. Import subscribes you to every feed in one.
- ${admin?`
-
- Add and remove the people who can sign in, and choose who is an admin.
`:''} -
- ${admin?``:''}
`); +
+ ${everyText(g.every_mins)}, for every feed that does not set its own. + ${admin?'This and the rest of the server\'s settings are on the admin page.':'Only an admin changes this.'}
+
+ ${esc(g.download_dir)}
+
`); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#gopml').onclick=opmlModal; - if(!admin) return; - $('#gusers').onclick=usersModal; - $('#gsave').onclick=async()=>{ - try{ - await api('/api/settings',{method:'PATCH',body:JSON.stringify({ - schedule:`every ${Math.max(1,Number($('#gnum').value)||1)}${$('#gunit').value}`, - max_new_per_check:Math.max(0,Number($('#gmax').value)||0), - media_types:$('#gtypes').value.split(',').map(t=>t.trim()).filter(Boolean), - max_total_gb:Number($('#gquota').value)||0, - max_age_days:Number($('#gage').value)||0})}); - closeModal(); toast('Settings saved'); - await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } - }catch(e){ toast(e.message,true); } - }; -} - -// Admin only, and the server enforces that: this screen is just the way in. -async function usersModal(){ - const users = await api('/api/users') || []; - openModal(`

Users

- ${users.map(u=>`
-
${esc(u.name)} - ${u.created?`Added ${dateOf(u.created)}`:'Added before this was kept'} · ${ - u.last_login?`signed in ${ago(u.last_login)}`:'never signed in'}
- ${u.password?'':'Proxy'} - -
`).join('')} -
-
- - -
- - At least 8 characters. Leave the password empty for someone who signs in - through the proxy. New people start with no feeds.
-
-
`); - const change=async(u,opts)=>{ - try{ - await api(`/api/users/${u.id}`,opts); - // Demoting yourself takes this screen away; reload so the page stops offering it. Only - // on success: reloading after a refusal wiped the toast that said why. - if(u.name===S.me?.name){ location.reload(); return; } - }catch(e){ toast(e.message,true); } - usersModal(); // on a refusal, this puts the checkbox back where the server left it - }; - $$('#modalCard [data-id]').forEach(row=>{ - const u=users.find(x=>String(x.id)===row.dataset.id); - $('[data-a="admin"]',row).onchange=e=> - change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})}); - $('[data-a="rm"]',row).onclick=()=>{ - if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) - change(u,{method:'DELETE'}); - }; - }); - $('#uadd').onclick=async()=>{ - try{ - await api('/api/users',{method:'POST',body:JSON.stringify({ - name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})}); - toast('Added'); usersModal(); - }catch(e){ toast(e.message,true); } // keep what was typed - }; } function settingsModal(f, newUrl?: string){ @@ -582,7 +381,6 @@ api('/api/me').then(u=>{ S.me=u; $('#who').textContent=u.name+(u.admin?' · admin':''); }).catch(()=>{}); -$('#logs').onclick=logsModal; $('#feedFilter').oninput=renderFeeds; // The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a // folder. Handled keys stop here, or the player's own Space and arrows would act on them too. diff --git a/web/src/util.ts b/web/src/util.ts index 5b7ee01..f80431f 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -46,6 +46,7 @@ const ICON={ fwd:fa('0 0 512 512',''), // solid/rotate-right pause:fa('0 0 384 512',''), // solid/pause alert:fa('0 0 128 512',''), // solid/exclamation + admin:fa('0 0 576 512',''), // solid/screwdriver-wrench caret:fa('0 0 256 512',''), // solid/caret-right left:fa('0 0 512 512',''), // solid/arrow-left subbed:fa('0 0 512 512',''), // solid/circle-check @@ -156,3 +157,22 @@ const S = { }; const LIMIT = 50; +const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; +const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; + +/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". +function splitEvery(m){ + if(!m) return {n:1, u:'h'}; + for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; + return {n:m, u:'m'}; +} +function unitOptions(sel){ + return UNITS.map(([v,l]) => + ``).join(''); +} +function everyText(m){ + if(!m) return '\u2014'; + const {n,u} = splitEvery(m); + const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; + return n + ' ' + name + (u!=='m' && n!==1 ? 's' : ''); +}