Files
SubMiner/stats/src/lib/ipc-client.ts
sudacode 5506a75ef8 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
2026-03-14 23:11:27 -07:00

97 lines
4.6 KiB
TypeScript

import type {
OverviewData, DailyRollup, MonthlyRollup,
SessionSummary, SessionTimelinePoint, SessionEvent,
VocabularyEntry, KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem, MediaDetailData,
AnimeLibraryItem, AnimeDetailData, AnimeWord,
StreakCalendarDay, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime,
WordDetailData, KanjiDetailData,
EpisodeDetailData,
} from '../types/stats';
interface StatsElectronAPI {
stats: {
getOverview: () => Promise<OverviewData>;
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
getSessions: (limit?: number) => Promise<SessionSummary[]>;
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
getSessionEvents: (id: number, limit?: number) => Promise<SessionEvent[]>;
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
getWordOccurrences: (
headword: string,
word: string,
reading: string,
limit?: number,
offset?: number,
) => Promise<VocabularyOccurrenceEntry[]>;
getKanji: (limit?: number) => Promise<KanjiEntry[]>;
getKanjiOccurrences: (
kanji: string,
limit?: number,
offset?: number,
) => Promise<VocabularyOccurrenceEntry[]>;
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
getAnimeCoverUrl: (animeId: number) => string;
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
getWordDetail: (wordId: number) => Promise<WordDetailData>;
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
hideOverlay: () => void;
};
}
declare global {
interface Window {
electronAPI?: StatsElectronAPI;
}
}
function getIpc(): StatsElectronAPI['stats'] {
const api = window.electronAPI?.stats;
if (!api) throw new Error('Electron IPC not available');
return api;
}
export const ipcClient = {
getOverview: () => getIpc().getOverview(),
getDailyRollups: (limit = 60) => getIpc().getDailyRollups(limit),
getMonthlyRollups: (limit = 24) => getIpc().getMonthlyRollups(limit),
getSessions: (limit = 50) => getIpc().getSessions(limit),
getSessionTimeline: (id: number, limit = 200) => getIpc().getSessionTimeline(id, limit),
getSessionEvents: (id: number, limit = 500) => getIpc().getSessionEvents(id, limit),
getVocabulary: (limit = 100) => getIpc().getVocabulary(limit),
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
getIpc().getWordOccurrences(headword, word, reading, limit, offset),
getKanji: (limit = 100) => getIpc().getKanji(limit),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
getIpc().getKanjiOccurrences(kanji, limit, offset),
getMediaLibrary: () => getIpc().getMediaLibrary(),
getMediaDetail: (videoId: number) => getIpc().getMediaDetail(videoId),
getAnimeLibrary: () => getIpc().getAnimeLibrary(),
getAnimeDetail: (animeId: number) => getIpc().getAnimeDetail(animeId),
getAnimeWords: (animeId: number, limit = 50) => getIpc().getAnimeWords(animeId, limit),
getAnimeRollups: (animeId: number, limit = 90) => getIpc().getAnimeRollups(animeId, limit),
getAnimeCoverUrl: (animeId: number) => getIpc().getAnimeCoverUrl(animeId),
getStreakCalendar: (days = 90) => getIpc().getStreakCalendar(days),
getEpisodesPerDay: (limit = 90) => getIpc().getEpisodesPerDay(limit),
getNewAnimePerDay: (limit = 90) => getIpc().getNewAnimePerDay(limit),
getWatchTimePerAnime: (limit = 90) => getIpc().getWatchTimePerAnime(limit),
getWordDetail: (wordId: number) => getIpc().getWordDetail(wordId),
getKanjiDetail: (kanjiId: number) => getIpc().getKanjiDetail(kanjiId),
getEpisodeDetail: (videoId: number) => getIpc().getEpisodeDetail(videoId),
ankiBrowse: (noteId: number) => getIpc().ankiBrowse(noteId),
ankiNotesInfo: (noteIds: number[]) => getIpc().ankiNotesInfo(noteIds),
};