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:
2026-03-17 19:54:15 -07:00
parent 08a5401a7d
commit f8e2ae4887
39 changed files with 2578 additions and 871 deletions

View File

@@ -32,5 +32,5 @@ export function useOverview() {
};
}, []);
return { data, sessions, loading, error };
return { data, sessions, setSessions, loading, error };
}

View File

@@ -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 };
}

View File

@@ -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));