Skip to content

Commit bfa4825

Browse files
committed
feat: add new watched time graph to the statistics page
1 parent 9613354 commit bfa4825

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

‎backend/routers/profile.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,28 @@ async def get_user_stats(
738738
activity_map[period_key]["episodes"] = cnt
739739
watch_activity = sorted(activity_map.values(), key=lambda x: x["month"])
740740

741+
# Watch time per period (same granularity as activity)
742+
watch_time_activity_q = await db.execute(
743+
select(
744+
activity_expr.label("period"),
745+
Media.media_type,
746+
func.coalesce(func.sum(effective_runtime), 0).label("minutes"),
747+
)
748+
.join(WatchEvent, WatchEvent.media_id == Media.id)
749+
.where(WatchEvent.user_id == user_id, Media.media_type.in_(["movie", "episode"]), *date_filters)
750+
.group_by(activity_expr, Media.media_type)
751+
.order_by(activity_expr)
752+
)
753+
watch_time_map: dict[str, dict] = {}
754+
for period_key, mtype, minutes in watch_time_activity_q.all():
755+
if period_key not in watch_time_map:
756+
watch_time_map[period_key] = {"month": period_key, "movie_minutes": 0, "show_minutes": 0}
757+
if mtype == "movie":
758+
watch_time_map[period_key]["movie_minutes"] = int(minutes)
759+
else:
760+
watch_time_map[period_key]["show_minutes"] = int(minutes)
761+
watch_time_activity = sorted(watch_time_map.values(), key=lambda x: x["month"])
762+
741763
# Average watches per weekday (0=Sun … 6=Sat)
742764
from sqlalchemy import extract
743765
dow_expr = func.extract("dow", WatchEvent.watched_at)
@@ -911,6 +933,7 @@ async def get_user_stats(
911933
"movie_watch_minutes": movie_watch_minutes,
912934
"show_watch_minutes": show_watch_minutes,
913935
"watch_activity": watch_activity,
936+
"watch_time_activity": watch_time_activity,
914937
"rating_distribution": rating_distribution,
915938
"avg_movie_rating": avg_movie_rating,
916939
"avg_show_rating": avg_show_rating,

‎frontend/src/pages/stats/[id].astro‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)