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` - Runtime-option contracts: `src/types/runtime-options.ts`
- Settings UI contracts: `src/types/settings.ts` - Settings UI contracts: `src/types/settings.ts`
- Session-binding contracts: `src/types/session-bindings.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` - Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics ## Ownership Heuristics
@@ -33,6 +33,10 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
## Contract ## 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. 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. 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: { ratios: {
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }], lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
readingSpeed: [{ label: 'Mar 1', value: 180 }],
}, },
librarySummary: [ librarySummary: [
{ {
@@ -572,7 +572,7 @@ export class ImmersionTrackerService {
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d', range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day', groupBy: 'day' | 'month' = 'day',
fillEmptyBuckets = true, fillEmptyBuckets = true,
): Promise<unknown> { ) {
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets); return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
} }
-195
View File
@@ -181,35 +181,6 @@ function createFakeImmersionTracker(
): NonNullable<IpcServiceDeps['immersionTracker']> { ): NonNullable<IpcServiceDeps['immersionTracker']> {
return { return {
recordYomitanLookup: () => {}, 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, markActiveVideoWatched: async () => false,
...overrides, ...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', () => { test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const saves: unknown[] = []; const saves: unknown[] = [];
-158
View File
@@ -132,35 +132,6 @@ export interface IpcServiceDeps {
) => Promise<PlaylistBrowserMutationResult>; ) => Promise<PlaylistBrowserMutationResult>;
immersionTracker?: { immersionTracker?: {
recordYomitanLookup: () => void; 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>; markActiveVideoWatched: () => Promise<boolean>;
} | null; } | null;
} }
@@ -459,24 +430,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
} }
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void { 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.on(
IPC_CHANNELS.command.setIgnoreMouseEvents, IPC_CHANNELS.command.setIgnoreMouseEvents,
(event: unknown, ignore: unknown, options: unknown = {}) => { (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); 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 { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js'; 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 = { type StatsCoverImagePayload = StatsCoverImage | null;
contentType: string; type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
dataUrl: string;
} | null;
type StatsCoverBatchBody = {
animeIds?: unknown;
videoIds?: unknown;
};
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3; 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) => { 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 { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import { registerStatsCoverRoutes } from './stats-cover-routes.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 { import {
resolveRetimedSecondarySubtitleTextFromSidecar, resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar, resolveSecondarySubtitleTextFromSidecar,
@@ -46,12 +52,6 @@ export type StatsMiningTimingEvent = {
noteId?: number; noteId?: number;
}; };
type StatsExcludedWordPayload = {
headword: string;
word: string;
reading: string;
};
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
if (raw === undefined) return fallback; if (raw === undefined) return fallback;
const n = Number(raw); const n = Number(raw);
@@ -87,12 +87,12 @@ function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
return parsed.length > 0 ? parsed : 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)) { if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
return null; return null;
} }
const words: StatsExcludedWordPayload[] = []; const words: StatsExcludedWord[] = [];
for (const row of (body as { words: unknown[] }).words) { for (const row of (body as { words: unknown[] }).words) {
if (!row || typeof row !== 'object') return null; if (!row || typeof row !== 'object') return null;
const { headword, word, reading } = row as Record<string, unknown>; const { headword, word, reading } = row as Record<string, unknown>;
@@ -320,20 +320,22 @@ function summarizeFilteredWordOccurrences(
return { knownWordsSeen, totalWordsSeen }; return { knownWordsSeen, totalWordsSeen };
} }
async function enrichSessionsWithKnownWordMetrics( async function enrichSessionsWithKnownWordMetrics<
tracker: ImmersionTrackerService, Session extends {
sessions: Array<{
sessionId: number; sessionId: number;
tokensSeen: number; tokensSeen: number;
}>, },
>(
tracker: ImmersionTrackerService,
sessions: Session[],
knownWordsCachePath?: string, knownWordsCachePath?: string,
): Promise< ): Promise<
Array<{ Array<
sessionId: number; Session & {
tokensSeen: number; knownWordsSeen: number;
knownWordsSeen: number; knownWordRate: number;
knownWordRate: number; }
}> >
> { > {
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath); const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
if (!knownWordsSet) { if (!knownWordsSet) {
@@ -603,46 +605,48 @@ export function createStatsApp(
rawSessions, rawSessions,
options?.knownWordCachePath, options?.knownWordCachePath,
); );
return c.json({ sessions, rollups, hints }); return c.json(statsJson('overview', { sessions, rollups, hints }));
}); });
app.get('/api/stats/daily-rollups', async (c) => { app.get('/api/stats/daily-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 60, 500); const limit = parseIntQuery(c.req.query('limit'), 60, 500);
const rollups = await tracker.getDailyRollups(limit); const rollups = await tracker.getDailyRollups(limit);
return c.json(rollups); return c.json(statsJson('dailyRollups', rollups));
}); });
app.get('/api/stats/monthly-rollups', async (c) => { app.get('/api/stats/monthly-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 24, 120); const limit = parseIntQuery(c.req.query('limit'), 24, 120);
const rollups = await tracker.getMonthlyRollups(limit); const rollups = await tracker.getMonthlyRollups(limit);
return c.json(rollups); return c.json(statsJson('monthlyRollups', rollups));
}); });
app.get('/api/stats/streak-calendar', async (c) => { app.get('/api/stats/streak-calendar', async (c) => {
const days = parseIntQuery(c.req.query('days'), 90, 365); 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) => { app.get('/api/stats/trends/episodes-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365); 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) => { app.get('/api/stats/trends/new-anime-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365); 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) => { app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365); 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) => { app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range')); const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy')); const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty')); 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) => { app.get('/api/stats/sessions', async (c) => {
@@ -653,30 +657,30 @@ export function createStatsApp(
rawSessions, rawSessions,
options?.knownWordCachePath, options?.knownWordCachePath,
); );
return c.json(sessions); return c.json(statsJson('sessions', sessions));
}); });
app.get('/api/stats/sessions/:id/timeline', async (c) => { app.get('/api/stats/sessions/:id/timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0); 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 rawLimit = c.req.query('limit');
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000); const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
const timeline = await tracker.getSessionTimeline(id, limit); 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) => { app.get('/api/stats/sessions/:id/events', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0); 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 limit = parseIntQuery(c.req.query('limit'), 500, 1000);
const eventTypes = parseEventTypesQuery(c.req.query('types')); const eventTypes = parseEventTypesQuery(c.req.query('types'));
const events = await tracker.getSessionEvents(id, limit, eventTypes); 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) => { app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0); 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>(); 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) => { app.get('/api/stats/vocabulary', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500); const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean); const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
const vocab = await tracker.getVocabularyStats(limit, excludePos); 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) => { 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) => { app.put('/api/stats/excluded-words', async (c) => {
@@ -739,7 +743,7 @@ export function createStatsApp(
const words = parseExcludedWordsBody(body); const words = parseExcludedWordsBody(body);
if (!words) return c.body(null, 400); if (!words) return c.body(null, 400);
await tracker.replaceStatsExcludedWords(words); await tracker.replaceStatsExcludedWords(words);
return c.json({ ok: true }); return c.json(statsJson('setExcludedWords', { ok: true }));
}); });
app.get('/api/stats/vocabulary/occurrences', async (c) => { app.get('/api/stats/vocabulary/occurrences', async (c) => {
@@ -747,17 +751,17 @@ export function createStatsApp(
const word = (c.req.query('word') ?? '').trim(); const word = (c.req.query('word') ?? '').trim();
const reading = (c.req.query('reading') ?? '').trim(); const reading = (c.req.query('reading') ?? '').trim();
if (!headword || !word) { if (!headword || !word) {
return c.json([], 400); return c.json(statsJson('wordOccurrences', []), 400);
} }
const limit = parseIntQuery(c.req.query('limit'), 50, 500); const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset); 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) => { app.get('/api/stats/sentences/search', async (c) => {
const query = (c.req.query('q') ?? '').trim(); 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 limit = parseIntQuery(c.req.query('limit'), 50, 100);
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true); const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
const searchOptions = await buildSentenceSearchOptions( const searchOptions = await buildSentenceSearchOptions(
@@ -766,24 +770,24 @@ export function createStatsApp(
options?.resolveSentenceSearchHeadwords, options?.resolveSentenceSearchHeadwords,
); );
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions); 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) => { app.get('/api/stats/kanji', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500); const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const kanji = await tracker.getKanjiStats(limit); const kanji = await tracker.getKanjiStats(limit);
return c.json(kanji); return c.json(statsJson('kanji', kanji));
}); });
app.get('/api/stats/kanji/occurrences', async (c) => { app.get('/api/stats/kanji/occurrences', async (c) => {
const kanji = (c.req.query('kanji') ?? '').trim(); const kanji = (c.req.query('kanji') ?? '').trim();
if (!kanji) { if (!kanji) {
return c.json([], 400); return c.json(statsJson('kanjiOccurrences', []), 400);
} }
const limit = parseIntQuery(c.req.query('limit'), 50, 500); const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset); 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) => { app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
@@ -793,7 +797,7 @@ export function createStatsApp(
if (!detail) return c.body(null, 404); if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getWordAnimeAppearances(wordId); const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
const similarWords = await tracker.getSimilarWords(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) => { app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
@@ -803,17 +807,17 @@ export function createStatsApp(
if (!detail) return c.body(null, 404); if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId); const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
const words = await tracker.getKanjiWords(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) => { app.get('/api/stats/media', async (c) => {
const library = await tracker.getMediaLibrary(); const library = await tracker.getMediaLibrary();
return c.json(library); return c.json(statsJson('mediaLibrary', library));
}); });
app.get('/api/stats/media/:videoId', async (c) => { app.get('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0); 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([ const [detail, rawSessions, rollups] = await Promise.all([
tracker.getMediaDetail(videoId), tracker.getMediaDetail(videoId),
tracker.getMediaSessions(videoId, 100), tracker.getMediaSessions(videoId, 100),
@@ -824,12 +828,12 @@ export function createStatsApp(
rawSessions, rawSessions,
options?.knownWordCachePath, options?.knownWordCachePath,
); );
return c.json({ detail, sessions, rollups }); return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
}); });
app.get('/api/stats/anime', async (c) => { app.get('/api/stats/anime', async (c) => {
const rows = await tracker.getAnimeLibrary(); const rows = await tracker.getAnimeLibrary();
return c.json(rows); return c.json(statsJson('animeLibrary', rows));
}); });
app.get('/api/stats/anime/:animeId', async (c) => { app.get('/api/stats/anime/:animeId', async (c) => {
@@ -841,21 +845,21 @@ export function createStatsApp(
tracker.getAnimeEpisodes(animeId), tracker.getAnimeEpisodes(animeId),
tracker.getAnimeAnilistEntries(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) => { app.get('/api/stats/anime/:animeId/words', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0); const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 50, 200); const limit = parseIntQuery(c.req.query('limit'), 50, 200);
if (animeId <= 0) return c.body(null, 400); 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) => { app.get('/api/stats/anime/:animeId/rollups', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0); const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 90, 365); const limit = parseIntQuery(c.req.query('limit'), 90, 365);
if (animeId <= 0) return c.body(null, 400); 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) => { 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 body = await c.req.json().catch(() => null);
const watched = typeof body?.watched === 'boolean' ? body.watched : true; const watched = typeof body?.watched === 'boolean' ? body.watched : true;
await tracker.setVideoWatched(videoId, watched); await tracker.setVideoWatched(videoId, watched);
return c.json({ ok: true }); return c.json(statsJson('setVideoWatched', { ok: true }));
}); });
app.delete('/api/stats/sessions', async (c) => { app.delete('/api/stats/sessions', async (c) => {
@@ -874,26 +878,26 @@ export function createStatsApp(
: []; : [];
if (ids.length === 0) return c.body(null, 400); if (ids.length === 0) return c.body(null, 400);
await tracker.deleteSessions(ids); await tracker.deleteSessions(ids);
return c.json({ ok: true }); return c.json(statsJson('deleteSessions', { ok: true }));
}); });
app.delete('/api/stats/sessions/:sessionId', async (c) => { app.delete('/api/stats/sessions/:sessionId', async (c) => {
const sessionId = parseIntQuery(c.req.param('sessionId'), 0); const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
if (sessionId <= 0) return c.body(null, 400); if (sessionId <= 0) return c.body(null, 400);
await tracker.deleteSession(sessionId); await tracker.deleteSession(sessionId);
return c.json({ ok: true }); return c.json(statsJson('deleteSession', { ok: true }));
}); });
app.delete('/api/stats/media/:videoId', async (c) => { app.delete('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0); const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400); if (videoId <= 0) return c.body(null, 400);
await tracker.deleteVideo(videoId); await tracker.deleteVideo(videoId);
return c.json({ ok: true }); return c.json(statsJson('deleteVideo', { ok: true }));
}); });
app.get('/api/stats/anilist/search', async (c) => { app.get('/api/stats/anilist/search', async (c) => {
const query = (c.req.query('q') ?? '').trim(); const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]); if (!query) return c.json(statsJson('anilistSearch', []));
try { try {
await options?.anilistRateLimiter?.acquire(); await options?.anilistRateLimiter?.acquire();
const res = await fetch('https://graphql.anilist.co', { const res = await fetch('https://graphql.anilist.co', {
@@ -918,44 +922,66 @@ export function createStatsApp(
}); });
options?.anilistRateLimiter?.recordResponse(res.headers); options?.anilistRateLimiter?.recordResponse(res.headers);
if (res.status === 429) { if (res.status === 429) {
return c.json([]); return c.json(statsJson('anilistSearch', []));
} }
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } }; const json = (await res.json()) as {
return c.json(json.data?.Page?.media ?? []); data?: { Page?: { media?: StatsAnilistSearchResult[] } };
};
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
} catch { } catch {
return c.json([]); return c.json(statsJson('anilistSearch', []));
} }
}); });
app.get('/api/stats/known-words', (c) => { app.get('/api/stats/known-words', (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json([]); if (!knownWordsSet) return c.json(statsJson('knownWords', []));
return c.json([...knownWordsSet]); return c.json(statsJson('knownWords', [...knownWordsSet]));
}); });
app.get('/api/stats/known-words-summary', async (c) => { app.get('/api/stats/known-words-summary', async (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath); 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(); 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) => { app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0); 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); 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); 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) => { app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0); 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); 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); 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) => { app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
@@ -964,7 +990,7 @@ export function createStatsApp(
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
if (!body?.anilistId) return c.body(null, 400); if (!body?.anilistId) return c.body(null, 400);
await tracker.reassignAnimeAnilist(animeId, body); await tracker.reassignAnimeAnilist(animeId, body);
return c.json({ ok: true }); return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
}); });
registerStatsCoverRoutes(app, tracker); registerStatsCoverRoutes(app, tracker);
@@ -980,7 +1006,7 @@ export function createStatsApp(
rawSessions, rawSessions,
options?.knownWordCachePath, options?.knownWordCachePath,
); );
return c.json({ sessions, words, cardEvents }); return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
}); });
app.post('/api/stats/anki/browse', async (c) => { app.post('/api/stats/anki/browse', async (c) => {
@@ -998,10 +1024,10 @@ export function createStatsApp(
params: { query: `nid:${noteId}` }, params: { query: `nid:${noteId}` },
}), }),
}); });
const result = await response.json(); const result = (await response.json()) as StatsAnkiBrowseResponse;
return c.json(result); return c.json(statsJson('ankiBrowse', result));
} catch { } 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, (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( const resolvedNoteIds = Array.from(
new Set( new Set(
noteIds.map((noteId) => { noteIds.map((noteId) => {
@@ -1039,13 +1065,16 @@ export function createStatsApp(
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>; result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
}; };
return c.json( return c.json(
(result.result ?? []).map((note) => ({ statsJson(
...note, 'ankiNotesInfo',
preview: buildAnkiNotePreview(note.fields, ankiConfig), (result.result ?? []).map((note) => ({
})), ...note,
preview: buildAnkiNotePreview(note.fields, ankiConfig),
})),
),
); );
} catch { } 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'; const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { 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) { 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)) { if (!existsSync(sourcePath)) {
return c.json({ error: 'File not found' }, 404); return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
} }
const ankiConfig = getAnkiConnectConfig(); const ankiConfig = getAnkiConnectConfig();
if (!ankiConfig) { 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(); const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = ''; let retimedSecondaryText = '';
@@ -1203,7 +1237,7 @@ export function createStatsApp(
if (mode === 'word') { if (mode === 'word') {
if (!options?.addYomitanNote) { 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([ const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
@@ -1219,9 +1253,9 @@ export function createStatsApp(
if (yomitanResult.status === 'rejected' || !yomitanResult.value) { if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
return c.json( return c.json(
{ statsJson('mineCard', {
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`, error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
}, }),
502, 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); const wordFieldName = getConfiguredWordFieldName(ankiConfig);
@@ -1394,7 +1428,9 @@ export function createStatsApp(
if (addNoteResult.status === 'rejected') { if (addNoteResult.status === 'rejected') {
return c.json( 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, 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) { if (options?.staticDir) {
+9 -4
View File
@@ -100,7 +100,7 @@ test(
'#!/bin/sh', '#!/bin/sh',
'if [ "${1:-}" = "--appimage-mount" ]; then', 'if [ "${1:-}" = "--appimage-mount" ]; then',
` echo "${mountDir}"`, ` 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', ' while :; do sleep 0.05; done',
'fi', 'fi',
`date +%s%N > "${resultsDir}/direct-run"`, `date +%s%N > "${resultsDir}/direct-run"`,
@@ -120,15 +120,20 @@ test(
// (the real runtime unmounts on its own after the signal), so poll. // (the real runtime unmounts on its own after the signal), so poll.
const releasedMarker = path.join(resultsDir, 'holder-released'); const releasedMarker = path.join(resultsDir, 'holder-released');
const pollDeadline = Date.now() + 2000; 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)); 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( const appRunExited = Number(
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(), fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
); );
const holderReleased = Number(fs.readFileSync(releasedMarker, 'utf8').trim());
const drainNs = holderReleased - appRunExited; const drainNs = holderReleased - appRunExited;
assert.ok( assert.ok(
drainNs >= 0.8e9, drainNs >= 0.8e9,
-42
View File
@@ -2,48 +2,6 @@ import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts'; import { IPC_CHANNELS } from './shared/ipc/contracts';
const statsAPI = { 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 => { confirmNativeDialog: (message: string): boolean => {
return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true; return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true;
}, },
-13
View File
@@ -101,19 +101,6 @@ export const IPC_CHANNELS = {
animetoshoDownloadFile: 'animetosho:download-file', animetoshoDownloadFile: 'animetosho:download-file',
animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages', animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages',
kikuBuildMergePreview: 'kiku:build-merge-preview', 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', getConfigSettingsSnapshot: 'config:get-settings-snapshot',
saveConfigSettingsPatch: 'config:save-settings-patch', saveConfigSettingsPatch: 'config:save-settings-patch',
openConfigSettingsFile: 'config:open-settings-file', 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 assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test'; import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeVisibilityFilter } from './TrendsTab'; 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 () => { 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.match(source, /title="Words \/ Min"/);
assert.doesNotMatch(source, /Reading Speed/); assert.doesNotMatch(source, /Reading Speed/);
+62 -105
View File
@@ -1,30 +1,17 @@
import type { 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, StatsAnkiNoteInfo,
StatsExcludedWord, StatsExcludedWord,
StatsCoverImagesData, StatsCoverImagesData,
StatsAnilistAssignment,
StatsAnkiNotesInfoRequest,
StatsCoverImagesRequest,
StatsDeleteSessionsRequest,
StatsExcludedWordsRequest,
StatsHttpClient,
StatsJsonResponseMap,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
} from '../types/stats'; } from '../types/stats';
import type { StatsMineCardParams, StatsMineCardResponse } from './mining'; import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry'; import { appendCoverRetryToken } from './cover-retry';
@@ -64,9 +51,12 @@ async function fetchResponse(path: string, init?: RequestInit): Promise<Response
return res; 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); const res = await fetchResponse(path);
return res.json() as Promise<T>; return res.json() as Promise<StatsJsonResponseMap[Key]>;
} }
function uniquePositiveIds(ids: number[]): number[] { function uniquePositiveIds(ids: number[]): number[] {
@@ -80,14 +70,15 @@ function uniquePositiveIds(ids: number[]): number[] {
} }
export const apiClient = { export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'), getOverview: () => fetchJson('overview', '/api/stats/overview'),
getDailyRollups: (limit = 60) => getDailyRollups: (limit = 60) =>
fetchJson<DailyRollup[]>(`/api/stats/daily-rollups?limit=${limit}`), fetchJson('dailyRollups', `/api/stats/daily-rollups?limit=${limit}`),
getMonthlyRollups: (limit = 24) => getMonthlyRollups: (limit = 24) =>
fetchJson<MonthlyRollup[]>(`/api/stats/monthly-rollups?limit=${limit}`), fetchJson('monthlyRollups', `/api/stats/monthly-rollups?limit=${limit}`),
getSessions: (limit = 50) => fetchJson<SessionSummary[]>(`/api/stats/sessions?limit=${limit}`), getSessions: (limit = 50) => fetchJson('sessions', `/api/stats/sessions?limit=${limit}`),
getSessionTimeline: (id: number, limit?: number) => getSessionTimeline: (id: number, limit?: number) =>
fetchJson<SessionTimelinePoint[]>( fetchJson(
'sessionTimeline',
limit === undefined limit === undefined
? `/api/stats/sessions/${id}/timeline` ? `/api/stats/sessions/${id}/timeline`
: `/api/stats/sessions/${id}/timeline?limit=${limit}`, : `/api/stats/sessions/${id}/timeline?limit=${limit}`,
@@ -97,90 +88,84 @@ export const apiClient = {
if (eventTypes && eventTypes.length > 0) { if (eventTypes && eventTypes.length > 0) {
params.set('types', eventTypes.join(',')); 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) => getSessionKnownWordsTimeline: (id: number) =>
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number; totalWordsSeen: number }>>( fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
`/api/stats/sessions/${id}/known-words-timeline`, getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
), getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
getVocabulary: (limit = 100) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
getExcludedWords: () => fetchJson<StatsExcludedWord[]>('/api/stats/excluded-words'),
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => { setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
await fetchResponse('/api/stats/excluded-words', { await fetchResponse('/api/stats/excluded-words', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, 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) => 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}`, `/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
), ),
searchSentences: (query: string, limit = 50, searchByHeadword = true) => searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
fetchJson<SentenceSearchResult[]>( fetchJson(
'sentenceSearch',
`/api/stats/sentences/search?${new URLSearchParams({ `/api/stats/sentences/search?${new URLSearchParams({
q: query, q: query,
limit: String(limit), limit: String(limit),
headword: String(searchByHeadword), headword: String(searchByHeadword),
}).toString()}`, }).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) => getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>( fetchJson(
'kanjiOccurrences',
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`, `/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
), ),
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'), getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'),
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`), getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'), getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'),
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`), getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) => 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) => 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) => getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken), appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
getCoverImages: async (params: { getCoverImages: async (params: StatsCoverImagesRequest): Promise<StatsCoverImagesData> => {
animeIds: number[];
videoIds: number[];
}): Promise<StatsCoverImagesData> => {
const res = await fetchResponse('/api/stats/covers', { const res = await fetchResponse('/api/stats/covers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
animeIds: uniquePositiveIds(params.animeIds), animeIds: uniquePositiveIds(params.animeIds),
videoIds: uniquePositiveIds(params.videoIds), videoIds: uniquePositiveIds(params.videoIds),
}), } satisfies StatsCoverImagesRequest),
}); });
return res.json() as Promise<StatsCoverImagesData>; return res.json() as Promise<StatsJsonResponseMap['coverImages']>;
}, },
getStreakCalendar: (days = 90) => getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`), fetchJson('streakCalendar', `/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) => 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) => 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) => getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), fetchJson('watchTimePerAnime', `/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: ( getTrendsDashboard: (range: StatsTrendRange, groupBy: StatsTrendGroupBy, fillEmpty = true) =>
range: '7d' | '30d' | '90d' | '365d' | 'all', fetchJson(
groupBy: 'day' | 'month', 'trendsDashboard',
fillEmpty = true,
) =>
fetchJson<TrendsDashboardData>(
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`, `/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
), ),
getWordDetail: (wordId: number) => getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`), fetchJson('wordDetail', `/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) => getKanjiDetail: (kanjiId: number) =>
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`), fetchJson('kanjiDetail', `/api/stats/kanji/${kanjiId}/detail`),
getEpisodeDetail: (videoId: number) => 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> => { setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}/watched`, { await fetchResponse(`/api/stats/media/${videoId}/watched`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched }), body: JSON.stringify({ watched } satisfies StatsVideoWatchedRequest),
}); });
}, },
deleteSession: async (sessionId: number): Promise<void> => { deleteSession: async (sessionId: number): Promise<void> => {
@@ -190,49 +175,21 @@ export const apiClient = {
await fetchResponse('/api/stats/sessions', { await fetchResponse('/api/stats/sessions', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds }), body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
}); });
}, },
deleteVideo: async (videoId: number): Promise<void> => { deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }); await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
}, },
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'), getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
'/api/stats/known-words-summary',
),
getAnimeKnownWordsSummary: (animeId: number) => getAnimeKnownWordsSummary: (animeId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>( fetchJson('animeKnownWordsSummary', `/api/stats/anime/${animeId}/known-words-summary`),
`/api/stats/anime/${animeId}/known-words-summary`,
),
getMediaKnownWordsSummary: (videoId: number) => getMediaKnownWordsSummary: (videoId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>( fetchJson('mediaKnownWordsSummary', `/api/stats/media/${videoId}/known-words-summary`),
`/api/stats/media/${videoId}/known-words-summary`,
),
searchAnilist: (query: string) => searchAnilist: (query: string) =>
fetchJson< fetchJson('anilistSearch', `/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
Array<{ reassignAnimeAnilist: async (animeId: number, info: StatsAnilistAssignment): Promise<void> => {
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> => {
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, { await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -254,9 +211,9 @@ export const apiClient = {
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, { const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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}`); if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
return res.json(); 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 type { StatsMineCardParams, StatsMineCardResponse, StatsMineMode } from '../types/stats';
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 function getStatsMineCardUnavailableReason( export function getStatsMineCardUnavailableReason(
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>, result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
+2 -430
View File
@@ -1,430 +1,2 @@
export interface SessionSummary { export * from '../../../src/types/stats-wire';
sessionId: number; export * from '../../../src/types/stats-http-contract';
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;
}>;
}
-1
View File
@@ -9,7 +9,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "src",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]