Load Stats view separately from listening-stat tracking
This commit is contained in:
@@ -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'\)/);
|
||||
});
|
||||
|
||||
125
frontend/app.js
125
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 = `
|
||||
<div class="empty-state stats-empty">
|
||||
<div class="empty-icon">📊</div>
|
||||
<h3 class="empty-title">No listening stats yet</h3>
|
||||
<p class="empty-desc">Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.</p>
|
||||
</div>`;
|
||||
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 `<svg class="st-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" /></svg>`;
|
||||
};
|
||||
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 = `
|
||||
<div class="stats">
|
||||
<div class="st-tiles">
|
||||
<div class="st-tile">
|
||||
<div class="st-label">⏱ Listening this month</div>
|
||||
<div class="st-big">${hrs(S.thisMonthSec)}</div>
|
||||
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">▶ Track plays</div>
|
||||
<div class="st-big">${S.totalPlays}</div>
|
||||
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">🔥 Practice streak</div>
|
||||
<div class="st-big">${S.streak.current}-day</div>
|
||||
<div class="st-sub">${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="st-grid">
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎤 Top artists</span><small>plays this month</small></div>
|
||||
${S.topArtists.length ? S.topArtists.map((a) => `
|
||||
<div class="st-bar-row" data-tip="${escapeHtml(a.name)} — ${a.plays} play${a.plays === 1 ? '' : 's'} this month">
|
||||
<span class="st-bar-name">${escapeHtml(a.name)}</span>
|
||||
<span class="st-bar-track"><span class="st-bar" style="width:${Math.max(3, (a.plays / maxArtist) * 100)}%"></span></span>
|
||||
<span class="st-bar-val">${a.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays this month yet.</div>'}
|
||||
</section>
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎵 Top songs</span><small>last 30 days</small></div>
|
||||
${S.topSongs.length ? S.topSongs.map((t, i) => `
|
||||
<div class="st-song" data-tip="${escapeHtml(t.title)} — ${t.plays} play${t.plays === 1 ? '' : 's'} in 30 days" data-id="${escapeHtml(t.id)}">
|
||||
<span class="st-rank">${String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="st-song-text"><b>${escapeHtml(t.title)}</b><small>${escapeHtml(t.channel)}</small></span>
|
||||
${spark(t.spark)}
|
||||
<span class="st-bar-val">${t.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays in the last 30 days.</div>'}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🗓 Listening habits</span><small>last 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}</small></div>
|
||||
<div class="st-heat-wrap">
|
||||
<div class="st-heat-days"><span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span>Sun</span></div>
|
||||
<div class="st-heat-main">
|
||||
<div class="st-heat-months">${months.map((m) => `<span>${m}</span>`).join('')}</div>
|
||||
<div class="st-heat">${S.heat.map((col) => col.map((x) => x.future
|
||||
? '<span class="st-cell future"></span>'
|
||||
: `<span class="st-cell l${level(x.minutes)}" data-tip="${fmtDay(x.date)} · ${x.minutes} min"></span>`).join('')).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-legend"><span>Less</span>${[0, 1, 2, 3, 4].map((l) => `<span class="st-cell l${l}"></span>`).join('')}<span>More</span></div>
|
||||
<details class="st-table"><summary>Show as table</summary>
|
||||
<table><thead><tr><th>Day</th><th>Minutes</th></tr></thead><tbody>
|
||||
${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => `<tr><td>${fmtDay(x.date)}</td><td>${x.minutes}</td></tr>`).join('') || '<tr><td colspan="2">No listening in this window.</td></tr>'}
|
||||
</tbody></table>
|
||||
</details>
|
||||
</section>
|
||||
<div id="statsTip" class="st-tip hidden"></div>
|
||||
</div>`;
|
||||
|
||||
// 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=<code>)
|
||||
// ============================================================================
|
||||
|
||||
@@ -348,6 +348,17 @@
|
||||
"files": [
|
||||
"/share-external.js"
|
||||
]
|
||||
},
|
||||
"feature:stats-view": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/stats-view.js"
|
||||
],
|
||||
"dependencies": [
|
||||
"feature:stats"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
124
frontend/stats-view.js
Normal file
124
frontend/stats-view.js
Normal file
@@ -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 = `
|
||||
<div class="empty-state stats-empty">
|
||||
<div class="empty-icon">📊</div>
|
||||
<h3 class="empty-title">No listening stats yet</h3>
|
||||
<p class="empty-desc">Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.</p>
|
||||
</div>`;
|
||||
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 `<svg class="st-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" /></svg>`;
|
||||
};
|
||||
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 = `
|
||||
<div class="stats">
|
||||
<div class="st-tiles">
|
||||
<div class="st-tile">
|
||||
<div class="st-label">⏱ Listening this month</div>
|
||||
<div class="st-big">${hrs(S.thisMonthSec)}</div>
|
||||
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">▶ Track plays</div>
|
||||
<div class="st-big">${S.totalPlays}</div>
|
||||
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
<div class="st-tile">
|
||||
<div class="st-label">🔥 Practice streak</div>
|
||||
<div class="st-big">${S.streak.current}-day</div>
|
||||
<div class="st-sub">${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="st-grid">
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎤 Top artists</span><small>plays this month</small></div>
|
||||
${S.topArtists.length ? S.topArtists.map((a) => `
|
||||
<div class="st-bar-row" data-tip="${escapeHtml(a.name)} — ${a.plays} play${a.plays === 1 ? '' : 's'} this month">
|
||||
<span class="st-bar-name">${escapeHtml(a.name)}</span>
|
||||
<span class="st-bar-track"><span class="st-bar" style="width:${Math.max(3, (a.plays / maxArtist) * 100)}%"></span></span>
|
||||
<span class="st-bar-val">${a.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays this month yet.</div>'}
|
||||
</section>
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🎵 Top songs</span><small>last 30 days</small></div>
|
||||
${S.topSongs.length ? S.topSongs.map((t, i) => `
|
||||
<div class="st-song" data-tip="${escapeHtml(t.title)} — ${t.plays} play${t.plays === 1 ? '' : 's'} in 30 days" data-id="${escapeHtml(t.id)}">
|
||||
<span class="st-rank">${String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="st-song-text"><b>${escapeHtml(t.title)}</b><small>${escapeHtml(t.channel)}</small></span>
|
||||
${spark(t.spark)}
|
||||
<span class="st-bar-val">${t.plays}</span>
|
||||
</div>`).join('') : '<div class="st-none">No plays in the last 30 days.</div>'}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="st-panel">
|
||||
<div class="st-panel-head"><span>🗓 Listening habits</span><small>last 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}</small></div>
|
||||
<div class="st-heat-wrap">
|
||||
<div class="st-heat-days"><span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span>Sun</span></div>
|
||||
<div class="st-heat-main">
|
||||
<div class="st-heat-months">${months.map((m) => `<span>${m}</span>`).join('')}</div>
|
||||
<div class="st-heat">${S.heat.map((col) => col.map((x) => x.future
|
||||
? '<span class="st-cell future"></span>'
|
||||
: `<span class="st-cell l${level(x.minutes)}" data-tip="${fmtDay(x.date)} · ${x.minutes} min"></span>`).join('')).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-legend"><span>Less</span>${[0, 1, 2, 3, 4].map((l) => `<span class="st-cell l${l}"></span>`).join('')}<span>More</span></div>
|
||||
<details class="st-table"><summary>Show as table</summary>
|
||||
<table><thead><tr><th>Day</th><th>Minutes</th></tr></thead><tbody>
|
||||
${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => `<tr><td>${fmtDay(x.date)}</td><td>${x.minutes}</td></tr>`).join('') || '<tr><td colspan="2">No listening in this window.</td></tr>'}
|
||||
</tbody></table>
|
||||
</details>
|
||||
</section>
|
||||
<div id="statsTip" class="st-tip hidden"></div>
|
||||
</div>`;
|
||||
|
||||
// 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);
|
||||
}));
|
||||
} };
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user