From 6fe1e0fee4435dd9123da9002d4d568dde334d11 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 13 Jul 2026 12:09:56 -0700 Subject: [PATCH] refactor(stats): drop dead IPC handlers, unify stats types over HTTP (#164) --- changes/unify-stats-transport-types.md | 4 + docs/architecture/domains.md | 1 + docs/architecture/stats-trends-data-flow.md | 4 + .../services/__tests__/stats-server.test.ts | 2 + .../services/immersion-tracker-service.ts | 2 +- src/core/services/ipc.test.ts | 195 -------- src/core/services/ipc.ts | 158 ------- src/core/services/stats-cover-routes.ts | 15 +- src/core/services/stats-server.ts | 220 +++++---- src/main/appimage-mount-keepalive.test.ts | 13 +- src/preload-stats.ts | 42 -- src/shared/ipc/contracts.ts | 13 - src/stats-transport-architecture.test.ts | 29 ++ src/types/stats-http-contract.ts | 231 ++++++++++ src/types/stats-wire.ts | 430 +++++++++++++++++ .../src/components/trends/TrendsTab.test.tsx | 3 +- stats/src/lib/api-client.ts | 167 +++---- stats/src/lib/ipc-client.ts | 112 ----- stats/src/lib/mining.ts | 26 +- stats/src/types/stats.ts | 432 +----------------- stats/tsconfig.json | 1 - 21 files changed, 917 insertions(+), 1183 deletions(-) create mode 100644 changes/unify-stats-transport-types.md create mode 100644 src/stats-transport-architecture.test.ts create mode 100644 src/types/stats-http-contract.ts create mode 100644 src/types/stats-wire.ts delete mode 100644 stats/src/lib/ipc-client.ts diff --git a/changes/unify-stats-transport-types.md b/changes/unify-stats-transport-types.md new file mode 100644 index 00000000..b6d7c126 --- /dev/null +++ b/changes/unify-stats-transport-types.md @@ -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. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 1da42617..3a5e64de 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -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 diff --git a/docs/architecture/stats-trends-data-flow.md b/docs/architecture/stats-trends-data-flow.md index 93edc584..8c7d27bb 100644 --- a/docs/architecture/stats-trends-data-flow.md +++ b/docs/architecture/stats-trends-data-flow.md @@ -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. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index d51fc060..81e4ab46 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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: [ { diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 85f57f92..4458d74f 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -572,7 +572,7 @@ export class ImmersionTrackerService { range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d', groupBy: 'day' | 'month' = 'day', fillEmptyBuckets = true, - ): Promise { + ) { return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets); } diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 5c6d7b4d..21cb0d8d 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -181,35 +181,6 @@ function createFakeImmersionTracker( ): NonNullable { 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[] = []; diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 78c8374f..78e17c75 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -132,35 +132,6 @@ export interface IpcServiceDeps { ) => Promise; immersionTracker?: { recordYomitanLookup: () => void; - getSessionSummaries: (limit?: number) => Promise; - getDailyRollups: (limit?: number) => Promise; - getMonthlyRollups: (limit?: number) => Promise; - 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; - getSessionEvents: (sessionId: number, limit?: number) => Promise; - getVocabularyStats: (limit?: number) => Promise; - getKanjiStats: (limit?: number) => Promise; - getMediaLibrary: () => Promise; - getMediaDetail: (videoId: number) => Promise; - getMediaSessions: (videoId: number, limit?: number) => Promise; - getMediaDailyRollups: (videoId: number, limit?: number) => Promise; - getCoverArt: (videoId: number) => Promise; markActiveVideoWatched: () => Promise; } | 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; - }); } diff --git a/src/core/services/stats-cover-routes.ts b/src/core/services/stats-cover-routes.ts index 4aec4906..dd6c0f43 100644 --- a/src/core/services/stats-cover-routes.ts +++ b/src/core/services/stats-cover-routes.ts @@ -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>; 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) => { diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 83af29f6..c12cc5e2 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -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; @@ -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(); @@ -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 }>; }; 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) { diff --git a/src/main/appimage-mount-keepalive.test.ts b/src/main/appimage-mount-keepalive.test.ts index 2d86e8e4..ed350f59 100644 --- a/src/main/appimage-mount-keepalive.test.ts +++ b/src/main/appimage-mount-keepalive.test.ts @@ -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, diff --git a/src/preload-stats.ts b/src/preload-stats.ts index 8e2b5a31..d235ba65 100644 --- a/src/preload-stats.ts +++ b/src/preload-stats.ts @@ -2,48 +2,6 @@ import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS } from './shared/ipc/contracts'; const statsAPI = { - getOverview: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview), - - getDailyRollups: (limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit), - - getMonthlyRollups: (limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMonthlyRollups, limit), - - getSessions: (limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessions, limit), - - getSessionTimeline: (sessionId: number, limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionTimeline, sessionId, limit), - - getSessionEvents: (sessionId: number, limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionEvents, sessionId, limit), - - getVocabulary: (limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetVocabulary, limit), - - getKanji: (limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetKanji, limit), - - getMediaLibrary: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaLibrary), - - getMediaDetail: (videoId: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDetail, videoId), - - getMediaSessions: (videoId: number, limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaSessions, videoId, limit), - - getMediaDailyRollups: (videoId: number, limit?: number): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDailyRollups, videoId, limit), - - getMediaCover: (videoId: number): Promise => - 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; }, diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index b00d0f1a..e6e0995b 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -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', diff --git a/src/stats-transport-architecture.test.ts b/src/stats-transport-architecture.test.ts new file mode 100644 index 00000000..f571d222 --- /dev/null +++ b/src/stats-transport-architecture.test.ts @@ -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/); +}); diff --git a/src/types/stats-http-contract.ts b/src/types/stats-http-contract.ts new file mode 100644 index 00000000..7cfc2b75 --- /dev/null +++ b/src/types/stats-http-contract.ts @@ -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: Key, + payload: StatsJsonResponseMap[Key], +): StatsJsonResponseMap[Key] { + return payload; +} + +export interface StatsHttpClient { + getOverview: () => Promise; + getDailyRollups: (limit?: number) => Promise; + getMonthlyRollups: (limit?: number) => Promise; + getSessions: (limit?: number) => Promise; + getSessionTimeline: (id: number, limit?: number) => Promise; + getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise; + getSessionKnownWordsTimeline: (id: number) => Promise; + getVocabulary: (limit?: number) => Promise; + getExcludedWords: () => Promise; + setExcludedWords: (words: StatsExcludedWord[]) => Promise; + getWordOccurrences: ( + headword: string, + word: string, + reading: string, + limit?: number, + offset?: number, + ) => Promise; + searchSentences: ( + query: string, + limit?: number, + searchByHeadword?: boolean, + ) => Promise; + getKanji: (limit?: number) => Promise; + getKanjiOccurrences: ( + kanji: string, + limit?: number, + offset?: number, + ) => Promise; + getMediaLibrary: () => Promise; + getMediaDetail: (videoId: number) => Promise; + getAnimeLibrary: () => Promise; + getAnimeDetail: (animeId: number) => Promise; + getAnimeWords: (animeId: number, limit?: number) => Promise; + getAnimeRollups: (animeId: number, limit?: number) => Promise; + getAnimeCoverUrl: (animeId: number, retryToken?: number) => string; + getCoverImages: (params: StatsCoverImagesRequest) => Promise; + getStreakCalendar: (days?: number) => Promise; + getEpisodesPerDay: (limit?: number) => Promise; + getNewAnimePerDay: (limit?: number) => Promise; + getWatchTimePerAnime: (limit?: number) => Promise; + getTrendsDashboard: ( + range: StatsTrendRange, + groupBy: StatsTrendGroupBy, + fillEmpty?: boolean, + ) => Promise; + getWordDetail: (wordId: number) => Promise; + getKanjiDetail: (kanjiId: number) => Promise; + getEpisodeDetail: (videoId: number) => Promise; + setVideoWatched: (videoId: number, watched: boolean) => Promise; + deleteSession: (sessionId: number) => Promise; + deleteSessions: (sessionIds: number[]) => Promise; + deleteVideo: (videoId: number) => Promise; + getKnownWords: () => Promise; + getKnownWordsSummary: () => Promise; + getAnimeKnownWordsSummary: (animeId: number) => Promise; + getMediaKnownWordsSummary: (videoId: number) => Promise; + searchAnilist: (query: string) => Promise; + reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise; + mineCard: (params: StatsMineCardParams) => Promise; + ankiBrowse: (noteId: number) => Promise; + ankiNotesInfo: (noteIds: number[]) => Promise; +} diff --git a/src/types/stats-wire.ts b/src/types/stats-wire.ts new file mode 100644 index 00000000..55643452 --- /dev/null +++ b/src/types/stats-wire.ts @@ -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; + 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; + media: Record; +} + +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; + }>; +} diff --git a/stats/src/components/trends/TrendsTab.test.tsx b/stats/src/components/trends/TrendsTab.test.tsx index 7cd5a66b..22fb7fbc 100644 --- a/stats/src/components/trends/TrendsTab.test.tsx +++ b/stats/src/components/trends/TrendsTab.test.tsx @@ -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/); diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index a8457c6e..ed64aad6 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -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(path: string): Promise { +async function fetchJson( + _key: Key, + path: string, +): Promise { const res = await fetchResponse(path); - return res.json() as Promise; + return res.json() as Promise; } function uniquePositiveIds(ids: number[]): number[] { @@ -80,14 +70,15 @@ function uniquePositiveIds(ids: number[]): number[] { } export const apiClient = { - getOverview: () => fetchJson('/api/stats/overview'), + getOverview: () => fetchJson('overview', '/api/stats/overview'), getDailyRollups: (limit = 60) => - fetchJson(`/api/stats/daily-rollups?limit=${limit}`), + fetchJson('dailyRollups', `/api/stats/daily-rollups?limit=${limit}`), getMonthlyRollups: (limit = 24) => - fetchJson(`/api/stats/monthly-rollups?limit=${limit}`), - getSessions: (limit = 50) => fetchJson(`/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( + 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(`/api/stats/sessions/${id}/events?${params.toString()}`); + return fetchJson('sessionEvents', `/api/stats/sessions/${id}/events?${params.toString()}`); }, getSessionKnownWordsTimeline: (id: number) => - fetchJson>( - `/api/stats/sessions/${id}/known-words-timeline`, - ), - getVocabulary: (limit = 100) => - fetchJson(`/api/stats/vocabulary?limit=${limit}`), - getExcludedWords: () => fetchJson('/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 => { 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( + 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( + fetchJson( + 'sentenceSearch', `/api/stats/sentences/search?${new URLSearchParams({ q: query, limit: String(limit), headword: String(searchByHeadword), }).toString()}`, ), - getKanji: (limit = 100) => fetchJson(`/api/stats/kanji?limit=${limit}`), + getKanji: (limit = 100) => fetchJson('kanji', `/api/stats/kanji?limit=${limit}`), getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) => - fetchJson( + fetchJson( + 'kanjiOccurrences', `/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`, ), - getMediaLibrary: () => fetchJson('/api/stats/media'), - getMediaDetail: (videoId: number) => fetchJson(`/api/stats/media/${videoId}`), - getAnimeLibrary: () => fetchJson('/api/stats/anime'), - getAnimeDetail: (animeId: number) => fetchJson(`/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(`/api/stats/anime/${animeId}/words?limit=${limit}`), + fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`), getAnimeRollups: (animeId: number, limit = 90) => - fetchJson(`/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 => { + getCoverImages: async (params: StatsCoverImagesRequest): Promise => { 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; + return res.json() as Promise; }, getStreakCalendar: (days = 90) => - fetchJson(`/api/stats/streak-calendar?days=${days}`), + fetchJson('streakCalendar', `/api/stats/streak-calendar?days=${days}`), getEpisodesPerDay: (limit = 90) => - fetchJson(`/api/stats/trends/episodes-per-day?limit=${limit}`), + fetchJson('episodesPerDay', `/api/stats/trends/episodes-per-day?limit=${limit}`), getNewAnimePerDay: (limit = 90) => - fetchJson(`/api/stats/trends/new-anime-per-day?limit=${limit}`), + fetchJson('newAnimePerDay', `/api/stats/trends/new-anime-per-day?limit=${limit}`), getWatchTimePerAnime: (limit = 90) => - fetchJson(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), - getTrendsDashboard: ( - range: '7d' | '30d' | '90d' | '365d' | 'all', - groupBy: 'day' | 'month', - fillEmpty = true, - ) => - fetchJson( + 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(`/api/stats/vocabulary/${wordId}/detail`), + fetchJson('wordDetail', `/api/stats/vocabulary/${wordId}/detail`), getKanjiDetail: (kanjiId: number) => - fetchJson(`/api/stats/kanji/${kanjiId}/detail`), + fetchJson('kanjiDetail', `/api/stats/kanji/${kanjiId}/detail`), getEpisodeDetail: (videoId: number) => - fetchJson(`/api/stats/episode/${videoId}/detail`), + fetchJson('episodeDetail', `/api/stats/episode/${videoId}/detail`), setVideoWatched: async (videoId: number, watched: boolean): Promise => { 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 => { @@ -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 => { await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }); }, - getKnownWords: () => fetchJson('/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 => { + fetchJson('anilistSearch', `/api/stats/anilist/search?q=${encodeURIComponent(query)}`), + reassignAnimeAnilist: async (animeId: number, info: StatsAnilistAssignment): Promise => { 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; diff --git a/stats/src/lib/ipc-client.ts b/stats/src/lib/ipc-client.ts deleted file mode 100644 index ae141037..00000000 --- a/stats/src/lib/ipc-client.ts +++ /dev/null @@ -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; - getDailyRollups: (limit?: number) => Promise; - getMonthlyRollups: (limit?: number) => Promise; - getSessions: (limit?: number) => Promise; - getSessionTimeline: (id: number, limit?: number) => Promise; - getSessionEvents: (id: number, limit?: number) => Promise; - getVocabulary: (limit?: number) => Promise; - getWordOccurrences: ( - headword: string, - word: string, - reading: string, - limit?: number, - offset?: number, - ) => Promise; - getKanji: (limit?: number) => Promise; - getKanjiOccurrences: ( - kanji: string, - limit?: number, - offset?: number, - ) => Promise; - getMediaLibrary: () => Promise; - getMediaDetail: (videoId: number) => Promise; - getAnimeLibrary: () => Promise; - getAnimeDetail: (animeId: number) => Promise; - getAnimeWords: (animeId: number, limit?: number) => Promise; - getAnimeRollups: (animeId: number, limit?: number) => Promise; - getAnimeCoverUrl: (animeId: number) => string; - getStreakCalendar: (days?: number) => Promise; - getEpisodesPerDay: (limit?: number) => Promise; - getNewAnimePerDay: (limit?: number) => Promise; - getWatchTimePerAnime: (limit?: number) => Promise; - getWordDetail: (wordId: number) => Promise; - getKanjiDetail: (kanjiId: number) => Promise; - getEpisodeDetail: (videoId: number) => Promise; - ankiBrowse: (noteId: number) => Promise; - ankiNotesInfo: (noteIds: number[]) => Promise; - 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), -}; diff --git a/stats/src/lib/mining.ts b/stats/src/lib/mining.ts index d296edd0..8edbabbb 100644 --- a/stats/src/lib/mining.ts +++ b/stats/src/lib/mining.ts @@ -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, diff --git a/stats/src/types/stats.ts b/stats/src/types/stats.ts index 68a28187..60243214 100644 --- a/stats/src/types/stats.ts +++ b/stats/src/types/stats.ts @@ -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; - 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; - media: Record; -} - -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'; diff --git a/stats/tsconfig.json b/stats/tsconfig.json index 12255397..4109a76b 100644 --- a/stats/tsconfig.json +++ b/stats/tsconfig.json @@ -9,7 +9,6 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "src", "baseUrl": ".", "paths": { "@/*": ["src/*"]