From b335f95f45c8461dd3efa042cc02b4dd600c5c38 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 8 Oct 2026 04:46:40 +0800 Subject: [PATCH] Load Stats view separately from listening-stat tracking --- frontend/app-seams.test.js | 8 +++ frontend/app.js | 125 +------------------------------------ frontend/assets.json | 11 ++++ frontend/index.html | 2 +- frontend/stats-view.js | 124 ++++++++++++++++++++++++++++++++++++ frontend/sw.js | 1 + perf/seams.mjs | 2 +- 7 files changed, 149 insertions(+), 124 deletions(-) create mode 100644 frontend/stats-view.js 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 = ` -
-
📊
-

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) => ` -
- ${escapeHtml(a.name)} - - ${a.plays} -
`).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)} - ${t.plays} -
`).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 - - ${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => ``).join('') || ''} -
DayMinutes
${fmtDay(x.date)}${x.minutes}
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); - })); +function renderStats() { + return Lazy.proxy('feature:stats-view', ['render'], 'StatsView').render(); } + // ============================================================================ // Presenter / projector view (/?present=) // ============================================================================ 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) => ` +
+ ${escapeHtml(a.name)} + + ${a.plays} +
`).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)} + ${t.plays} +
`).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 + + ${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => ``).join('') || ''} +
DayMinutes
${fmtDay(x.date)}${x.minutes}
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.