// Formatting and screen-reader helpers shared by every module. A leaf: it // imports nothing, so any module can use it without creating a cycle. export const $ = id => document.getElementById(id); export const esc = s => String(s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); export const pad = n => String(n).padStart(2, '0'); export const fmtDur = s => { if (s == null) return '—'; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60); return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`; }; // "2 h 59 min" — for running text a screen reader speaks, where "2:59:59" // reads like a clock time export const fmtDurWords = s => { const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); if (h) return m ? `${h} h ${m} min` : `${h} h`; return m ? `${m} min` : `${Math.round(s)} s`; }; export const fmtSize = b => { if (b < 1024) return b + ' B'; if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB'; if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB'; return (b / (1 << 30)).toFixed(2) + ' GB'; }; // Epoch seconds -> local wall-clock "HH:MM:SS" export const fmtClock = ts => { const d = new Date(ts * 1000); return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); }; // Epoch seconds -> local "HH:MM" export const fmtHM = ts => { const d = new Date(ts * 1000); return pad(d.getHours()) + ':' + pad(d.getMinutes()); }; // Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of // the filename server-side) -> epoch seconds, or null for unparseable values export const fileStartEpoch = date => { const t = Date.parse(String(date).replace(' ', 'T')); return isNaN(t) ? null : t / 1000; }; // "1:30", "0:01:30" or "90" -> seconds; null when unparseable export function parseTime(s) { if (!s || !s.trim()) return null; const parts = s.trim().split(':').map(v => parseFloat(v)); if (parts.some(isNaN) || parts.length > 3) return null; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; return parts[0]; } // "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the // ISO date, which screen readers read digit group by digit group. en-GB to // match the page's lang="en", so the speech language never switches mid-page. export function dayLabel(day) { const [y, m, d] = day.split('-').map(Number); const date = new Date(y, m - 1, d); if (isNaN(date)) return day; const now = new Date(); const opts = {weekday: 'long', day: 'numeric', month: 'long'}; if (y !== now.getFullYear()) opts.year = 'numeric'; const text = date.toLocaleDateString('en-GB', opts); // Math.round absorbs the 23/25 h days around DST switches const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000); return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text; } export function announce(msg) { const el = $('sr-announce'); if (!el) return; el.textContent = ''; // clear first so same text re-triggers setTimeout(() => { el.textContent = msg; }, 50); } // A long chip list is a wall of buttons: hide it behind an aria-expanded // toggle. Returns the toggle to insert before the chips, or null when the list // is short enough to show as is. The separator space lives inside the // aria-hidden arrow — a leading space in the text node reads as an indent. export function chipToggle(chips, text) { if (chips.children.length <= 12) return null; chips.hidden = true; const tog = document.createElement('button'); tog.className = 'chip-toggle'; tog.setAttribute('aria-expanded', 'false'); const arrow = document.createElement('span'); arrow.className = 'day-arrow'; arrow.setAttribute('aria-hidden', 'true'); arrow.textContent = '▸ '; tog.append(arrow, text); tog.addEventListener('click', () => { const exp = chips.hidden; chips.hidden = !exp; tog.setAttribute('aria-expanded', exp); arrow.textContent = exp ? '▾ ' : '▸ '; }); return tog; } // localStorage for per-browser conveniences (pre-roll, auto-advance, whether // the settings are open). It can be missing or throw (private mode, blocked // storage), and nothing here may depend on it working. export const remember = { get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } }, set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } }, };