mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 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:
22
stats/src/hooks/useAnimeDetail.ts
Normal file
22
stats/src/hooks/useAnimeDetail.ts
Normal 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 };
|
||||
}
|
||||
21
stats/src/hooks/useAnimeLibrary.ts
Normal file
21
stats/src/hooks/useAnimeLibrary.ts
Normal 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 };
|
||||
}
|
||||
22
stats/src/hooks/useKanjiDetail.ts
Normal file
22
stats/src/hooks/useKanjiDetail.ts
Normal 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 };
|
||||
}
|
||||
22
stats/src/hooks/useMediaDetail.ts
Normal file
22
stats/src/hooks/useMediaDetail.ts
Normal 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 };
|
||||
}
|
||||
19
stats/src/hooks/useMediaLibrary.ts
Normal file
19
stats/src/hooks/useMediaLibrary.ts
Normal 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 };
|
||||
}
|
||||
23
stats/src/hooks/useOverview.ts
Normal file
23
stats/src/hooks/useOverview.ts
Normal 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 };
|
||||
}
|
||||
78
stats/src/hooks/useSessions.ts
Normal file
78
stats/src/hooks/useSessions.ts
Normal 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 };
|
||||
}
|
||||
7
stats/src/hooks/useStatsApi.ts
Normal file
7
stats/src/hooks/useStatsApi.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { apiClient } from '../lib/api-client';
|
||||
|
||||
export type StatsClient = typeof apiClient;
|
||||
|
||||
export function getStatsClient(): StatsClient {
|
||||
return apiClient;
|
||||
}
|
||||
21
stats/src/hooks/useStreakCalendar.ts
Normal file
21
stats/src/hooks/useStreakCalendar.ts
Normal 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 };
|
||||
}
|
||||
58
stats/src/hooks/useTrends.ts
Normal file
58
stats/src/hooks/useTrends.ts
Normal 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 };
|
||||
}
|
||||
39
stats/src/hooks/useVocabulary.ts
Normal file
39
stats/src/hooks/useVocabulary.ts
Normal 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 };
|
||||
}
|
||||
22
stats/src/hooks/useWordDetail.ts
Normal file
22
stats/src/hooks/useWordDetail.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user