mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-12 04:19:25 -07:00
feat: overhaul stats dashboard with navigation, trends, and anime views
Add navigation state machine for tab/detail routing, anime overview stats with Yomitan lookup rates, session word count accuracy fixes, vocabulary tab hook order fix, simplified trends data fetching from backend-aggregated endpoints, and improved session detail charts.
This commit is contained in:
@@ -2,113 +2,12 @@ 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 { StackedTrendChart } from './StackedTrendChart';
|
||||
import {
|
||||
buildAnimeVisibilityOptions,
|
||||
filterHiddenAnimeData,
|
||||
pruneHiddenAnime,
|
||||
} from './anime-visibility';
|
||||
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>>();
|
||||
const allDays = new Set<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);
|
||||
allDays.add(p.epochDay);
|
||||
}
|
||||
|
||||
const sortedDays = [...allDays].sort((a, b) => a - b);
|
||||
if (sortedDays.length < 2) return points;
|
||||
|
||||
const minDay = sortedDays[0]!;
|
||||
const maxDay = sortedDays[sortedDays.length - 1]!;
|
||||
const everyDay: number[] = [];
|
||||
for (let d = minDay; d <= maxDay; d++) {
|
||||
everyDay.push(d);
|
||||
}
|
||||
|
||||
const result: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of byAnime) {
|
||||
let cumulative = 0;
|
||||
const firstDay = Math.min(...dayMap.keys());
|
||||
for (const day of everyDay) {
|
||||
if (day < firstDay) continue;
|
||||
cumulative += dayMap.get(day) ?? 0;
|
||||
result.push({ epochDay: day, 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 (
|
||||
@@ -201,41 +100,34 @@ export function TrendsTab() {
|
||||
|
||||
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>;
|
||||
if (!data) return null;
|
||||
|
||||
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);
|
||||
const cardsProgress = buildCumulativePerAnime(cardsPerAnime);
|
||||
const wordsProgress = buildCumulativePerAnime(wordsPerAnime);
|
||||
const animeTitles = buildAnimeVisibilityOptions([
|
||||
episodesPerAnime,
|
||||
watchTimePerAnime,
|
||||
cardsPerAnime,
|
||||
wordsPerAnime,
|
||||
animeProgress,
|
||||
cardsProgress,
|
||||
wordsProgress,
|
||||
data.animePerDay.episodes,
|
||||
data.animePerDay.watchTime,
|
||||
data.animePerDay.cards,
|
||||
data.animePerDay.words,
|
||||
data.animePerDay.lookups,
|
||||
data.animeCumulative.episodes,
|
||||
data.animeCumulative.cards,
|
||||
data.animeCumulative.words,
|
||||
data.animeCumulative.watchTime,
|
||||
]);
|
||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||
|
||||
const filteredEpisodesPerAnime = filterHiddenAnimeData(episodesPerAnime, activeHiddenAnime);
|
||||
const filteredWatchTimePerAnime = filterHiddenAnimeData(watchTimePerAnime, activeHiddenAnime);
|
||||
const filteredCardsPerAnime = filterHiddenAnimeData(cardsPerAnime, activeHiddenAnime);
|
||||
const filteredWordsPerAnime = filterHiddenAnimeData(wordsPerAnime, activeHiddenAnime);
|
||||
const filteredAnimeProgress = filterHiddenAnimeData(animeProgress, activeHiddenAnime);
|
||||
const filteredCardsProgress = filterHiddenAnimeData(cardsProgress, activeHiddenAnime);
|
||||
const filteredWordsProgress = filterHiddenAnimeData(wordsProgress, activeHiddenAnime);
|
||||
const filteredEpisodesPerAnime = filterHiddenAnimeData(data.animePerDay.episodes, activeHiddenAnime);
|
||||
const filteredWatchTimePerAnime = filterHiddenAnimeData(data.animePerDay.watchTime, activeHiddenAnime);
|
||||
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
||||
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
||||
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
|
||||
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.lookupsPerHundred,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredAnimeProgress = filterHiddenAnimeData(data.animeCumulative.episodes, activeHiddenAnime);
|
||||
const filteredCardsProgress = filterHiddenAnimeData(data.animeCumulative.cards, activeHiddenAnime);
|
||||
const filteredWordsProgress = filterHiddenAnimeData(data.animeCumulative.words, activeHiddenAnime);
|
||||
const filteredWatchTimeProgress = filterHiddenAnimeData(data.animeCumulative.watchTime, activeHiddenAnime);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -245,23 +137,27 @@ export function TrendsTab() {
|
||||
onRangeChange={setRange}
|
||||
onGroupByChange={setGroupBy}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SectionHeader>Activity</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time (min)"
|
||||
data={dashboard.watchTime}
|
||||
data={data.activity.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"
|
||||
/>
|
||||
<TrendChart title="Cards Mined" data={data.activity.cards} color="#a6da95" type="bar" />
|
||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||
|
||||
<SectionHeader>Period Trends</SectionHeader>
|
||||
<TrendChart title="Watch Time (min)" data={data.progress.watchTime} color="#8aadf4" type="line" />
|
||||
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
|
||||
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
|
||||
<TrendChart title="New Words Seen" data={data.progress.newWords} color="#c6a0f6" type="line" />
|
||||
<TrendChart title="Cards Mined" data={data.progress.cards} color="#a6da95" type="line" />
|
||||
<TrendChart title="Episodes Watched" data={data.progress.episodes} color="#91d7e3" type="line" />
|
||||
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
|
||||
<TrendChart title="Lookups / 100 Words" data={data.ratios.lookupsPerHundred} color="#f5a97f" type="line" />
|
||||
|
||||
<SectionHeader>Anime — Per Day</SectionHeader>
|
||||
<AnimeVisibilityFilter
|
||||
@@ -285,8 +181,11 @@ export function TrendsTab() {
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
||||
<StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} />
|
||||
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
||||
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
||||
<StackedTrendChart title="Lookups/100w per Anime" data={filteredLookupsPerHundredPerAnime} />
|
||||
|
||||
<SectionHeader>Anime — Cumulative</SectionHeader>
|
||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||
<StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
|
||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
||||
@@ -294,13 +193,13 @@ export function TrendsTab() {
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={watchByDow}
|
||||
data={data.patterns.watchTimeByDayOfWeek}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={watchByHour}
|
||||
data={data.patterns.watchTimeByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user