- 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>
243 lines
10 KiB
JavaScript
243 lines
10 KiB
JavaScript
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>
|
||
);
|
||
}
|