Files
SubMiner/stats/src/lib/api-client.ts
sudacode 364f7aacb7 feat(stats): expose 365d trends range in dashboard UI
Add '365d' to the client TrendRange union, the TimeRange hook type, and
the DateRangeSelector segmented control so users can select a 365-day
window in the trends dashboard.
2026-04-09 00:44:31 -07:00

221 lines
8.6 KiB
TypeScript

import type {
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
TrendsDashboardData,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
StatsAnkiNoteInfo,
} from '../types/stats';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
const resolvedLocation =
location ??
(typeof window === 'undefined'
? { protocol: 'file:', origin: 'null', search: '' }
: window.location);
const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
if (queryApiBase) {
return queryApiBase;
}
return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
}
export const BASE_URL = resolveStatsBaseUrl();
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE_URL}${path}`, init);
if (!res.ok) {
let body = '';
try {
body = (await res.text()).trim();
} catch {
body = '';
}
throw new Error(
body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`,
);
}
return res;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetchResponse(path);
return res.json() as Promise<T>;
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getDailyRollups: (limit = 60) =>
fetchJson<DailyRollup[]>(`/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}`),
getSessionTimeline: (id: number, limit?: number) =>
fetchJson<SessionTimelinePoint[]>(
limit === undefined
? `/api/stats/sessions/${id}/timeline`
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
),
getSessionEvents: (id: number, limit = 500, eventTypes?: number[]) => {
const params = new URLSearchParams({ limit: String(limit) });
if (eventTypes && eventTypes.length > 0) {
params.set('types', eventTypes.join(','));
}
return fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?${params.toString()}`);
},
getSessionKnownWordsTimeline: (id: number) =>
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number }>>(
`/api/stats/sessions/${id}/known-words-timeline`,
),
getVocabulary: (limit = 100) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
),
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
`/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}`),
getAnimeWords: (animeId: number, limit = 50) =>
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`,
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
getNewAnimePerDay: (limit = 90) =>
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') =>
fetchJson<TrendsDashboardData>(
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
),
getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) =>
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),
getEpisodeDetail: (videoId: number) =>
fetchJson<EpisodeDetailData>(`/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 }),
});
},
deleteSession: async (sessionId: number): Promise<void> => {
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
},
deleteSessions: async (sessionIds: number[]): Promise<void> => {
await fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds }),
});
},
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',
),
getAnimeKnownWordsSummary: (animeId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
`/api/stats/anime/${animeId}/known-words-summary`,
),
getMediaKnownWordsSummary: (videoId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
`/api/stats/media/${videoId}/known-words-summary`,
),
searchAnilist: (query: string) =>
fetchJson<
Array<{
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | 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> => {
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
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[] }> => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
return res.json();
},
ankiBrowse: async (noteId: number): Promise<void> => {
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
},
ankiNotesInfo: async (noteIds: number[]): Promise<StatsAnkiNoteInfo[]> => {
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds }),
});
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
return res.json();
},
};