mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -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:
58
stats/src/components/trends/DateRangeSelector.tsx
Normal file
58
stats/src/components/trends/DateRangeSelector.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
range: TimeRange;
|
||||
groupBy: GroupBy;
|
||||
onRangeChange: (r: TimeRange) => void;
|
||||
onGroupByChange: (g: GroupBy) => void;
|
||||
}
|
||||
|
||||
export function DateRangeSelector({
|
||||
range,
|
||||
groupBy,
|
||||
onRangeChange,
|
||||
onGroupByChange,
|
||||
}: DateRangeSelectorProps) {
|
||||
const ranges: TimeRange[] = ['7d', '30d', '90d', 'all'];
|
||||
const groups: GroupBy[] = ['day', 'month'];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Range</span>
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onRangeChange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`px-2.5 py-1 rounded text-xs ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r === 'all' ? 'All' : r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-ctp-surface2">{'\u00B7'}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Group by</span>
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => onGroupByChange(g)}
|
||||
aria-pressed={groupBy === g}
|
||||
className={`px-2.5 py-1 rounded text-xs capitalize ${
|
||||
groupBy === g
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
stats/src/components/trends/StackedTrendChart.tsx
Normal file
100
stats/src/components/trends/StackedTrendChart.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
export interface PerAnimeDataPoint {
|
||||
epochDay: number;
|
||||
animeTitle: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface StackedTrendChartProps {
|
||||
title: string;
|
||||
data: PerAnimeDataPoint[];
|
||||
}
|
||||
|
||||
const LINE_COLORS = [
|
||||
'#8aadf4', '#c6a0f6', '#a6da95', '#f5a97f', '#f5bde6',
|
||||
'#91d7e3', '#ee99a0', '#f4dbd6',
|
||||
];
|
||||
|
||||
function buildLineData(raw: PerAnimeDataPoint[]) {
|
||||
const totalByAnime = new Map<string, number>();
|
||||
for (const entry of raw) {
|
||||
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
|
||||
}
|
||||
|
||||
const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const topTitles = sorted.slice(0, 7).map(([title]) => title);
|
||||
const topSet = new Set(topTitles);
|
||||
|
||||
const byDay = new Map<number, Record<string, number>>();
|
||||
for (const entry of raw) {
|
||||
if (!topSet.has(entry.animeTitle)) continue;
|
||||
const row = byDay.get(entry.epochDay) ?? {};
|
||||
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
||||
byDay.set(entry.epochDay, row);
|
||||
}
|
||||
|
||||
const points = [...byDay.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([epochDay, values]) => ({
|
||||
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
...values,
|
||||
}));
|
||||
|
||||
return { points, seriesKeys: topTitles };
|
||||
}
|
||||
|
||||
export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
||||
const { points, seriesKeys } = buildLineData(data);
|
||||
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
|
||||
};
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<div className="text-xs text-ctp-overlay2">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<LineChart data={points}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={LINE_COLORS[i % LINE_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{seriesKeys.map((key, i) => (
|
||||
<span key={key} className="flex items-center gap-1 text-[10px] text-ctp-subtext0">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }}
|
||||
/>
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
stats/src/components/trends/TrendChart.tsx
Normal file
43
stats/src/components/trends/TrendChart.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
data: Array<{ label: string; value: number }>;
|
||||
color: string;
|
||||
type: 'bar' | 'line';
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
export function TrendChart({ title, data, color, type, formatter }: TrendChartProps) {
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
|
||||
};
|
||||
|
||||
const formatValue = (v: number) => formatter ? [formatter(v), title] : [String(v), title];
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Bar dataKey="value" fill={color} radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
stats/src/components/trends/TrendsTab.tsx
Normal file
160
stats/src/components/trends/TrendsTab.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
|
||||
import { DateRangeSelector } from './DateRangeSelector';
|
||||
import { TrendChart } from './TrendChart';
|
||||
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart';
|
||||
import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data';
|
||||
import { localDayFromMs } from '../../lib/formatters';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function buildWatchTimeByDayOfWeek(sessions: SessionSummary[]): ChartPoint[] {
|
||||
const totals = new Array(7).fill(0);
|
||||
for (const s of sessions) {
|
||||
const dow = new Date(s.startedAtMs).getDay();
|
||||
totals[dow] += s.activeWatchedMs;
|
||||
}
|
||||
return DAY_NAMES.map((name, i) => ({ label: name, value: Math.round(totals[i] / 60_000) }));
|
||||
}
|
||||
|
||||
function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
|
||||
const totals = new Array(24).fill(0);
|
||||
for (const s of sessions) {
|
||||
const hour = new Date(s.startedAtMs).getHours();
|
||||
totals[hour] += s.activeWatchedMs;
|
||||
}
|
||||
return totals.map((ms, i) => ({
|
||||
label: `${String(i).padStart(2, '0')}:00`,
|
||||
value: Math.round(ms / 60_000),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
|
||||
const byAnime = new Map<string, Map<number, number>>();
|
||||
for (const p of points) {
|
||||
const dayMap = byAnime.get(p.animeTitle) ?? new Map();
|
||||
dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value);
|
||||
byAnime.set(p.animeTitle, dayMap);
|
||||
}
|
||||
const result: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of byAnime) {
|
||||
const sorted = [...dayMap.entries()].sort(([a], [b]) => a - b);
|
||||
let cumulative = 0;
|
||||
for (const [epochDay, value] of sorted) {
|
||||
cumulative += value;
|
||||
result.push({ epochDay, animeTitle, value: cumulative });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildPerAnimeFromSessions(
|
||||
sessions: SessionSummary[],
|
||||
getValue: (s: SessionSummary) => number,
|
||||
): PerAnimeDataPoint[] {
|
||||
const map = new Map<string, Map<number, number>>();
|
||||
for (const s of sessions) {
|
||||
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
|
||||
const day = localDayFromMs(s.startedAtMs);
|
||||
const animeMap = map.get(title) ?? new Map();
|
||||
animeMap.set(day, (animeMap.get(day) ?? 0) + getValue(s));
|
||||
map.set(title, animeMap);
|
||||
}
|
||||
const points: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of map) {
|
||||
for (const [epochDay, value] of dayMap) {
|
||||
points.push({ epochDay, animeTitle, value });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnimeDataPoint[] {
|
||||
// Group by anime+day, counting distinct videoIds
|
||||
const map = new Map<string, Map<number, Set<number | null>>>();
|
||||
for (const s of sessions) {
|
||||
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
|
||||
const day = localDayFromMs(s.startedAtMs);
|
||||
const animeMap = map.get(title) ?? new Map();
|
||||
const videoSet = animeMap.get(day) ?? new Set();
|
||||
videoSet.add(s.videoId);
|
||||
animeMap.set(day, videoSet);
|
||||
map.set(title, animeMap);
|
||||
}
|
||||
const points: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of map) {
|
||||
for (const [epochDay, videoSet] of dayMap) {
|
||||
points.push({ epochDay, animeTitle, value: videoSet.size });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-ctp-subtext0 text-sm font-medium uppercase tracking-wider mt-6 mb-2 col-span-full">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendsTab() {
|
||||
const [range, setRange] = useState<TimeRange>('30d');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
||||
const { data, loading, error } = useTrends(range, groupBy);
|
||||
|
||||
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>;
|
||||
|
||||
const dashboard = buildTrendDashboard(data.rollups);
|
||||
const watchByDow = buildWatchTimeByDayOfWeek(data.sessions);
|
||||
const watchByHour = buildWatchTimeByHour(data.sessions);
|
||||
|
||||
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
|
||||
epochDay: e.epochDay, animeTitle: e.animeTitle, value: e.totalActiveMin,
|
||||
}));
|
||||
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
|
||||
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
|
||||
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
|
||||
|
||||
const animeProgress = buildCumulativePerAnime(episodesPerAnime);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
groupBy={groupBy}
|
||||
onRangeChange={setRange}
|
||||
onGroupByChange={setGroupBy}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<SectionHeader>Activity</SectionHeader>
|
||||
<TrendChart title="Watch Time (min)" data={dashboard.watchTime} color="#8aadf4" type="bar" />
|
||||
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
|
||||
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
|
||||
<TrendChart
|
||||
title="Avg Session (min)"
|
||||
data={dashboard.averageSessionMinutes}
|
||||
color="#f5bde6"
|
||||
type="line"
|
||||
/>
|
||||
|
||||
<SectionHeader>Efficiency</SectionHeader>
|
||||
<TrendChart title="Cards per Hour" data={dashboard.cardsPerHour} color="#f5a97f" type="line" />
|
||||
|
||||
<SectionHeader>Anime</SectionHeader>
|
||||
<StackedTrendChart title="Anime Progress (episodes)" data={animeProgress} />
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
|
||||
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} />
|
||||
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} />
|
||||
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} />
|
||||
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" />
|
||||
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user