mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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
101 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|