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
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: stats
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
+1
View File
@@ -39,6 +39,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Runtime-option contracts: `src/types/runtime-options.ts`
- Settings UI contracts: `src/types/settings.ts`
- Session-binding contracts: `src/types/session-bindings.ts`
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
- Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics
@@ -33,6 +33,10 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
## Contract
Stats data crosses the process boundary over HTTP only. Shared JSON models live in `src/types/stats-wire.ts`; endpoint request/response mappings and the client interface live in `src/types/stats-http-contract.ts`. The server and stats client both type-check against those files. `stats/src/types/stats.ts` is a compatibility re-export, not an independent contract copy.
The stats preload bridge remains limited to native window behavior such as confirmation-dialog layering. Do not add stats data request channels back to Electron IPC.
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
@@ -170,6 +170,8 @@ const TRENDS_DASHBOARD = {
},
ratios: {
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
readingSpeed: [{ label: 'Mar 1', value: 180 }],
},
librarySummary: [
{
@@ -572,7 +572,7 @@ export class ImmersionTrackerService {
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day',
fillEmptyBuckets = true,
): Promise<unknown> {
) {
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
}
-195
View File
@@ -181,35 +181,6 @@ function createFakeImmersionTracker(
): NonNullable<IpcServiceDeps['immersionTracker']> {
return {
recordYomitanLookup: () => {},
getSessionSummaries: async () => [],
getDailyRollups: async () => [],
getMonthlyRollups: async () => [],
getQueryHints: async () => ({
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalActiveMin: 0,
totalCards: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async () => [],
getSessionEvents: async () => [],
getVocabularyStats: async () => [],
getKanjiStats: async () => [],
getMediaLibrary: async () => [],
getMediaDetail: async () => null,
getMediaSessions: async () => [],
getMediaDailyRollups: async () => [],
getCoverArt: async () => null,
markActiveVideoWatched: async () => false,
...overrides,
};
@@ -776,172 +747,6 @@ test('registerIpcHandlers records yomitan lookup when subtitle context recording
}
});
test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(createRegisterIpcDeps(), registrar);
const overviewHandler = handlers.handle.get(IPC_CHANNELS.request.statsGetOverview);
assert.ok(overviewHandler);
assert.deepEqual(await overviewHandler!({}), {
sessions: [],
rollups: [],
hints: {
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalCards: 0,
totalActiveMin: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},
});
});
test('registerIpcHandlers validates and clamps stats request limits', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<[string, number, number?]> = [];
registerIpcHandlers(
createRegisterIpcDeps({
immersionTracker: {
recordYomitanLookup: () => {},
getSessionSummaries: async (limit = 0) => {
calls.push(['sessions', limit]);
return [];
},
getDailyRollups: async (limit = 0) => {
calls.push(['daily', limit]);
return [];
},
getMonthlyRollups: async (limit = 0) => {
calls.push(['monthly', limit]);
return [];
},
getQueryHints: async () => ({
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalCards: 0,
totalActiveMin: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async (sessionId: number, limit = 0) => {
calls.push(['timeline', limit, sessionId]);
return [];
},
getSessionEvents: async (sessionId: number, limit = 0) => {
calls.push(['events', limit, sessionId]);
return [];
},
getVocabularyStats: async (limit = 0) => {
calls.push(['vocabulary', limit]);
return [];
},
getKanjiStats: async (limit = 0) => {
calls.push(['kanji', limit]);
return [];
},
getMediaLibrary: async () => [],
getMediaDetail: async () => null,
getMediaSessions: async () => [],
getMediaDailyRollups: async () => [],
getCoverArt: async () => null,
markActiveVideoWatched: async () => false,
},
}),
registrar,
);
await handlers.handle.get(IPC_CHANNELS.request.statsGetDailyRollups)!({}, -1);
await handlers.handle.get(IPC_CHANNELS.request.statsGetMonthlyRollups)!(
{},
Number.POSITIVE_INFINITY,
);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessions)!({}, 9999);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, 12.5);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionEvents)!({}, 7, 0);
await handlers.handle.get(IPC_CHANNELS.request.statsGetVocabulary)!({}, 1000);
await handlers.handle.get(IPC_CHANNELS.request.statsGetKanji)!({}, NaN);
assert.deepEqual(calls, [
['daily', 60],
['monthly', 24],
['sessions', 500],
['timeline', 200, 7],
['events', 500, 7],
['vocabulary', 500],
['kanji', 100],
]);
});
test('registerIpcHandlers requests the full timeline when no limit is provided', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<[string, number | undefined, number]> = [];
registerIpcHandlers(
createRegisterIpcDeps({
immersionTracker: {
recordYomitanLookup: () => {},
getSessionSummaries: async () => [],
getDailyRollups: async () => [],
getMonthlyRollups: async () => [],
getQueryHints: async () => ({
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalCards: 0,
totalActiveMin: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async (sessionId: number, limit?: number) => {
calls.push(['timeline', limit, sessionId]);
return [];
},
getSessionEvents: async () => [],
getVocabularyStats: async () => [],
getKanjiStats: async () => [],
getMediaLibrary: async () => [],
getMediaDetail: async () => null,
getMediaSessions: async () => [],
getMediaDailyRollups: async () => [],
getCoverArt: async () => null,
markActiveVideoWatched: async () => false,
},
}),
registrar,
);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, undefined);
assert.deepEqual(calls, [['timeline', undefined, 7]]);
});
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const saves: unknown[] = [];
-158
View File
@@ -132,35 +132,6 @@ export interface IpcServiceDeps {
) => Promise<PlaylistBrowserMutationResult>;
immersionTracker?: {
recordYomitanLookup: () => void;
getSessionSummaries: (limit?: number) => Promise<unknown>;
getDailyRollups: (limit?: number) => Promise<unknown>;
getMonthlyRollups: (limit?: number) => Promise<unknown>;
getQueryHints: () => Promise<{
totalSessions: number;
activeSessions: number;
episodesToday: number;
activeAnimeCount: number;
totalActiveMin: number;
totalCards: number;
activeDays: number;
totalEpisodesWatched: number;
totalAnimeCompleted: number;
totalTokensSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
newWordsToday: number;
newWordsThisWeek: number;
}>;
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
getVocabularyStats: (limit?: number) => Promise<unknown>;
getKanjiStats: (limit?: number) => Promise<unknown>;
getMediaLibrary: () => Promise<unknown>;
getMediaDetail: (videoId: number) => Promise<unknown>;
getMediaSessions: (videoId: number, limit?: number) => Promise<unknown>;
getMediaDailyRollups: (videoId: number, limit?: number) => Promise<unknown>;
getCoverArt: (videoId: number) => Promise<unknown>;
markActiveVideoWatched: () => Promise<boolean>;
} | null;
}
@@ -459,24 +430,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
}
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
const parsePositiveIntLimit = (
value: unknown,
defaultValue: number,
maxValue: number,
): number => {
if (!Number.isInteger(value) || (value as number) < 1) {
return defaultValue;
}
return Math.min(value as number, maxValue);
};
const parsePositiveInteger = (value: unknown): number | null => {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return null;
}
return value;
};
ipc.on(
IPC_CHANNELS.command.setIgnoreMouseEvents,
(event: unknown, ignore: unknown, options: unknown = {}) => {
@@ -904,115 +857,4 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
},
);
// Stats request handlers
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
const tracker = deps.immersionTracker;
if (!tracker) {
return {
sessions: [],
rollups: [],
hints: {
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalActiveMin: 0,
totalCards: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},
};
}
const [sessions, rollups, hints] = await Promise.all([
tracker.getSessionSummaries(5),
tracker.getDailyRollups(14),
tracker.getQueryHints(),
]);
return { sessions, rollups, hints };
});
ipc.handle(IPC_CHANNELS.request.statsGetDailyRollups, async (_event, limit: unknown) => {
const parsedLimit = parsePositiveIntLimit(limit, 60, 500);
return deps.immersionTracker?.getDailyRollups(parsedLimit) ?? [];
});
ipc.handle(IPC_CHANNELS.request.statsGetMonthlyRollups, async (_event, limit: unknown) => {
const parsedLimit = parsePositiveIntLimit(limit, 24, 120);
return deps.immersionTracker?.getMonthlyRollups(parsedLimit) ?? [];
});
ipc.handle(IPC_CHANNELS.request.statsGetSessions, async (_event, limit: unknown) => {
const parsedLimit = parsePositiveIntLimit(limit, 50, 500);
return deps.immersionTracker?.getSessionSummaries(parsedLimit) ?? [];
});
ipc.handle(
IPC_CHANNELS.request.statsGetSessionTimeline,
async (_event, sessionId: unknown, limit: unknown) => {
const parsedSessionId = parsePositiveInteger(sessionId);
if (parsedSessionId === null) return [];
const parsedLimit = limit === undefined ? undefined : parsePositiveIntLimit(limit, 200, 1000);
return deps.immersionTracker?.getSessionTimeline(parsedSessionId, parsedLimit) ?? [];
},
);
ipc.handle(
IPC_CHANNELS.request.statsGetSessionEvents,
async (_event, sessionId: unknown, limit: unknown) => {
const parsedSessionId = parsePositiveInteger(sessionId);
if (parsedSessionId === null) return [];
const parsedLimit = parsePositiveIntLimit(limit, 500, 1000);
return deps.immersionTracker?.getSessionEvents(parsedSessionId, parsedLimit) ?? [];
},
);
ipc.handle(IPC_CHANNELS.request.statsGetVocabulary, async (_event, limit: unknown) => {
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
return deps.immersionTracker?.getVocabularyStats(parsedLimit) ?? [];
});
ipc.handle(IPC_CHANNELS.request.statsGetKanji, async (_event, limit: unknown) => {
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
return deps.immersionTracker?.getKanjiStats(parsedLimit) ?? [];
});
ipc.handle(IPC_CHANNELS.request.statsGetMediaLibrary, async () => {
return deps.immersionTracker?.getMediaLibrary() ?? [];
});
ipc.handle(IPC_CHANNELS.request.statsGetMediaDetail, async (_event, videoId: unknown) => {
if (typeof videoId !== 'number') return null;
return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
});
ipc.handle(
IPC_CHANNELS.request.statsGetMediaSessions,
async (_event, videoId: unknown, limit: unknown) => {
if (typeof videoId !== 'number') return [];
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
return deps.immersionTracker?.getMediaSessions(videoId, parsedLimit) ?? [];
},
);
ipc.handle(
IPC_CHANNELS.request.statsGetMediaDailyRollups,
async (_event, videoId: unknown, limit: unknown) => {
if (typeof videoId !== 'number') return [];
const parsedLimit = parsePositiveIntLimit(limit, 90, 500);
return deps.immersionTracker?.getMediaDailyRollups(videoId, parsedLimit) ?? [];
},
);
ipc.handle(IPC_CHANNELS.request.statsGetMediaCover, async (_event, videoId: unknown) => {
if (typeof videoId !== 'number') return null;
return deps.immersionTracker?.getCoverArt(videoId) ?? null;
});
}
+5 -10
View File
@@ -1,15 +1,10 @@
import type { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
import type { StatsCoverImage } from '../../types/stats-wire.js';
type StatsCoverImagePayload = {
contentType: string;
dataUrl: string;
} | null;
type StatsCoverBatchBody = {
animeIds?: unknown;
videoIds?: unknown;
};
type StatsCoverImagePayload = StatsCoverImage | null;
type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
@@ -130,7 +125,7 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
}),
);
return c.json({ anime, media });
return c.json(statsJson('coverImages', { anime, media }));
});
app.get('/api/stats/anime/:animeId/cover', async (c) => {
+128 -92
View File
@@ -18,6 +18,12 @@ import {
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import { registerStatsCoverRoutes } from './stats-cover-routes.js';
import {
statsJson,
type StatsAnilistSearchResult,
type StatsAnkiBrowseResponse,
} from '../../types/stats-http-contract.js';
import type { StatsExcludedWord } from '../../types/stats-wire.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
@@ -46,12 +52,6 @@ export type StatsMiningTimingEvent = {
noteId?: number;
};
type StatsExcludedWordPayload = {
headword: string;
word: string;
reading: string;
};
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
@@ -87,12 +87,12 @@ function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
return parsed.length > 0 ? parsed : undefined;
}
function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | null {
function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null {
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
return null;
}
const words: StatsExcludedWordPayload[] = [];
const words: StatsExcludedWord[] = [];
for (const row of (body as { words: unknown[] }).words) {
if (!row || typeof row !== 'object') return null;
const { headword, word, reading } = row as Record<string, unknown>;
@@ -320,20 +320,22 @@ function summarizeFilteredWordOccurrences(
return { knownWordsSeen, totalWordsSeen };
}
async function enrichSessionsWithKnownWordMetrics(
tracker: ImmersionTrackerService,
sessions: Array<{
async function enrichSessionsWithKnownWordMetrics<
Session extends {
sessionId: number;
tokensSeen: number;
}>,
},
>(
tracker: ImmersionTrackerService,
sessions: Session[],
knownWordsCachePath?: string,
): Promise<
Array<{
sessionId: number;
tokensSeen: number;
knownWordsSeen: number;
knownWordRate: number;
}>
Array<
Session & {
knownWordsSeen: number;
knownWordRate: number;
}
>
> {
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
if (!knownWordsSet) {
@@ -603,46 +605,48 @@ export function createStatsApp(
rawSessions,
options?.knownWordCachePath,
);
return c.json({ sessions, rollups, hints });
return c.json(statsJson('overview', { sessions, rollups, hints }));
});
app.get('/api/stats/daily-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
const rollups = await tracker.getDailyRollups(limit);
return c.json(rollups);
return c.json(statsJson('dailyRollups', rollups));
});
app.get('/api/stats/monthly-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
const rollups = await tracker.getMonthlyRollups(limit);
return c.json(rollups);
return c.json(statsJson('monthlyRollups', rollups));
});
app.get('/api/stats/streak-calendar', async (c) => {
const days = parseIntQuery(c.req.query('days'), 90, 365);
return c.json(await tracker.getStreakCalendar(days));
return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days)));
});
app.get('/api/stats/trends/episodes-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(await tracker.getEpisodesPerDay(limit));
return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit)));
});
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(await tracker.getNewAnimePerDay(limit));
return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit)));
});
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(await tracker.getWatchTimePerAnime(limit));
return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit)));
});
app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
return c.json(
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
);
});
app.get('/api/stats/sessions', async (c) => {
@@ -653,30 +657,30 @@ export function createStatsApp(
rawSessions,
options?.knownWordCachePath,
);
return c.json(sessions);
return c.json(statsJson('sessions', sessions));
});
app.get('/api/stats/sessions/:id/timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json([], 400);
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
const rawLimit = c.req.query('limit');
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
const timeline = await tracker.getSessionTimeline(id, limit);
return c.json(timeline);
return c.json(statsJson('sessionTimeline', timeline));
});
app.get('/api/stats/sessions/:id/events', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json([], 400);
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
const eventTypes = parseEventTypesQuery(c.req.query('types'));
const events = await tracker.getSessionEvents(id, limit, eventTypes);
return c.json(events);
return c.json(statsJson('sessionEvents', events));
});
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json([], 400);
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
@@ -720,18 +724,18 @@ export function createStatsApp(
});
}
return c.json(knownByLinesSeen);
return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen));
});
app.get('/api/stats/vocabulary', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
const vocab = await tracker.getVocabularyStats(limit, excludePos);
return c.json(vocab);
return c.json(statsJson('vocabulary', vocab));
});
app.get('/api/stats/excluded-words', async (c) => {
return c.json(await tracker.getStatsExcludedWords());
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
});
app.put('/api/stats/excluded-words', async (c) => {
@@ -739,7 +743,7 @@ export function createStatsApp(
const words = parseExcludedWordsBody(body);
if (!words) return c.body(null, 400);
await tracker.replaceStatsExcludedWords(words);
return c.json({ ok: true });
return c.json(statsJson('setExcludedWords', { ok: true }));
});
app.get('/api/stats/vocabulary/occurrences', async (c) => {
@@ -747,17 +751,17 @@ export function createStatsApp(
const word = (c.req.query('word') ?? '').trim();
const reading = (c.req.query('reading') ?? '').trim();
if (!headword || !word) {
return c.json([], 400);
return c.json(statsJson('wordOccurrences', []), 400);
}
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
return c.json(occurrences);
return c.json(statsJson('wordOccurrences', occurrences));
});
app.get('/api/stats/sentences/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
if (!query) return c.json(statsJson('sentenceSearch', []));
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
const searchOptions = await buildSentenceSearchOptions(
@@ -766,24 +770,24 @@ export function createStatsApp(
options?.resolveSentenceSearchHeadwords,
);
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
return c.json(rows);
return c.json(statsJson('sentenceSearch', rows));
});
app.get('/api/stats/kanji', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const kanji = await tracker.getKanjiStats(limit);
return c.json(kanji);
return c.json(statsJson('kanji', kanji));
});
app.get('/api/stats/kanji/occurrences', async (c) => {
const kanji = (c.req.query('kanji') ?? '').trim();
if (!kanji) {
return c.json([], 400);
return c.json(statsJson('kanjiOccurrences', []), 400);
}
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
return c.json(occurrences);
return c.json(statsJson('kanjiOccurrences', occurrences));
});
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
@@ -793,7 +797,7 @@ export function createStatsApp(
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
const similarWords = await tracker.getSimilarWords(wordId);
return c.json({ detail, animeAppearances, similarWords });
return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords }));
});
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
@@ -803,17 +807,17 @@ export function createStatsApp(
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
const words = await tracker.getKanjiWords(kanjiId);
return c.json({ detail, animeAppearances, words });
return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words }));
});
app.get('/api/stats/media', async (c) => {
const library = await tracker.getMediaLibrary();
return c.json(library);
return c.json(statsJson('mediaLibrary', library));
});
app.get('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.json(null, 400);
if (videoId <= 0) return c.json(statsJson('error', null), 400);
const [detail, rawSessions, rollups] = await Promise.all([
tracker.getMediaDetail(videoId),
tracker.getMediaSessions(videoId, 100),
@@ -824,12 +828,12 @@ export function createStatsApp(
rawSessions,
options?.knownWordCachePath,
);
return c.json({ detail, sessions, rollups });
return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
});
app.get('/api/stats/anime', async (c) => {
const rows = await tracker.getAnimeLibrary();
return c.json(rows);
return c.json(statsJson('animeLibrary', rows));
});
app.get('/api/stats/anime/:animeId', async (c) => {
@@ -841,21 +845,21 @@ export function createStatsApp(
tracker.getAnimeEpisodes(animeId),
tracker.getAnimeAnilistEntries(animeId),
]);
return c.json({ detail, episodes, anilistEntries });
return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries }));
});
app.get('/api/stats/anime/:animeId/words', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
if (animeId <= 0) return c.body(null, 400);
return c.json(await tracker.getAnimeWords(animeId, limit));
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
});
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
if (animeId <= 0) return c.body(null, 400);
return c.json(await tracker.getAnimeDailyRollups(animeId, limit));
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
});
app.patch('/api/stats/media/:videoId/watched', async (c) => {
@@ -864,7 +868,7 @@ export function createStatsApp(
const body = await c.req.json().catch(() => null);
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
await tracker.setVideoWatched(videoId, watched);
return c.json({ ok: true });
return c.json(statsJson('setVideoWatched', { ok: true }));
});
app.delete('/api/stats/sessions', async (c) => {
@@ -874,26 +878,26 @@ export function createStatsApp(
: [];
if (ids.length === 0) return c.body(null, 400);
await tracker.deleteSessions(ids);
return c.json({ ok: true });
return c.json(statsJson('deleteSessions', { ok: true }));
});
app.delete('/api/stats/sessions/:sessionId', async (c) => {
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
if (sessionId <= 0) return c.body(null, 400);
await tracker.deleteSession(sessionId);
return c.json({ ok: true });
return c.json(statsJson('deleteSession', { ok: true }));
});
app.delete('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
await tracker.deleteVideo(videoId);
return c.json({ ok: true });
return c.json(statsJson('deleteVideo', { ok: true }));
});
app.get('/api/stats/anilist/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
if (!query) return c.json(statsJson('anilistSearch', []));
try {
await options?.anilistRateLimiter?.acquire();
const res = await fetch('https://graphql.anilist.co', {
@@ -918,44 +922,66 @@ export function createStatsApp(
});
options?.anilistRateLimiter?.recordResponse(res.headers);
if (res.status === 429) {
return c.json([]);
return c.json(statsJson('anilistSearch', []));
}
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
return c.json(json.data?.Page?.media ?? []);
const json = (await res.json()) as {
data?: { Page?: { media?: StatsAnilistSearchResult[] } };
};
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
} catch {
return c.json([]);
return c.json(statsJson('anilistSearch', []));
}
});
app.get('/api/stats/known-words', (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json([]);
return c.json([...knownWordsSet]);
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
return c.json(statsJson('knownWords', [...knownWordsSet]));
});
app.get('/api/stats/known-words-summary', async (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
if (!knownWordsSet) {
return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }));
}
const headwords = await tracker.getAllDistinctHeadwords();
return c.json(countKnownWords(headwords, knownWordsSet));
return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
if (animeId <= 0) {
return c.json(
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
);
}
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
if (!knownWordsSet) {
return c.json(
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
);
}
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
return c.json(countKnownWords(headwords, knownWordsSet));
return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
if (videoId <= 0) {
return c.json(
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
);
}
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
if (!knownWordsSet) {
return c.json(
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
);
}
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
return c.json(countKnownWords(headwords, knownWordsSet));
return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
@@ -964,7 +990,7 @@ export function createStatsApp(
const body = await c.req.json().catch(() => null);
if (!body?.anilistId) return c.body(null, 400);
await tracker.reassignAnimeAnilist(animeId, body);
return c.json({ ok: true });
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
});
registerStatsCoverRoutes(app, tracker);
@@ -980,7 +1006,7 @@ export function createStatsApp(
rawSessions,
options?.knownWordCachePath,
);
return c.json({ sessions, words, cardEvents });
return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
});
app.post('/api/stats/anki/browse', async (c) => {
@@ -998,10 +1024,10 @@ export function createStatsApp(
params: { query: `nid:${noteId}` },
}),
});
const result = await response.json();
return c.json(result);
const result = (await response.json()) as StatsAnkiBrowseResponse;
return c.json(statsJson('ankiBrowse', result));
} catch {
return c.json({ error: 'Failed to reach AnkiConnect' }, 502);
return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502);
}
});
@@ -1012,7 +1038,7 @@ export function createStatsApp(
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
)
: [];
if (noteIds.length === 0) return c.json([]);
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
const resolvedNoteIds = Array.from(
new Set(
noteIds.map((noteId) => {
@@ -1039,13 +1065,16 @@ export function createStatsApp(
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
};
return c.json(
(result.result ?? []).map((note) => ({
...note,
preview: buildAnkiNotePreview(note.fields, ankiConfig),
})),
statsJson(
'ankiNotesInfo',
(result.result ?? []).map((note) => ({
...note,
preview: buildAnkiNotePreview(note.fields, ankiConfig),
})),
),
);
} catch {
return c.json([], 502);
return c.json(statsJson('ankiNotesInfo', []), 502);
}
});
@@ -1063,19 +1092,24 @@ export function createStatsApp(
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
return c.json(
statsJson('mineCard', {
error: 'sourcePath, sentence, startMs, and endMs are required',
}),
400,
);
}
if (endMs <= startMs) {
return c.json({ error: 'endMs must be greater than startMs' }, 400);
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
}
if (!existsSync(sourcePath)) {
return c.json({ error: 'File not found' }, 404);
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
}
const ankiConfig = getAnkiConnectConfig();
if (!ankiConfig) {
return c.json({ error: 'AnkiConnect is not configured' }, 500);
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
}
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = '';
@@ -1203,7 +1237,7 @@ export function createStatsApp(
if (mode === 'word') {
if (!options?.addYomitanNote) {
return c.json({ error: 'Yomitan bridge not available' }, 500);
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
}
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
@@ -1219,9 +1253,9 @@ export function createStatsApp(
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
return c.json(
{
statsJson('mineCard', {
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
},
}),
502,
);
}
@@ -1336,7 +1370,7 @@ export function createStatsApp(
}
}
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
}
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
@@ -1394,7 +1428,9 @@ export function createStatsApp(
if (addNoteResult.status === 'rejected') {
return c.json(
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
statsJson('mineCard', {
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
}),
502,
);
}
@@ -1470,7 +1506,7 @@ export function createStatsApp(
}
}
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
});
if (options?.staticDir) {
+9 -4
View File
@@ -100,7 +100,7 @@ test(
'#!/bin/sh',
'if [ "${1:-}" = "--appimage-mount" ]; then',
` echo "${mountDir}"`,
` trap 'date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
' while :; do sleep 0.05; done',
'fi',
`date +%s%N > "${resultsDir}/direct-run"`,
@@ -120,15 +120,20 @@ test(
// (the real runtime unmounts on its own after the signal), so poll.
const releasedMarker = path.join(resultsDir, 'holder-released');
const pollDeadline = Date.now() + 2000;
while (!fs.existsSync(releasedMarker) && Date.now() < pollDeadline) {
let holderReleased: number | null = null;
while (holderReleased === null && Date.now() < pollDeadline) {
if (fs.existsSync(releasedMarker)) {
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
}
if (holderReleased !== null) break;
await new Promise((r) => setTimeout(r, 25));
}
assert.ok(fs.existsSync(releasedMarker), 'holder must be released');
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
const appRunExited = Number(
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
);
const holderReleased = Number(fs.readFileSync(releasedMarker, 'utf8').trim());
const drainNs = holderReleased - appRunExited;
assert.ok(
drainNs >= 0.8e9,
-42
View File
@@ -2,48 +2,6 @@ import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
const statsAPI = {
getOverview: (): Promise<unknown> => ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
getDailyRollups: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit),
getMonthlyRollups: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMonthlyRollups, limit),
getSessions: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessions, limit),
getSessionTimeline: (sessionId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionTimeline, sessionId, limit),
getSessionEvents: (sessionId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionEvents, sessionId, limit),
getVocabulary: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetVocabulary, limit),
getKanji: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetKanji, limit),
getMediaLibrary: (): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaLibrary),
getMediaDetail: (videoId: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDetail, videoId),
getMediaSessions: (videoId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaSessions, videoId, limit),
getMediaDailyRollups: (videoId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDailyRollups, videoId, limit),
getMediaCover: (videoId: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaCover, videoId),
hideOverlay: (): void => {
ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay);
},
confirmNativeDialog: (message: string): boolean => {
return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true;
},
-13
View File
@@ -101,19 +101,6 @@ export const IPC_CHANNELS = {
animetoshoDownloadFile: 'animetosho:download-file',
animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages',
kikuBuildMergePreview: 'kiku:build-merge-preview',
statsGetOverview: 'stats:get-overview',
statsGetDailyRollups: 'stats:get-daily-rollups',
statsGetMonthlyRollups: 'stats:get-monthly-rollups',
statsGetSessions: 'stats:get-sessions',
statsGetSessionTimeline: 'stats:get-session-timeline',
statsGetSessionEvents: 'stats:get-session-events',
statsGetVocabulary: 'stats:get-vocabulary',
statsGetKanji: 'stats:get-kanji',
statsGetMediaLibrary: 'stats:get-media-library',
statsGetMediaDetail: 'stats:get-media-detail',
statsGetMediaSessions: 'stats:get-media-sessions',
statsGetMediaDailyRollups: 'stats:get-media-daily-rollups',
statsGetMediaCover: 'stats:get-media-cover',
getConfigSettingsSnapshot: 'config:get-settings-snapshot',
saveConfigSettingsPatch: 'config:save-settings-patch',
openConfigSettingsFile: 'config:open-settings-file',
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
function read(relativePath: string): string {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
test('stats data uses the shared HTTP contract while native dialogs retain IPC', () => {
assert.equal(fs.existsSync(path.join(root, 'stats/src/lib/ipc-client.ts')), false);
const preload = read('src/preload-stats.ts');
assert.doesNotMatch(preload, /statsGet[A-Z]/);
assert.match(preload, /statsNativeConfirmDialog/);
const ipcContracts = read('src/shared/ipc/contracts.ts');
assert.doesNotMatch(ipcContracts, /statsGet[A-Z]/);
const ipcHandlers = read('src/core/services/ipc.ts');
assert.doesNotMatch(ipcHandlers, /statsGet[A-Z]/);
const apiClient = read('stats/src/lib/api-client.ts');
const statsServer = read('src/core/services/stats-server.ts');
assert.match(apiClient, /StatsHttpClient/);
assert.match(statsServer, /statsJson/);
});
+231
View File
@@ -0,0 +1,231 @@
import type {
AnimeDetailData,
AnimeLibraryItem,
AnimeWord,
DailyRollup,
EpisodeDetailData,
EpisodesPerDay,
KanjiDetailData,
KanjiEntry,
MediaDetailData,
MediaLibraryItem,
MonthlyRollup,
NewAnimePerDay,
OverviewData,
SentenceSearchResult,
SessionEvent,
SessionSummary,
SessionTimelinePoint,
StatsAnkiNoteInfo,
StatsCoverImagesData,
StatsExcludedWord,
StreakCalendarDay,
TrendsDashboardData,
VocabularyEntry,
VocabularyOccurrenceEntry,
WatchTimePerAnime,
WordDetailData,
} from './stats-wire';
export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
export type StatsTrendGroupBy = 'day' | 'month';
export type StatsMineMode = 'word' | 'sentence' | 'audio';
export interface StatsSessionKnownWordsTimelinePoint {
linesSeen: number;
knownWordsSeen: number;
totalWordsSeen: number;
}
export interface StatsKnownWordsSummary {
totalUniqueWords: number;
knownWordCount: number;
}
export interface StatsAnilistSearchResult {
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;
}
export interface StatsAnilistAssignment {
anilistId: number;
titleRomaji?: string | null;
titleEnglish?: string | null;
titleNative?: string | null;
episodesTotal?: number | null;
description?: string | null;
coverUrl?: string | null;
}
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 interface StatsCoverImagesRequest {
animeIds: number[];
videoIds: number[];
}
export interface StatsExcludedWordsRequest {
words: StatsExcludedWord[];
}
export interface StatsVideoWatchedRequest {
watched: boolean;
}
export interface StatsDeleteSessionsRequest {
sessionIds: number[];
}
export interface StatsAnkiNotesInfoRequest {
noteIds: number[];
}
export interface StatsOkResponse {
ok: true;
}
export interface StatsErrorResponse {
error: string;
}
export interface StatsAnkiBrowseResponse {
result?: number[] | null;
error?: string | null;
}
export interface StatsJsonResponseMap {
error: StatsErrorResponse | null;
overview: OverviewData;
dailyRollups: DailyRollup[];
monthlyRollups: MonthlyRollup[];
sessions: SessionSummary[];
sessionTimeline: SessionTimelinePoint[];
sessionEvents: SessionEvent[];
sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[];
vocabulary: VocabularyEntry[];
excludedWords: StatsExcludedWord[];
setExcludedWords: StatsOkResponse;
wordOccurrences: VocabularyOccurrenceEntry[];
sentenceSearch: SentenceSearchResult[];
kanji: KanjiEntry[];
kanjiOccurrences: VocabularyOccurrenceEntry[];
wordDetail: WordDetailData;
kanjiDetail: KanjiDetailData;
mediaLibrary: MediaLibraryItem[];
mediaDetail: MediaDetailData;
animeLibrary: AnimeLibraryItem[];
animeDetail: AnimeDetailData;
animeWords: AnimeWord[];
animeRollups: DailyRollup[];
setVideoWatched: StatsOkResponse;
deleteSessions: StatsOkResponse;
deleteSession: StatsOkResponse;
deleteVideo: StatsOkResponse;
anilistSearch: StatsAnilistSearchResult[];
knownWords: string[];
knownWordsSummary: StatsKnownWordsSummary;
animeKnownWordsSummary: StatsKnownWordsSummary;
mediaKnownWordsSummary: StatsKnownWordsSummary;
reassignAnimeAnilist: StatsOkResponse;
coverImages: StatsCoverImagesData;
episodeDetail: EpisodeDetailData;
ankiBrowse: StatsAnkiBrowseResponse;
ankiNotesInfo: StatsAnkiNoteInfo[];
mineCard: StatsMineCardResponse;
streakCalendar: StreakCalendarDay[];
episodesPerDay: EpisodesPerDay[];
newAnimePerDay: NewAnimePerDay[];
watchTimePerAnime: WatchTimePerAnime[];
trendsDashboard: TrendsDashboardData;
}
export function statsJson<Key extends keyof StatsJsonResponseMap>(
_key: Key,
payload: StatsJsonResponseMap[Key],
): StatsJsonResponseMap[Key] {
return payload;
}
export interface StatsHttpClient {
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, eventTypes?: number[]) => Promise<SessionEvent[]>;
getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>;
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
getExcludedWords: () => Promise<StatsExcludedWord[]>;
setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>;
getWordOccurrences: (
headword: string,
word: string,
reading: string,
limit?: number,
offset?: number,
) => Promise<VocabularyOccurrenceEntry[]>;
searchSentences: (
query: string,
limit?: number,
searchByHeadword?: boolean,
) => Promise<SentenceSearchResult[]>;
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, retryToken?: number) => string;
getCoverImages: (params: StatsCoverImagesRequest) => Promise<StatsCoverImagesData>;
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
getTrendsDashboard: (
range: StatsTrendRange,
groupBy: StatsTrendGroupBy,
fillEmpty?: boolean,
) => Promise<TrendsDashboardData>;
getWordDetail: (wordId: number) => Promise<WordDetailData>;
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
setVideoWatched: (videoId: number, watched: boolean) => Promise<void>;
deleteSession: (sessionId: number) => Promise<void>;
deleteSessions: (sessionIds: number[]) => Promise<void>;
deleteVideo: (videoId: number) => Promise<void>;
getKnownWords: () => Promise<string[]>;
getKnownWordsSummary: () => Promise<StatsKnownWordsSummary>;
getAnimeKnownWordsSummary: (animeId: number) => Promise<StatsKnownWordsSummary>;
getMediaKnownWordsSummary: (videoId: number) => Promise<StatsKnownWordsSummary>;
searchAnilist: (query: string) => Promise<StatsAnilistSearchResult[]>;
reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise<void>;
mineCard: (params: StatsMineCardParams) => Promise<StatsMineCardResponse>;
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
}
+430
View File
@@ -0,0 +1,430 @@
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: number;
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;
}>;
}
@@ -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/*"]