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,121 @@
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme';
import { EventType } from '../../types/stats';
interface SessionDetailProps {
sessionId: number;
cardsMined: number;
}
const tooltipStyle = {
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 11,
};
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
const EVENT_COLORS: Partial<Record<number, { color: string; label: string }>> = {
[EventType.CARD_MINED]: { color: '#a6da95', label: 'Card mined' },
[EventType.PAUSE_START]: { color: '#f5a97f', label: 'Pause' },
};
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
const { timeline, events, loading, error } = useSessionDetail(sessionId);
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
const chartData = [...timeline]
.reverse()
.map((t) => ({
tsMs: t.sampleMs,
time: formatTime(t.sampleMs),
words: t.wordsSeen,
cards: t.cardsMined,
}));
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = events.filter(
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
).length;
const cardEventCount = events.filter((e) => e.eventType === EventType.CARD_MINED).length;
const markerEvents = events.filter((e) => EVENT_COLORS[e.eventType]);
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
{chartData.length > 0 && (
<ResponsiveContainer width="100%" height={120}>
<LineChart data={chartData}>
<XAxis
dataKey="time"
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} />
<Line dataKey="words" stroke="#c6a0f6" strokeWidth={1.5} dot={false} name="Words" />
<Line dataKey="cards" stroke="#a6da95" strokeWidth={1.5} dot={false} name="Cards" />
{markerEvents.map((e, i) => {
const cfg = EVENT_COLORS[e.eventType]!;
const matchIdx = chartData.findIndex((d) => d.tsMs >= e.tsMs);
const x = matchIdx >= 0 ? chartData[matchIdx]!.time : null;
if (!x) return null;
return (
<ReferenceLine
key={`${e.eventType}-${i}`}
x={x}
stroke={cfg.color}
strokeDasharray="3 3"
strokeOpacity={0.6}
label=""
/>
);
})}
</LineChart>
</ResponsiveContainer>
)}
<div className="flex flex-wrap gap-4 text-xs text-ctp-subtext0">
<span>{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span>
<span>{seekCount} seek{seekCount !== 1 ? 's' : ''}</span>
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span>
</div>
{markerEvents.length > 0 && (
<div className="flex flex-wrap gap-3 text-[10px]">
{Object.entries(EVENT_COLORS).map(([type, cfg]) => {
if (!cfg) return null;
const count = markerEvents.filter((e) => e.eventType === Number(type)).length;
if (count === 0) return null;
return (
<span key={type} className="flex items-center gap-1">
<span className="inline-block w-2.5 h-0.5 rounded" style={{ background: cfg.color }} />
<span className="text-ctp-overlay2">{cfg.label} ({count})</span>
</span>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
import {
formatDuration,
formatRelativeDate,
formatNumber,
} from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
interface SessionRowProps {
session: SessionSummary;
isExpanded: boolean;
detailsId: string;
onToggle: () => void;
}
function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
if (!videoId || failed) {
return (
<div className="w-10 h-14 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-sm font-bold shrink-0">
{fallbackChar}
</div>
);
}
return (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
alt=""
loading="lazy"
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setFailed(true)}
/>
);
}
export function SessionRow({ session, isExpanded, detailsId, onToggle }: SessionRowProps) {
return (
<button
type="button"
onClick={onToggle}
aria-expanded={isExpanded}
aria-controls={detailsId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(session.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
>
{'\u25B8'}
</div>
</button>
);
}

View File

@@ -0,0 +1,99 @@
import { useState, useMemo } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { todayLocalDay, localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
const today = todayLocalDay();
for (const session of sessions) {
const sessionDay = localDayFromMs(session.startedAtMs);
let label: string;
if (sessionDay === today) {
label = 'Today';
} else if (sessionDay === today - 1) {
label = 'Yesterday';
} else {
label = new Date(session.startedAtMs).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
});
}
const group = groups.get(label);
if (group) {
group.push(session);
} else {
groups.set(label, [session]);
}
}
return groups;
}
export function SessionsTab() {
const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter(
(s) => s.canonicalTitle?.toLowerCase().includes(q),
);
}, [sessions, search]);
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
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">
<input
type="text"
placeholder="Search by title..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full 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"
/>
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
<div key={dayLabel}>
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2">
{dayLabel}
</h3>
<div className="space-y-2">
{daySessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail sessionId={s.sessionId} cardsMined={s.cardsMined} />
</div>
)}
</div>
);
})}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-ctp-overlay2 text-sm">
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
</div>
);
}