Files
SubMiner/stats/src/components/trends/StackedTrendChart.tsx
sudacode 5506a75ef8 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
2026-03-14 23:11:27 -07:00

101 lines
3.3 KiB
TypeScript

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>
);
}