feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -0,0 +1,147 @@
import { useState, useMemo, useEffect } from 'react';
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
import { formatDuration } from '../../lib/formatters';
import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
type CardSize = 'sm' | 'md' | 'lg';
const GRID_CLASSES: Record<CardSize, string> = {
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
};
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: 'lastWatched', label: 'Last Watched' },
{ key: 'watchTime', label: 'Watch Time' },
{ key: 'cards', label: 'Cards' },
{ key: 'episodes', label: 'Episodes' },
];
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
return [...list].sort((a, b) => {
switch (key) {
case 'lastWatched':
return b.lastWatchedMs - a.lastWatchedMs;
case 'watchTime':
return b.totalActiveMs - a.totalActiveMs;
case 'cards':
return b.totalCards - a.totalCards;
case 'episodes':
return b.episodeCount - a.episodeCount;
}
});
}
interface AnimeTabProps {
initialAnimeId?: number | null;
onClearInitialAnime?: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (animeId: number, videoId: number) => void;
}
export function AnimeTab({
initialAnimeId,
onClearInitialAnime,
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<CardSize>('md');
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
useEffect(() => {
if (initialAnimeId != null) {
setSelectedAnimeId(initialAnimeId);
onClearInitialAnime?.();
}
}, [initialAnimeId, onClearInitialAnime]);
const filtered = useMemo(() => {
const base = search.trim()
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
: anime;
return sortAnime(base, sortKey);
}, [anime, search, sortKey]);
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
if (selectedAnimeId !== null) {
return (
<AnimeDetailView
animeId={selectedAnimeId}
onBack={() => setSelectedAnimeId(null)}
onNavigateToWord={onNavigateToWord}
onOpenEpisodeDetail={
onOpenEpisodeDetail
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
/>
);
}
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search anime..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.label}
</option>
))}
</select>
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
{(['sm', 'md', 'lg'] as const).map((size) => (
<button
key={size}
onClick={() => setCardSize(size)}
className={`px-2 py-1 rounded-md text-xs transition-colors ${
cardSize === size
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{size === 'sm' ? '▪' : size === 'md' ? '◼' : '⬛'}
</button>
))}
</div>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} anime · {formatDuration(totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
) : (
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
{filtered.map((item) => (
<AnimeCard
key={item.animeId}
anime={item}
onClick={() => setSelectedAnimeId(item.animeId)}
/>
))}
</div>
)}
</div>
);
}