@@ -114,6 +114,12 @@ const isOwnStats = currentUser && String(currentUser.id) === String(id);
114114 </div >
115115 </div >
116116
117+ <!-- Watch time chart -->
118+ <div class = " bg-zinc-900/60 border border-zinc-800 rounded-2xl p-6 lg:col-span-2" >
119+ <h3 class = " group-label mb-5" >Watch Time Over Time</h3 >
120+ <div id = " watch-time-chart" class = " h-40 animate-pulse bg-zinc-800/50 rounded-xl" ></div >
121+ </div >
122+
117123 <!-- Watch time breakdown -->
118124 <div class = " bg-zinc-900/60 border border-zinc-800 rounded-2xl p-6" >
119125 <h3 class = " group-label mb-5" >Watch Time</h3 >
@@ -367,6 +373,104 @@ const isOwnStats = currentUser && String(currentUser.id) === String(id);
367373 }
368374 }
369375
376+ // Watch time chart
377+ const watchTimeChart = document.getElementById('watch-time-chart');
378+ if (watchTimeChart) {
379+ let wtData = since && until
380+ ? (() => {
381+ const dataMap = Object.fromEntries((data.watch_time_activity || []).map(d => [d.month, d]));
382+ const filled = [];
383+ if (isDaily) {
384+ const cur = new Date(since); cur.setHours(0,0,0,0);
385+ const end = new Date(until); end.setHours(0,0,0,0);
386+ while (cur <= end) {
387+ const key = _fmt(cur);
388+ filled.push(dataMap[key] || { month: key, movie_minutes: 0, show_minutes: 0 });
389+ cur.setDate(cur.getDate() + 1);
390+ }
391+ } else {
392+ const [sy, sm] = since.split('-').map(Number);
393+ const [uy, um] = until.split('-').map(Number);
394+ let cy = sy, cm = sm;
395+ while (cy < uy || (cy === uy && cm <= um)) {
396+ const key = `${cy}-${String(cm).padStart(2,'0')}`;
397+ filled.push(dataMap[key] || { month: key, movie_minutes: 0, show_minutes: 0 });
398+ if (++cm > 12) { cm = 1; cy++; }
399+ }
400+ }
401+ return filled;
402+ })()
403+ : (data.watch_time_activity || []).slice(-24);
404+
405+ watchTimeChart.classList.remove('animate-pulse', 'bg-zinc-800/50', 'rounded-xl');
406+
407+ const fmtTime = (mins) => {
408+ if (!mins) return '0m';
409+ const h = Math.floor(mins / 60), m = mins % 60;
410+ return h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
411+ };
412+
413+ if (wtData.length > 0 && wtData.some(d => d.movie_minutes + d.show_minutes > 0)) {
414+ const maxVal = Math.max(...wtData.map(d => d.movie_minutes + d.show_minutes), 1);
415+ const barMinWidth = isDaily ? (wtData.length > 90 ? 4 : 10) : 32;
416+ watchTimeChart.style.display = 'flex';
417+ watchTimeChart.style.flexDirection = 'column';
418+ watchTimeChart.innerHTML = `
419+ <div id="wt-info" style="height:18px;margin-bottom:4px;display:flex;align-items:center">
420+ <span id="wt-info-text" style="font-size:11px;font-weight:700;color:#a1a1aa"></span>
421+ </div>
422+ <div class="overflow-x-auto scrollbar-none" style="flex:1;min-height:0">
423+ <div style="display:flex;flex-direction:column;min-width:max-content;height:100%;padding-bottom:4px">
424+ <div style="flex:1;display:flex;align-items:flex-end;gap:2px;min-height:0">
425+ ${wtData.map(d => {
426+ const total = d.movie_minutes + d.show_minutes;
427+ const pct = (total / maxVal) * 100;
428+ const moviePct = total > 0 ? (d.movie_minutes / total) * 100 : 50;
429+ const parts = d.month.split('-').map(Number);
430+ const barLabel = isDaily
431+ ? `${monthNames[parts[1]-1]}. ${parts[2]}, ${parts[0]}`
432+ : `${monthNames[parts[1]-1]}. ${parts[0]}`;
433+ const tooltip = `${barLabel}: ${fmtTime(total)} total · ${fmtTime(d.movie_minutes)} movies · ${fmtTime(d.show_minutes)} shows`;
434+ return `
435+ <div data-info="${tooltip}" style="flex:1;min-width:${barMinWidth}px;height:100%;display:flex;flex-direction:column;justify-content:flex-end">
436+ <div style="height:${total > 0 ? Math.max(pct, 2) : 0}%;width:100%;border-radius:3px 3px 0 0;overflow:hidden;display:flex;flex-direction:column">
437+ <div style="flex:${moviePct};background:rgba(14,165,233,0.8)"></div>
438+ <div style="flex:${100-moviePct};background:rgba(168,85,247,0.8)"></div>
439+ </div>
440+ </div>
441+ `;
442+ }).join('')}
443+ </div>
444+ <div style="display:flex;gap:2px;margin-top:4px">
445+ ${wtData.map(d => {
446+ const label = getActivityLabel(d, isDaily, wtData.length);
447+ return `<div style="flex:1;min-width:${barMinWidth}px;text-align:center"><span style="font-size:9px;font-weight:700;color:#52525b;text-transform:uppercase;letter-spacing:.04em">${label}</span></div>`;
448+ }).join('')}
449+ </div>
450+ </div>
451+ </div>
452+ <div style="display:flex;gap:16px;margin-top:10px;padding-top:8px;border-top:1px solid rgba(39,39,42,0.4)">
453+ <span style="display:flex;align-items:center;gap:6px;font-size:10px;font-weight:700;color:#71717a;text-transform:uppercase;letter-spacing:.1em"><span style="width:8px;height:8px;border-radius:2px;background:rgba(14,165,233,0.8);display:inline-block"></span>Movies</span>
454+ <span style="display:flex;align-items:center;gap:6px;font-size:10px;font-weight:700;color:#71717a;text-transform:uppercase;letter-spacing:.1em"><span style="width:8px;height:8px;border-radius:2px;background:rgba(168,85,247,0.8);display:inline-block"></span>Shows</span>
455+ </div>
456+ `;
457+ const wtInfoText = document.getElementById('wt-info-text');
458+ watchTimeChart.addEventListener('mouseover', e => {
459+ const bar = e.target.closest('[data-info]');
460+ if (bar && wtInfoText) wtInfoText.textContent = bar.dataset.info;
461+ });
462+ watchTimeChart.addEventListener('mouseleave', () => {
463+ if (wtInfoText) wtInfoText.textContent = '';
464+ });
465+ watchTimeChart.addEventListener('touchstart', e => {
466+ const bar = e.target.closest('[data-info]');
467+ if (bar && wtInfoText) wtInfoText.textContent = bar.dataset.info;
468+ }, { passive: true });
469+ } else {
470+ watchTimeChart.innerHTML = '<p style="color:#52525b;font-size:14px;font-weight:500;text-align:center;padding-top:40px">No watch time data for the selected period.</p>';
471+ }
472+ }
473+
370474 // Rating distribution
371475 const ratingChart = document.getElementById('rating-chart');
372476 if (ratingChart) {
0 commit comments