feat(stats): speed up session maintenance and improve stats UI (#111)

This commit is contained in:
2026-06-08 02:20:52 -07:00
committed by GitHub
parent e6a16a069b
commit 311f1e8ee5
108 changed files with 7441 additions and 729 deletions
+40 -11
View File
@@ -6,6 +6,7 @@ import type {
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
SentenceSearchResult,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
@@ -23,7 +24,10 @@ import type {
EpisodeDetailData,
StatsAnkiNoteInfo,
StatsExcludedWord,
StatsCoverImagesData,
} from '../types/stats';
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
@@ -65,6 +69,16 @@ async function fetchJson<T>(path: string): Promise<T> {
return res.json() as Promise<T>;
}
function uniquePositiveIds(ids: number[]): number[] {
const uniqueIds = new Set<number>();
for (const id of ids) {
if (Number.isFinite(id) && id > 0) {
uniqueIds.add(Math.floor(id));
}
}
return Array.from(uniqueIds).sort((a, b) => a - b);
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getDailyRollups: (limit = 60) =>
@@ -103,6 +117,14 @@ export const apiClient = {
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
),
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
fetchJson<SentenceSearchResult[]>(
`/api/stats/sentences/search?${new URLSearchParams({
q: query,
limit: String(limit),
headword: String(searchByHeadword),
}).toString()}`,
),
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
@@ -116,7 +138,22 @@ export const apiClient = {
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
getCoverImages: async (params: {
animeIds: number[];
videoIds: number[];
}): Promise<StatsCoverImagesData> => {
const res = await fetchResponse('/api/stats/covers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
animeIds: uniquePositiveIds(params.animeIds),
videoIds: uniquePositiveIds(params.videoIds),
}),
});
return res.json() as Promise<StatsCoverImagesData>;
},
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
@@ -175,6 +212,7 @@ export const apiClient = {
episodes: number | null;
season: string | null;
seasonYear: number | null;
description: string | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
@@ -197,16 +235,7 @@ export const apiClient = {
body: JSON.stringify(info),
});
},
mineCard: async (params: {
sourcePath: string;
startMs: number;
endMs: number;
sentence: string;
word: string;
secondaryText?: string | null;
videoTitle: string;
mode: 'word' | 'sentence' | 'audio';
}): Promise<{ noteId?: number; error?: string; errors?: string[] }> => {
mineCard: async (params: StatsMineCardParams): Promise<StatsMineCardResponse> => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },