diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index 6f137c8..0fa6e57 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -68,3 +68,11 @@ test('Share and External stay behind lazy entry proxies',()=>{ const text=context.window.YT.External.m3u([{id:'benchmedia01',title:'Fixture'}]); assert.match(text,/#EXTM3U/);assert.match(text,/benchmedia01/); }); + +test('listening tracking does not pull in the lazy Stats view',()=>{ + const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8')); + assert.deepEqual(groups['feature:stats-view'].files,['/stats-view.js']); + assert.deepEqual(groups['feature:stats-view'].dependencies,['feature:stats']); + assert.deepEqual(groups['feature:stats'].files,['/stats-core.js']); + assert.match(app,/Lazy\.proxy\('feature:stats-view', \['render'\], 'StatsView'\)/); +}); diff --git a/frontend/app.js b/frontend/app.js index a7be822..e2a778b 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -5180,130 +5180,11 @@ const StatsTrack = (() => { // ============================================================================ // Stats view // ============================================================================ -async function renderStats() { - try { await Lazy.load('feature:stats'); } catch (error) { toast(error.message); return; } - if (view.type !== 'stats') return; - StatsTrack.flush(); - els.listTitle.textContent = 'Stats'; - resetListHeaderExtras(); - els.listActions.innerHTML = ''; - els.status.classList.add('hidden'); - const c = els.cards; - const S = StatsCore.summarize(data.stats); - if (!S.totalPlays && S.totalSec < 60) { - c.innerHTML = ` -
Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.
-| Day | Minutes |
|---|---|
| ${fmtDay(x.date)} | ${x.minutes} |
| No listening in this window. | |
)
// ============================================================================
diff --git a/frontend/assets.json b/frontend/assets.json
index d81c7be..c8469f1 100644
--- a/frontend/assets.json
+++ b/frontend/assets.json
@@ -348,6 +348,17 @@
"files": [
"/share-external.js"
]
+ },
+ "feature:stats-view": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/stats-view.js"
+ ],
+ "dependencies": [
+ "feature:stats"
+ ]
}
}
}
diff --git a/frontend/index.html b/frontend/index.html
index 6230dec..ead66fd 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -19,7 +19,7 @@
-
+
diff --git a/frontend/stats-view.js b/frontend/stats-view.js
new file mode 100644
index 0000000..abfffc5
--- /dev/null
+++ b/frontend/stats-view.js
@@ -0,0 +1,124 @@
+/* Lazy classic chunk; its executing singleton stays pinned until reload. */
+(window.YT ||= {}).StatsView = { render: async function renderStats() {
+ try { await Lazy.load('feature:stats'); } catch (error) { toast(error.message); return; }
+ if (view.type !== 'stats') return;
+ StatsTrack.flush();
+ els.listTitle.textContent = 'Stats';
+ resetListHeaderExtras();
+ els.listActions.innerHTML = '';
+ els.status.classList.add('hidden');
+ const c = els.cards;
+ const S = StatsCore.summarize(data.stats);
+ if (!S.totalPlays && S.totalSec < 60) {
+ c.innerHTML = `
+
+
+ No listening stats yet
+ Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.
+ `;
+ return;
+ }
+ const hrs = (sec) => StatsCore.fmtHours(sec);
+ const delta = S.thisMonthSec - S.lastMonthSec;
+ const deltaTxt = S.lastMonthSec || S.thisMonthSec
+ ? `${delta >= 0 ? '▲ +' : '▼ −'}${(Math.abs(delta) / 3600).toFixed(1)}h vs last month`
+ : '';
+ const maxArtist = Math.max(1, ...S.topArtists.map((a) => a.plays));
+ const spark = (vals) => {
+ const w = 96, h = 26, max = Math.max(1, ...vals);
+ const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - 3 - (v / max) * (h - 6)}`).join(' ');
+ return ``;
+ };
+ const heatMax = Math.max(1, ...S.heat.flat().map((x) => x.minutes));
+ const level = (m) => (m <= 0 ? 0 : Math.min(4, 1 + Math.floor((m / heatMax) * 3.999)));
+ const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
+ // Month label over the first week column of each month.
+ const months = S.heat.map((col, w) => {
+ const d = new Date(`${col[0].date}T12:00:00`);
+ const prev = w ? new Date(`${S.heat[w - 1][0].date}T12:00:00`) : null;
+ return !prev || prev.getMonth() !== d.getMonth() ? d.toLocaleDateString(undefined, { month: 'short' }) : '';
+ });
+ const playlistsWith = data.playlists.length;
+
+ c.innerHTML = `
+
+
+
+ ⏱ Listening this month
+ ${hrs(S.thisMonthSec)}
+ ${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time
+
+
+ ▶ Track plays
+ ${S.totalPlays}
+ ${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}
+
+
+ 🔥 Practice streak
+ ${S.streak.current}-day
+ ${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}
+
+
+
+
+
+ 🎤 Top artistsplays this month
+ ${S.topArtists.length ? S.topArtists.map((a) => `
+ `).join('') : 'No plays this month yet.'}
+
+
+ 🎵 Top songslast 30 days
+ ${S.topSongs.length ? S.topSongs.map((t, i) => `
+
+ ${String(i + 1).padStart(2, '0')}
+ ${escapeHtml(t.title)}${escapeHtml(t.channel)}
+ ${spark(t.spark)}
+
+ `).join('') : 'No plays in the last 30 days.'}
+
+
+
+
+ 🗓 Listening habitslast 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}
+
+ MonWedFriSun
+
+ ${months.map((m) => `${m}`).join('')}
+ ${S.heat.map((col) => col.map((x) => x.future
+ ? ''
+ : ``).join('')).join('')}
+
+
+ Less${[0, 1, 2, 3, 4].map((l) => ``).join('')}More
+ Show as table
+ Day Minutes
+ ${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => `${fmtDay(x.date)} ${x.minutes} `).join('') || 'No listening in this window. '}
+
+
+
+
+ `;
+
+ // One tooltip for every mark (bars, songs, heat cells).
+ const root = c.querySelector('.stats');
+ const tip = $('statsTip');
+ root.addEventListener('pointermove', (e) => {
+ const el = e.target.closest('[data-tip]');
+ if (!el) { tip.classList.add('hidden'); return; }
+ tip.textContent = el.dataset.tip;
+ tip.classList.remove('hidden');
+ const r = root.getBoundingClientRect();
+ const half = tip.offsetWidth / 2 + 4; // keep the whole tip inside the panel
+ tip.style.left = `${Math.min(r.width - half, Math.max(half, e.clientX - r.left))}px`;
+ tip.style.top = `${e.clientY - r.top - 14}px`;
+ });
+ root.addEventListener('pointerleave', () => tip.classList.add('hidden'));
+ root.querySelectorAll('.st-song').forEach((row) => row.addEventListener('click', () => {
+ const v = data.history.find((x) => x.id === row.dataset.id) || { id: row.dataset.id, title: row.querySelector('b').textContent };
+ Player.loadVideo(v);
+ }));
+} };
diff --git a/frontend/sw.js b/frontend/sw.js
index a1a84d2..0551c1b 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -144,6 +144,7 @@ const SHELL = [
'/remote-client.js',
'/watch-party-client.js',
'/share-external.js',
+ '/stats-view.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
diff --git a/perf/seams.mjs b/perf/seams.mjs
index 1413091..58d705b 100644
--- a/perf/seams.mjs
+++ b/perf/seams.mjs
@@ -61,7 +61,7 @@ async function run(name){
await page.evaluate(()=>closeModal());checked.push('share');
}
if(groups.includes('feature:stats-view')) {
- await page.evaluate(async()=>{view={type:'stats'};await renderStats();});await page.waitForSelector('.stats-empty');checked.push('stats');
+ await page.evaluate(async()=>{view={type:'stats'};render();});await page.waitForSelector('.stats-empty');checked.push('stats');
}
assert.equal(proxy.getStats().requestCount,before,'all warmed seam code loads from SW without network');
// Ignore only verified disconnected version/recommendation API transports in WebKit.