Initial commit — YT Hub
Self-hosted personal YouTube management app. FastAPI + SQLite backend, React + Vite + Tailwind frontend. Dockerfiles and compose included for Portainer deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
197
frontend/src/pages/Stats.jsx
Normal file
197
frontend/src/pages/Stats.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStats } from "../api";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function fmt(seconds) {
|
||||
if (!seconds) return "0m";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h === 0) return `${m}m`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-2xl p-4 flex flex-col gap-1">
|
||||
<p className="text-[11px] text-zinc-500 uppercase tracking-wider font-medium">{label}</p>
|
||||
<p className="text-2xl font-bold text-white font-mono leading-none">{value}</p>
|
||||
{sub && <p className="text-xs text-zinc-600 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stats() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stats"],
|
||||
queryFn: () => getStats().then(r => r.data),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const maxSeconds = Math.max(...(data.top_channels.map(c => c.watch_seconds || 0)), 1);
|
||||
const today = new Date();
|
||||
const days = Array.from({ length: 30 }, (_, i) => {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - (29 - i));
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
const dailyMap = Object.fromEntries((data.daily || []).map(d => [d.date, d]));
|
||||
const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1);
|
||||
|
||||
const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
|
||||
const topTags = (data.taste_profile || []).slice(0, 12);
|
||||
const maxTagScore = Math.max(...topTags.map(t => t.score || 0), 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
|
||||
<h1 className="font-display font-bold text-2xl text-white">Stats</h1>
|
||||
|
||||
{/* Top numbers */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard label="Total watched" value={data.total_watched.toLocaleString()} sub={fmt(data.total_watch_seconds) + " total"} />
|
||||
<StatCard label="This week" value={data.this_week.count} sub={fmt(data.this_week.seconds)} />
|
||||
<StatCard label="This month" value={data.this_month.count} sub={fmt(data.this_month.seconds)} />
|
||||
<StatCard label="Total liked" value={(data.total_liked || 0).toLocaleString()} sub="videos" />
|
||||
</div>
|
||||
|
||||
{/* Engagement row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
label="Avg completion"
|
||||
value={`${data.avg_completion_percent ?? 0}%`}
|
||||
sub="of videos you start"
|
||||
/>
|
||||
<StatCard
|
||||
label="Finished"
|
||||
value={(data.finished_count || 0).toLocaleString()}
|
||||
sub="watched ≥90%"
|
||||
/>
|
||||
<StatCard
|
||||
label="Bailed early"
|
||||
value={(data.bailed_count || 0).toLocaleString()}
|
||||
sub="left before 20%"
|
||||
/>
|
||||
<StatCard
|
||||
label="Rewatched"
|
||||
value={(data.rewatched_videos || 0).toLocaleString()}
|
||||
sub={`${data.total_rewatches || 0} total rewatches`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity chart */}
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Activity — last 30 days</h2>
|
||||
<div className="flex items-end gap-0.5 h-16">
|
||||
{days.map(date => {
|
||||
const entry = dailyMap[date];
|
||||
const count = entry?.count || 0;
|
||||
const pct = count / maxDayCount;
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
title={`${date}: ${count} video${count !== 1 ? "s" : ""}`}
|
||||
className="flex-1 rounded-sm transition-all"
|
||||
style={{
|
||||
height: count === 0 ? "2px" : `${Math.max(pct * 100, 6)}%`,
|
||||
backgroundColor: count === 0 ? "#27272a" : `hsl(50,95%,${30 + pct * 30}%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-zinc-600">
|
||||
<span>30 days ago</span><span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Top channels */}
|
||||
{data.top_channels.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Top channels by watch time</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{data.top_channels.map(ch => {
|
||||
const pct = (ch.watch_seconds || 0) / maxSeconds;
|
||||
return (
|
||||
<div key={ch.id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<Link to={`/channels/${ch.id}`} className="text-zinc-200 hover:text-white transition-colors truncate text-[13px]">{ch.name}</Link>
|
||||
<span className="text-zinc-500 text-[11px] shrink-0 ml-2 font-mono">{ch.watch_count} · {fmt(ch.watch_seconds)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent/60 rounded-full" style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top categories */}
|
||||
{data.top_categories?.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Top categories</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{data.top_categories.map(cat => {
|
||||
const pct = cat.watch_count / maxCatCount;
|
||||
const comp = cat.avg_completion ? Math.round(cat.avg_completion) : null;
|
||||
return (
|
||||
<div key={cat.category} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] text-zinc-200 truncate">{cat.category}</span>
|
||||
<span className="text-[11px] text-zinc-500 shrink-0 ml-2 font-mono">
|
||||
{cat.watch_count}{comp !== null ? ` · ${comp}% avg` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-indigo-500/60 rounded-full" style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Taste profile */}
|
||||
{topTags.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
|
||||
<p className="text-[11px] text-zinc-600">built from your watches, likes and bookmarks</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topTags.map(t => {
|
||||
const intensity = t.score / maxTagScore;
|
||||
return (
|
||||
<span
|
||||
key={t.tag}
|
||||
title={`score: ${t.score.toFixed(1)}`}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `rgba(250,204,21,${0.08 + intensity * 0.18})`,
|
||||
color: `hsl(50,95%,${55 + intensity * 20}%)`,
|
||||
fontSize: `${11 + intensity * 4}px`,
|
||||
}}
|
||||
>
|
||||
{t.tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user