feat(stats): build anime-centric stats dashboard frontend

5-tab React dashboard with Catppuccin Mocha theme:
- Overview: hero stats, streak calendar, watch time chart, recent sessions
- Anime: grid with cover art, episode list with completion %, detail view
- Trends: 15 charts across Activity, Efficiency, Anime, and Patterns
- Vocabulary: POS-filtered word/kanji lists with detail panels
- Sessions: expandable session history with event timeline

Features:
- Cross-tab navigation (anime <-> vocabulary)
- Global word detail panel overlay
- Expandable episode detail with Anki card links (Expression field)
- Per-anime multi-line trend charts
- Watch time by day-of-week and hour-of-day
- Collapsible sections with accessibility (aria-expanded)
- Card size selector for anime grid
- Cover art caching via AniList
- HTTP API client with file:// protocol fallback for Electron overlay
This commit is contained in:
2026-03-14 22:15:02 -07:00
parent 950263bd66
commit 0f44107beb
68 changed files with 5372 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import { Fragment, useState } from 'react';
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
import { CollapsibleSection } from './CollapsibleSection';
import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats';
interface AnimeCardsListProps {
episodes: AnimeEpisode[];
totalCards: number;
}
export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
if (totalCards === 0) {
return (
<CollapsibleSection title="Cards Mined (0)" defaultOpen={false}>
<p className="text-sm text-ctp-overlay2">No cards mined from this anime yet.</p>
</CollapsibleSection>
);
}
const withCards = episodes.filter((ep) => ep.totalCards > 0);
return (
<CollapsibleSection title={`Cards Mined (${formatNumber(totalCards)})`} defaultOpen={false}>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="w-6 py-2 pr-1 font-medium" />
<th className="text-left py-2 pr-3 font-medium">Episode</th>
<th className="text-right py-2 pr-3 font-medium">Cards</th>
<th className="text-right py-2 font-medium">Last Watched</th>
</tr>
</thead>
<tbody>
{withCards.map((ep) => (
<Fragment key={ep.videoId}>
<tr
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
{expandedVideoId === ep.videoId ? '▼' : '▶'}
</td>
<td className="py-2 pr-3 text-ctp-text truncate max-w-[300px]">
<span className="text-ctp-subtext0 mr-2">
{ep.episode != null ? `#${ep.episode}` : ''}
</span>
{ep.canonicalTitle}
</td>
<td className="py-2 pr-3 text-right text-ctp-green">
{formatNumber(ep.totalCards)}
</td>
<td className="py-2 text-right text-ctp-overlay2">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
</td>
</tr>
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={4} className="py-2">
<EpisodeDetail videoId={ep.videoId} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</CollapsibleSection>
);
}