fix(v0.5.3-prep): Quick Stations robust load + add history export

Two fixes in one commit, both responding to David's testing of
v0.5.3-prep:

1. Quick Stations 'Loading...' stuck

The picker showed 'Loading channel list…' indefinitely when the
user opened Options BEFORE ever opening the New Tab Page (so the
tuner.stationsCache hadn't been populated yet). Fix:
initQuickStationsUI now falls back to calling listAllStations()
directly if the cache is empty, then writes the result back to
cache so future opens are instant. New error message if the network
fetch fails: 'Could not load SomaFM channel list — check your
network connection and reopen Settings.'

2. History export (JSON + CSV)

Added two buttons in the Local data card 'Export history (JSON)'
and 'Export history (CSV)'. JSON dumps the full tuner.history array
pretty-printed; CSV uses RFC 4180 quoting (every cell quoted, doubled
quotes for embedded quotes) with header artist,title,station,
station_id,when_iso. Filename: rangerhq-tuner-history-YYYY-MM-DD.{ext}.
Empty-history case shows an error toast. Toast confirms count + format
on success.

Per David: 'this app is kinda done' after this, then upload to Web
Store once v0.5.0 clears.
This commit is contained in:
2026-06-10 01:07:53 +01:00
parent 529409eed9
commit 60331b8350
2 changed files with 71 additions and 2 deletions
+5
View File
@@ -53,6 +53,11 @@
</div> </div>
</div> </div>
<div class="opt-actions">
<button id="opt-export-json" class="opt-btn">Export history (JSON)</button>
<button id="opt-export-csv" class="opt-btn">Export history (CSV)</button>
</div>
<div class="opt-actions"> <div class="opt-actions">
<button id="opt-clear-history" class="opt-btn">Clear history</button> <button id="opt-clear-history" class="opt-btn">Clear history</button>
<button id="opt-clear-favs" class="opt-btn">Clear favourites</button> <button id="opt-clear-favs" class="opt-btn">Clear favourites</button>
+66 -2
View File
@@ -9,6 +9,7 @@ import {
getHistoryCap, setHistoryCap, getHistoryCap, setHistoryCap,
clearHistory, clearFavourites, clearAll, clearHistory, clearFavourites, clearAll,
} from '../lib/history.js'; } from '../lib/history.js';
import { listAllStations } from '../sources/index.js';
import { import {
THEME_KEY, THEME_DEFAULT, VALID_THEMES, THEME_KEY, THEME_DEFAULT, VALID_THEMES,
getTheme, setTheme, applyTheme, getTheme, setTheme, applyTheme,
@@ -42,6 +43,9 @@ const els = {
qsCount: document.getElementById('opt-qs-count'), qsCount: document.getElementById('opt-qs-count'),
qsList: document.getElementById('opt-qs-list'), qsList: document.getElementById('opt-qs-list'),
qsReset: document.getElementById('opt-qs-reset'), qsReset: document.getElementById('opt-qs-reset'),
// Export history (v0.5.3)
exportJson: document.getElementById('opt-export-json'),
exportCsv: document.getElementById('opt-export-csv'),
}; };
init().catch(err => { init().catch(err => {
@@ -76,6 +80,9 @@ async function init() {
await refreshStats(); await refreshStats();
}); });
els.exportJson.addEventListener('click', () => exportHistoryAs('json'));
els.exportCsv.addEventListener('click', () => exportHistoryAs('csv'));
els.clearHistory.addEventListener('click', async () => { els.clearHistory.addEventListener('click', async () => {
if (!confirm('Clear all track history? Favourites will be preserved.')) return; if (!confirm('Clear all track history? Favourites will be preserved.')) return;
await clearHistory(); await clearHistory();
@@ -151,7 +158,24 @@ async function initThemeUI() {
* tuner.quickStations. NewTab's storage.onChanged listener re-renders * tuner.quickStations. NewTab's storage.onChanged listener re-renders
* the chip row instantly without a reload. */ * the chip row instantly without a reload. */
async function initQuickStationsUI() { async function initQuickStationsUI() {
const cache = (await chrome.storage.local.get('tuner.stationsCache'))['tuner.stationsCache'] || []; // Try the NewTab cache first (fast path).
let cache = (await chrome.storage.local.get('tuner.stationsCache'))['tuner.stationsCache'] || [];
// Fresh install fallback: if Options is opened BEFORE NewTab has ever
// populated the cache, fetch SomaFM channels directly + cache them.
if (!cache.length) {
try {
cache = await listAllStations();
// Cache it back so future loads are instant.
await chrome.storage.local.set({
'tuner.stationsCache': cache,
'tuner.stationsCachedAt': Date.now(),
});
} catch (err) {
console.warn('Quick Stations: failed to fetch channel list', err);
}
}
// Stations come in as { id: "somafm:groovesalad", name, description, genre, ... } // Stations come in as { id: "somafm:groovesalad", name, description, genre, ... }
// Sort alphabetically by name for predictable picker order. // Sort alphabetically by name for predictable picker order.
const channels = [...cache].sort((a, b) => (a.name || '').localeCompare(b.name || '')); const channels = [...cache].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
@@ -161,7 +185,7 @@ async function initQuickStationsUI() {
if (!channels.length) { if (!channels.length) {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'opt-qs-loading'; li.className = 'opt-qs-loading';
li.textContent = 'Channel list not cached yet — open the New Tab Page once to populate it.'; li.textContent = 'Could not load SomaFM channel list — check your network connection and reopen Settings.';
els.qsList.appendChild(li); els.qsList.appendChild(li);
return; return;
} }
@@ -288,6 +312,46 @@ function formatBytes(n) {
return `${(n / (1024 * 1024)).toFixed(2)} MB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`;
} }
/** Download tuner.history as a JSON or CSV file. (v0.5.3) */
async function exportHistoryAs(format) {
const history = await getHistory();
if (!history.length) {
toast('No history to export yet — play some music first', 'error');
return;
}
let blob, ext, mime;
if (format === 'json') {
blob = new Blob([JSON.stringify(history, null, 2)], { type: 'application/json' });
ext = 'json';
} else {
// CSV — RFC 4180 quoting (doubled quotes, every cell quoted for safety).
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const header = 'artist,title,station,station_id,when_iso\n';
const rows = history.map(e => [
q(e.artist),
q(e.title),
q(e.station),
q(e.stationId),
q(e.at ? new Date(e.at).toISOString() : ''),
].join(',')).join('\n');
blob = new Blob([header + rows + '\n'], { type: 'text/csv;charset=utf-8' });
ext = 'csv';
}
const ts = new Date().toISOString().slice(0, 10);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rangerhq-tuner-history-${ts}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast(`Exported ${history.length} track${history.length === 1 ? '' : 's'} as ${ext.toUpperCase()}`);
}
function toast(message, tone) { function toast(message, tone) {
els.toast.textContent = message; els.toast.textContent = message;
els.toast.dataset.tone = tone || ''; els.toast.dataset.tone = tone || '';