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:
2026-03-14 22:15:02 -07:00
parent 950263bd66
commit 0f44107beb
68 changed files with 5372 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeDetailData } from '../types/stats';
export function useAnimeDetail(animeId: number | null) {
const [data, setData] = useState<AnimeDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (animeId === null) return;
setLoading(true);
setError(null);
getStatsClient()
.getAnimeDetail(animeId)
.then(setData)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [animeId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,21 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getAnimeLibrary()
.then((data) => { if (!cancelled) setAnime(data); })
.catch((err: Error) => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return { anime, loading, error };
}

View File

@@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { KanjiDetailData } from '../types/stats';
export function useKanjiDetail(kanjiId: number | null) {
const [data, setData] = useState<KanjiDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (kanjiId === null) return;
setLoading(true);
setError(null);
getStatsClient()
.getKanjiDetail(kanjiId)
.then(setData)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [kanjiId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { MediaDetailData } from '../types/stats';
export function useMediaDetail(videoId: number | null) {
const [data, setData] = useState<MediaDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (videoId === null) return;
setLoading(true);
setError(null);
getStatsClient()
.getMediaDetail(videoId)
.then(setData)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [videoId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,19 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { MediaLibraryItem } from '../types/stats';
export function useMediaLibrary() {
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getStatsClient()
.getMediaLibrary()
.then(setMedia)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
return { media, loading, error };
}

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { OverviewData, SessionSummary } from '../types/stats';
export function useOverview() {
const [data, setData] = useState<OverviewData | null>(null);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const client = getStatsClient();
Promise.all([client.getOverview(), client.getSessions(50)])
.then(([overview, allSessions]) => {
setData(overview);
setSessions(allSessions);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
return { data, sessions, loading, error };
}

View File

@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
export function useSessions(limit = 50) {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
client
.getSessions(limit)
.then((nextSessions) => {
if (cancelled) return;
setSessions(nextSessions);
})
.catch((err) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [limit]);
return { sessions, loading, error };
}
export function useSessionDetail(sessionId: number | null) {
const [timeline, setTimeline] = useState<SessionTimelinePoint[]>([]);
const [events, setEvents] = useState<SessionEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
if (sessionId == null) {
setTimeline([]);
setEvents([]);
setLoading(false);
return () => {
cancelled = true;
};
}
setLoading(true);
setTimeline([]);
setEvents([]);
const client = getStatsClient();
Promise.all([client.getSessionTimeline(sessionId), client.getSessionEvents(sessionId)])
.then(([nextTimeline, nextEvents]) => {
if (cancelled) return;
setTimeline(nextTimeline);
setEvents(nextEvents);
})
.catch((err) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [sessionId]);
return { timeline, events, loading, error };
}

View File

@@ -0,0 +1,7 @@
import { apiClient } from '../lib/api-client';
export type StatsClient = typeof apiClient;
export function getStatsClient(): StatsClient {
return apiClient;
}

View File

@@ -0,0 +1,21 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { StreakCalendarDay } from '../types/stats';
export function useStreakCalendar(days = 90) {
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getStreakCalendar(days)
.then((data) => { if (!cancelled) setCalendar(data); })
.catch((err: Error) => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [days]);
return { calendar, loading, error };
}

View File

@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { DailyRollup, MonthlyRollup, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime, SessionSummary, AnimeLibraryItem } from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | 'all';
export type GroupBy = 'day' | 'month';
export interface TrendsData {
rollups: DailyRollup[] | MonthlyRollup[];
episodesPerDay: EpisodesPerDay[];
newAnimePerDay: NewAnimePerDay[];
watchTimePerAnime: WatchTimePerAnime[];
sessions: SessionSummary[];
animeLibrary: AnimeLibraryItem[];
}
export function useTrends(range: TimeRange, groupBy: GroupBy) {
const [data, setData] = useState<TrendsData>({
rollups: [],
episodesPerDay: [],
newAnimePerDay: [],
watchTimePerAnime: [],
sessions: [],
animeLibrary: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
const client = getStatsClient();
const limitMap: Record<TimeRange, number> = { '7d': 7, '30d': 30, '90d': 90, all: 365 };
const limit = limitMap[range];
const monthlyLimit = Math.max(1, Math.ceil(limit / 30));
const rollupFetcher =
groupBy === 'month'
? client.getMonthlyRollups(monthlyLimit)
: client.getDailyRollups(limit);
Promise.all([
rollupFetcher,
client.getEpisodesPerDay(limit),
client.getNewAnimePerDay(limit),
client.getWatchTimePerAnime(limit),
client.getSessions(500),
client.getAnimeLibrary(),
])
.then(([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
setData({ rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary });
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [range, groupBy]);
return { data, loading, error };
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200)])
.then(([wordsResult, kanjiResult]) => {
const errors: string[] = [];
if (wordsResult.status === 'fulfilled') {
setWords(wordsResult.value);
} else {
errors.push(wordsResult.reason.message);
}
if (kanjiResult.status === 'fulfilled') {
setKanji(kanjiResult.value);
} else {
errors.push(kanjiResult.reason.message);
}
if (errors.length > 0) {
setError(errors.join('; '));
}
})
.finally(() => setLoading(false));
}, []);
return { words, kanji, loading, error };
}

View File

@@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { WordDetailData } from '../types/stats';
export function useWordDetail(wordId: number | null) {
const [data, setData] = useState<WordDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (wordId === null) return;
setLoading(true);
setError(null);
getStatsClient()
.getWordDetail(wordId)
.then(setData)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [wordId]);
return { data, loading, error };
}