mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -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:
@@ -32,5 +32,5 @@ export function useOverview() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, sessions, loading, error };
|
||||
return { data, sessions, setSessions, loading, error };
|
||||
}
|
||||
|
||||
@@ -34,9 +34,15 @@ export function useSessions(limit = 50) {
|
||||
return { sessions, loading, error };
|
||||
}
|
||||
|
||||
export interface KnownWordsTimelinePoint {
|
||||
linesSeen: number;
|
||||
knownWordsSeen: number;
|
||||
}
|
||||
|
||||
export function useSessionDetail(sessionId: number | null) {
|
||||
const [timeline, setTimeline] = useState<SessionTimelinePoint[]>([]);
|
||||
const [events, setEvents] = useState<SessionEvent[]>([]);
|
||||
const [knownWordsTimeline, setKnownWordsTimeline] = useState<KnownWordsTimelinePoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -46,6 +52,7 @@ export function useSessionDetail(sessionId: number | null) {
|
||||
if (sessionId == null) {
|
||||
setTimeline([]);
|
||||
setEvents([]);
|
||||
setKnownWordsTimeline([]);
|
||||
setLoading(false);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -54,12 +61,18 @@ export function useSessionDetail(sessionId: number | null) {
|
||||
setLoading(true);
|
||||
setTimeline([]);
|
||||
setEvents([]);
|
||||
setKnownWordsTimeline([]);
|
||||
const client = getStatsClient();
|
||||
Promise.all([client.getSessionTimeline(sessionId), client.getSessionEvents(sessionId)])
|
||||
.then(([nextTimeline, nextEvents]) => {
|
||||
Promise.all([
|
||||
client.getSessionTimeline(sessionId),
|
||||
client.getSessionEvents(sessionId),
|
||||
client.getSessionKnownWordsTimeline(sessionId),
|
||||
])
|
||||
.then(([nextTimeline, nextEvents, nextKnownWords]) => {
|
||||
if (cancelled) return;
|
||||
setTimeline(nextTimeline);
|
||||
setEvents(nextEvents);
|
||||
setKnownWordsTimeline(nextKnownWords);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
@@ -74,5 +87,5 @@ export function useSessionDetail(sessionId: number | null) {
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return { timeline, events, loading, error };
|
||||
return { timeline, events, knownWordsTimeline, loading, error };
|
||||
}
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type {
|
||||
DailyRollup,
|
||||
MonthlyRollup,
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
SessionSummary,
|
||||
AnimeLibraryItem,
|
||||
} from '../types/stats';
|
||||
import type { TrendsDashboardData } 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 [data, setData] = useState<TrendsDashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -38,51 +14,12 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
||||
let cancelled = false;
|
||||
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 sessionsLimitMap: Record<TimeRange, number> = {
|
||||
'7d': 200,
|
||||
'30d': 500,
|
||||
'90d': 500,
|
||||
all: 500,
|
||||
};
|
||||
|
||||
const rollupFetcher =
|
||||
groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit);
|
||||
|
||||
Promise.all([
|
||||
rollupFetcher,
|
||||
client.getEpisodesPerDay(limit),
|
||||
client.getNewAnimePerDay(limit),
|
||||
client.getWatchTimePerAnime(limit),
|
||||
client.getSessions(sessionsLimitMap[range]),
|
||||
client.getAnimeLibrary(),
|
||||
])
|
||||
.then(
|
||||
([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
|
||||
if (cancelled) return;
|
||||
const now = new Date();
|
||||
const localMidnight = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const cutoffMs =
|
||||
range === 'all' ? null : localMidnight - (limitMap[range] - 1) * 86_400_000;
|
||||
const filteredSessions =
|
||||
cutoffMs == null ? sessions : sessions.filter((s) => s.startedAtMs >= cutoffMs);
|
||||
setData({
|
||||
rollups,
|
||||
episodesPerDay,
|
||||
newAnimePerDay,
|
||||
watchTimePerAnime,
|
||||
sessions: filteredSessions,
|
||||
animeLibrary,
|
||||
});
|
||||
},
|
||||
)
|
||||
getStatsClient()
|
||||
.getTrendsDashboard(range, groupBy)
|
||||
.then((nextData) => {
|
||||
if (cancelled) return;
|
||||
setData(nextData);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
|
||||
Reference in New Issue
Block a user