refactor(stats): drop dead IPC handlers, unify stats types over HTTP (#164)

This commit is contained in:
2026-07-13 12:09:56 -07:00
committed by GitHub
parent 0ab840b362
commit 6fe1e0fee4
21 changed files with 917 additions and 1183 deletions
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeVisibilityFilter } from './TrendsTab';
@@ -81,7 +82,7 @@ test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing
});
test('TrendsTab source labels words per minute without reading speed wording', async () => {
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
const source = await readFile(new URL('./TrendsTab.tsx', import.meta.url), 'utf8');
assert.match(source, /title="Words \/ Min"/);
assert.doesNotMatch(source, /Reading Speed/);
+62 -105
View File
@@ -1,30 +1,17 @@
import type {
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
SentenceSearchResult,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
TrendsDashboardData,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
StatsAnkiNoteInfo,
StatsExcludedWord,
StatsCoverImagesData,
StatsAnilistAssignment,
StatsAnkiNotesInfoRequest,
StatsCoverImagesRequest,
StatsDeleteSessionsRequest,
StatsExcludedWordsRequest,
StatsHttpClient,
StatsJsonResponseMap,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
} from '../types/stats';
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
@@ -64,9 +51,12 @@ async function fetchResponse(path: string, init?: RequestInit): Promise<Response
return res;
}
async function fetchJson<T>(path: string): Promise<T> {
async function fetchJson<Key extends keyof StatsJsonResponseMap>(
_key: Key,
path: string,
): Promise<StatsJsonResponseMap[Key]> {
const res = await fetchResponse(path);
return res.json() as Promise<T>;
return res.json() as Promise<StatsJsonResponseMap[Key]>;
}
function uniquePositiveIds(ids: number[]): number[] {
@@ -80,14 +70,15 @@ function uniquePositiveIds(ids: number[]): number[] {
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getOverview: () => fetchJson('overview', '/api/stats/overview'),
getDailyRollups: (limit = 60) =>
fetchJson<DailyRollup[]>(`/api/stats/daily-rollups?limit=${limit}`),
fetchJson('dailyRollups', `/api/stats/daily-rollups?limit=${limit}`),
getMonthlyRollups: (limit = 24) =>
fetchJson<MonthlyRollup[]>(`/api/stats/monthly-rollups?limit=${limit}`),
getSessions: (limit = 50) => fetchJson<SessionSummary[]>(`/api/stats/sessions?limit=${limit}`),
fetchJson('monthlyRollups', `/api/stats/monthly-rollups?limit=${limit}`),
getSessions: (limit = 50) => fetchJson('sessions', `/api/stats/sessions?limit=${limit}`),
getSessionTimeline: (id: number, limit?: number) =>
fetchJson<SessionTimelinePoint[]>(
fetchJson(
'sessionTimeline',
limit === undefined
? `/api/stats/sessions/${id}/timeline`
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
@@ -97,90 +88,84 @@ export const apiClient = {
if (eventTypes && eventTypes.length > 0) {
params.set('types', eventTypes.join(','));
}
return fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?${params.toString()}`);
return fetchJson('sessionEvents', `/api/stats/sessions/${id}/events?${params.toString()}`);
},
getSessionKnownWordsTimeline: (id: number) =>
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number; totalWordsSeen: number }>>(
`/api/stats/sessions/${id}/known-words-timeline`,
),
getVocabulary: (limit = 100) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
getExcludedWords: () => fetchJson<StatsExcludedWord[]>('/api/stats/excluded-words'),
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
await fetchResponse('/api/stats/excluded-words', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ words }),
body: JSON.stringify({ words } satisfies StatsExcludedWordsRequest),
});
},
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
fetchJson(
'wordOccurrences',
`/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[]>(
fetchJson(
'sentenceSearch',
`/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}`),
getKanji: (limit = 100) => fetchJson('kanji', `/api/stats/kanji?limit=${limit}`),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
fetchJson(
'kanjiOccurrences',
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
),
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'),
getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'),
getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) =>
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
fetchJson('animeRollups', `/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
getCoverImages: async (params: {
animeIds: number[];
videoIds: number[];
}): Promise<StatsCoverImagesData> => {
getCoverImages: async (params: StatsCoverImagesRequest): 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),
}),
} satisfies StatsCoverImagesRequest),
});
return res.json() as Promise<StatsCoverImagesData>;
return res.json() as Promise<StatsJsonResponseMap['coverImages']>;
},
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
fetchJson('streakCalendar', `/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
fetchJson('episodesPerDay', `/api/stats/trends/episodes-per-day?limit=${limit}`),
getNewAnimePerDay: (limit = 90) =>
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
fetchJson('newAnimePerDay', `/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: (
range: '7d' | '30d' | '90d' | '365d' | 'all',
groupBy: 'day' | 'month',
fillEmpty = true,
) =>
fetchJson<TrendsDashboardData>(
fetchJson('watchTimePerAnime', `/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: (range: StatsTrendRange, groupBy: StatsTrendGroupBy, fillEmpty = true) =>
fetchJson(
'trendsDashboard',
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
),
getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
fetchJson('wordDetail', `/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) =>
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),
fetchJson('kanjiDetail', `/api/stats/kanji/${kanjiId}/detail`),
getEpisodeDetail: (videoId: number) =>
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
fetchJson('episodeDetail', `/api/stats/episode/${videoId}/detail`),
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched }),
body: JSON.stringify({ watched } satisfies StatsVideoWatchedRequest),
});
},
deleteSession: async (sessionId: number): Promise<void> => {
@@ -190,49 +175,21 @@ export const apiClient = {
await fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds }),
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
});
},
deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
},
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
getKnownWordsSummary: () =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
'/api/stats/known-words-summary',
),
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
getAnimeKnownWordsSummary: (animeId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
`/api/stats/anime/${animeId}/known-words-summary`,
),
fetchJson('animeKnownWordsSummary', `/api/stats/anime/${animeId}/known-words-summary`),
getMediaKnownWordsSummary: (videoId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
`/api/stats/media/${videoId}/known-words-summary`,
),
fetchJson('mediaKnownWordsSummary', `/api/stats/media/${videoId}/known-words-summary`),
searchAnilist: (query: string) =>
fetchJson<
Array<{
id: number;
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;
}>
>(`/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
reassignAnimeAnilist: async (
animeId: number,
info: {
anilistId: number;
titleRomaji?: string | null;
titleEnglish?: string | null;
titleNative?: string | null;
episodesTotal?: number | null;
description?: string | null;
coverUrl?: string | null;
},
): Promise<void> => {
fetchJson('anilistSearch', `/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
reassignAnimeAnilist: async (animeId: number, info: StatsAnilistAssignment): Promise<void> => {
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -254,9 +211,9 @@ export const apiClient = {
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds }),
body: JSON.stringify({ noteIds } satisfies StatsAnkiNotesInfoRequest),
});
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
return res.json();
},
};
} satisfies StatsHttpClient;
-112
View File
@@ -1,112 +0,0 @@
import type {
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
StatsAnkiNoteInfo,
} 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<StatsAnkiNoteInfo[]>;
hideOverlay: () => void;
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => 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?: number) => 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),
};
+7 -19
View File
@@ -1,23 +1,11 @@
import type { SentenceSearchResult } from '../types/stats';
import type {
SentenceSearchResult,
StatsMineCardParams,
StatsMineCardResponse,
StatsMineMode,
} from '../types/stats';
export type StatsMineMode = 'word' | 'sentence' | 'audio';
export interface StatsMineCardParams {
sourcePath: string;
startMs: number;
endMs: number;
sentence: string;
word: string;
secondaryText?: string | null;
videoTitle: string;
mode: StatsMineMode;
}
export interface StatsMineCardResponse {
noteId?: number;
error?: string;
errors?: string[];
}
export type { StatsMineCardParams, StatsMineCardResponse, StatsMineMode } from '../types/stats';
export function getStatsMineCardUnavailableReason(
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
+2 -430
View File
@@ -1,430 +1,2 @@
export interface SessionSummary {
sessionId: number;
canonicalTitle: string | null;
videoId: number | null;
animeId: number | null;
animeTitle: string | null;
startedAtMs: number;
endedAtMs: number | null;
totalWatchedMs: number;
activeWatchedMs: number;
linesSeen: number;
tokensSeen: number;
cardsMined: number;
lookupCount: number;
lookupHits: number;
yomitanLookupCount: number;
knownWordsSeen: number;
knownWordRate: number;
}
export interface DailyRollup {
rollupDayOrMonth: number;
videoId: number | null;
totalSessions: number;
totalActiveMin: number;
totalLinesSeen: number;
totalTokensSeen: number;
totalCards: number;
cardsPerHour: number | null;
tokensPerMin: number | null;
lookupHitRate: number | null;
}
export type MonthlyRollup = DailyRollup;
export interface SessionTimelinePoint {
sampleMs: number;
totalWatchedMs: number;
activeWatchedMs: number;
linesSeen: number;
tokensSeen: number;
cardsMined: number;
}
export interface SessionEvent {
eventType: EventType;
tsMs: number;
payload: string | null;
}
export interface AnkiNotePreview {
word: string;
sentence: string;
translation: string;
}
export interface StatsAnkiNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
preview?: AnkiNotePreview;
}
export interface VocabularyEntry {
wordId: number;
headword: string;
word: string;
reading: string;
partOfSpeech: string | null;
pos1: string | null;
pos2: string | null;
pos3: string | null;
frequency: number;
frequencyRank: number | null;
animeCount: number;
firstSeen: number;
lastSeen: number;
}
export interface StatsExcludedWord {
headword: string;
word: string;
reading: string;
}
export interface StatsCoverImage {
contentType: string;
dataUrl: string;
}
export interface StatsCoverImagesData {
anime: Record<number, StatsCoverImage | null>;
media: Record<number, StatsCoverImage | null>;
}
export interface KanjiEntry {
kanjiId: number;
kanji: string;
frequency: number;
firstSeen: number;
lastSeen: number;
}
export interface VocabularyOccurrenceEntry {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
occurrenceCount: number;
}
export interface SentenceSearchResult {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
}
export interface OverviewData {
sessions: SessionSummary[];
rollups: DailyRollup[];
hints: {
totalSessions: number;
activeSessions: number;
episodesToday: number;
activeAnimeCount: number;
totalEpisodesWatched: number;
totalAnimeCompleted: number;
totalActiveMin: number;
activeDays: number;
totalCards?: number;
totalTokensSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
newWordsToday: number;
newWordsThisWeek: number;
};
}
export interface MediaLibraryItem {
videoId: number;
canonicalTitle: string;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
lastWatchedMs: number;
hasCoverArt: number;
youtubeVideoId?: string | null;
videoUrl?: string | null;
videoTitle?: string | null;
videoThumbnailUrl?: string | null;
channelId?: string | null;
channelName?: string | null;
channelUrl?: string | null;
channelThumbnailUrl?: string | null;
uploaderId?: string | null;
uploaderUrl?: string | null;
description?: string | null;
}
export interface MediaDetailData {
detail: {
videoId: number;
canonicalTitle: string;
animeId: number | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
totalLinesSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
youtubeVideoId?: string | null;
videoUrl?: string | null;
videoTitle?: string | null;
videoThumbnailUrl?: string | null;
channelId?: string | null;
channelName?: string | null;
channelUrl?: string | null;
channelThumbnailUrl?: string | null;
uploaderId?: string | null;
uploaderUrl?: string | null;
description?: string | null;
} | null;
sessions: SessionSummary[];
rollups: DailyRollup[];
}
export const EventType = {
SUBTITLE_LINE: 1,
MEDIA_BUFFER: 2,
LOOKUP: 3,
CARD_MINED: 4,
SEEK_FORWARD: 5,
SEEK_BACKWARD: 6,
PAUSE_START: 7,
PAUSE_END: 8,
YOMITAN_LOOKUP: 9,
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
export interface AnimeLibraryItem {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
episodeCount: number;
episodesTotal: number | null;
lastWatchedMs: number;
}
export interface AnilistEntry {
anilistId: number;
titleRomaji: string | null;
titleEnglish: string | null;
season: number | null;
}
export interface AnimeDetailData {
detail: {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
totalLinesSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
episodeCount: number;
lastWatchedMs: number;
};
episodes: AnimeEpisode[];
anilistEntries: AnilistEntry[];
}
export interface AnimeEpisode {
videoId: number;
episode: number | null;
season: number | null;
durationMs: number;
endedMediaMs: number | null;
watched: number;
canonicalTitle: string;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
totalYomitanLookupCount: number;
lastWatchedMs: number;
}
export interface AnimeWord {
wordId: number;
headword: string;
word: string;
reading: string;
partOfSpeech: string | null;
frequency: number;
}
export interface StreakCalendarDay {
epochDay: number;
totalActiveMin: number;
}
export interface EpisodesPerDay {
epochDay: number;
episodeCount: number;
}
export interface NewAnimePerDay {
epochDay: number;
newAnimeCount: number;
}
export interface WatchTimePerAnime {
epochDay: number;
animeId: number;
animeTitle: string;
totalActiveMin: number;
}
export interface TrendChartPoint {
label: string;
value: number;
}
export interface TrendPerAnimePoint {
epochDay: number;
animeTitle: string;
value: number;
}
export interface LibrarySummaryRow {
title: string;
watchTimeMin: number;
videos: number;
sessions: number;
cards: number;
words: number;
lookups: number;
lookupsPerHundred: number | null;
firstWatched: number;
lastWatched: number;
}
export interface TrendsDashboardData {
activity: {
watchTime: TrendChartPoint[];
cards: TrendChartPoint[];
words: TrendChartPoint[];
sessions: TrendChartPoint[];
};
progress: {
watchTime: TrendChartPoint[];
sessions: TrendChartPoint[];
words: TrendChartPoint[];
newWords: TrendChartPoint[];
cards: TrendChartPoint[];
episodes: TrendChartPoint[];
lookups: TrendChartPoint[];
};
ratios: {
lookupsPerHundred: TrendChartPoint[];
cardsPerHour: TrendChartPoint[];
readingSpeed: TrendChartPoint[];
};
librarySummary: LibrarySummaryRow[];
animeCumulative: {
watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
};
patterns: {
watchTimeByDayOfWeek: TrendChartPoint[];
watchTimeByHour: TrendChartPoint[];
};
}
export interface WordDetailData {
detail: {
wordId: number;
headword: string;
word: string;
reading: string;
partOfSpeech: string | null;
pos1: string | null;
pos2: string | null;
pos3: string | null;
frequency: number;
firstSeen: number;
lastSeen: number;
};
animeAppearances: Array<{
animeId: number;
animeTitle: string;
occurrenceCount: number;
}>;
similarWords: Array<{
wordId: number;
headword: string;
word: string;
reading: string;
frequency: number;
}>;
}
export interface EpisodeCardEvent {
eventId: number;
sessionId: number;
tsMs: number;
cardsDelta: number;
noteIds: number[];
}
export interface EpisodeDetailData {
sessions: SessionSummary[];
words: AnimeWord[];
cardEvents: EpisodeCardEvent[];
}
export interface KanjiDetailData {
detail: {
kanjiId: number;
kanji: string;
frequency: number;
firstSeen: number;
lastSeen: number;
};
animeAppearances: Array<{
animeId: number;
animeTitle: string;
occurrenceCount: number;
}>;
words: Array<{
wordId: number;
headword: string;
word: string;
reading: string;
frequency: number;
}>;
}
export * from '../../../src/types/stats-wire';
export * from '../../../src/types/stats-http-contract';
-1
View File
@@ -9,7 +9,6 @@
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]