Files
youclonedl/frontend/src/pages/Stats.jsx
Mattias Thall ea99b74ba8 Add scheduled sync, disk space awareness, and subtitle downloads
- auto-sync daemon: background thread checks every hour and syncs followed
  channels for users with sync_interval_hours set (6/12/24h options)
- disk stats: /api/stats now returns total/used/free/download bytes;
  Stats page shows a disk usage bar
- subtitles: subtitle_langs setting (e.g. "en,sv") passed through all
  download paths; yt-dlp writes .srt files alongside the video
- Settings page: sync interval dropdown + subtitle languages input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:36:50 +02:00

243 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getStats, deleteTasteTag } 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 fmtBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0, v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
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 qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["stats"],
queryFn: () => getStats().then(r => r.data),
staleTime: 5 * 60_000,
});
const deleteTag = useMutation({
mutationFn: (tag) => deleteTasteTag(tag),
onSuccess: () => qc.invalidateQueries({ queryKey: ["stats"] }),
});
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>
{/* Disk usage */}
{data.disk?.total_bytes && (
<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">Disk usage</h2>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-400">Downloads</span>
<span className="text-zinc-300 font-mono">{fmtBytes(data.disk.download_bytes)}</span>
</div>
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-accent/70 rounded-full transition-all"
style={{ width: `${Math.min((data.disk.used_bytes / data.disk.total_bytes) * 100, 100)}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-zinc-600">
<span>{fmtBytes(data.disk.used_bytes)} used</span>
<span>{fmtBytes(data.disk.free_bytes)} free · {fmtBytes(data.disk.total_bytes)} total</span>
</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="flex items-center gap-1 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}
<button
onClick={() => deleteTag.mutate(t.tag)}
disabled={deleteTag.isPending}
className="opacity-40 hover:opacity-100 transition-opacity leading-none ml-0.5"
title="Remove from taste profile"
>
×
</button>
</span>
);
})}
</div>
</div>
)}
</div>
);
}