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,24 @@
interface StatCardProps {
label: string;
value: string;
subValue?: string;
color?: string;
trend?: { direction: 'up' | 'down' | 'flat'; text: string };
}
export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div>
{subValue && (
<div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>
)}
{trend && (
<div className={`text-xs mt-1 ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}>
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'} {trend.text}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
interface Tab {
id: TabId;
label: string;
}
const TABS: Tab[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'anime', label: 'Anime' },
{ id: 'trends', label: 'Trends' },
{ id: 'vocabulary', label: 'Vocabulary' },
{ id: 'sessions', label: 'Sessions' },
];
interface TabBarProps {
activeTab: TabId;
onTabChange: (tabId: TabId) => void;
}
export function TabBar({ activeTab, onTabChange }: TabBarProps) {
return (
<nav className="flex border-b border-ctp-surface1" role="tablist" aria-label="Stats tabs">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-controls={`panel-${tab.id}`}
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onTabChange(tab.id)}
className={`px-4 py-2.5 text-sm font-medium transition-colors
${
activeTab === tab.id
? 'text-ctp-text border-b-2 border-ctp-lavender'
: 'text-ctp-subtext0 hover:text-ctp-subtext1'
}`}
>
{tab.label}
</button>
))}
</nav>
);
}