mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-22 02:56:24 -07:00
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:
121
stats/src/components/sessions/SessionDetail.tsx
Normal file
121
stats/src/components/sessions/SessionDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
stats/src/components/sessions/SessionRow.tsx
Normal file
76
stats/src/components/sessions/SessionRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
stats/src/components/sessions/SessionsTab.tsx
Normal file
99
stats/src/components/sessions/SessionsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user