chore: apply remaining workspace formatting and updates

This commit is contained in:
2026-03-16 01:54:35 -07:00
parent 77c35c770d
commit a9e33618e7
82 changed files with 1530 additions and 736 deletions

View File

@@ -149,7 +149,13 @@ test('stats command launches attached app command with response path', async ()
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(forwarded, [ assert.deepEqual(forwarded, [
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--log-level', 'debug'], [
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--log-level',
'debug',
],
]); ]);
}); });
@@ -187,8 +193,7 @@ test('stats command throws when stats response reports an error', async () => {
const context = createContext(); const context = createContext();
context.args.stats = true; context.args.stats = true;
await assert.rejects( await assert.rejects(async () => {
async () => {
await runStatsCommand(context, { await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test', createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'), joinPath: (...parts) => parts.join('/'),
@@ -199,17 +204,14 @@ test('stats command throws when stats response reports an error', async () => {
}), }),
removeDir: () => {}, removeDir: () => {},
}); });
}, }, /Immersion tracking is disabled in config\./);
/Immersion tracking is disabled in config\./,
);
}); });
test('stats command fails if attached app exits before startup response', async () => { test('stats command fails if attached app exits before startup response', async () => {
const context = createContext(); const context = createContext();
context.args.stats = true; context.args.stats = true;
await assert.rejects( await assert.rejects(async () => {
async () => {
await runStatsCommand(context, { await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test', createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'), joinPath: (...parts) => parts.join('/'),
@@ -220,7 +222,5 @@ test('stats command fails if attached app exits before startup response', async
}, },
removeDir: () => {}, removeDir: () => {},
}); });
}, }, /Stats app exited before startup response \(status 2\)\./);
/Stats app exited before startup response \(status 2\)\./,
);
}); });

View File

@@ -81,12 +81,16 @@ export async function runStatsCommand(
'stats', 'stats',
); );
const startupResult = await Promise.race([ const startupResult = await Promise.race([
deps.waitForStatsResponse(responsePath).then((response) => ({ kind: 'response' as const, response })), deps
.waitForStatsResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })), attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
]); ]);
if (startupResult.kind === 'exit') { if (startupResult.kind === 'exit') {
if (startupResult.status !== 0) { if (startupResult.status !== 0) {
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`); throw new Error(
`Stats app exited before startup response (status ${startupResult.status}).`,
);
} }
const response = await deps.waitForStatsResponse(responsePath); const response = await deps.waitForStatsResponse(responsePath);
if (!response.ok) { if (!response.ok) {

View File

@@ -335,7 +335,10 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
}); });
}); });
test('stats command launches attached app flow and waits for response file', { timeout: 15000 }, () => { test(
'stats command launches attached app flow and waits for response file',
{ timeout: 15000 },
() => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
@@ -380,9 +383,13 @@ exit 0
const result = runLauncher(['stats', '--log-level', 'debug'], env); const result = runLauncher(['stats', '--log-level', 'debug'], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match(fs.readFileSync(capturePath, 'utf8'), /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/); assert.match(
}); fs.readFileSync(capturePath, 'utf8'),
/^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/,
);
}); });
},
);
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => { test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
withTempDir((root) => { withTempDir((root) => {

View File

@@ -15,10 +15,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import Database from 'libsql'; import Database from 'libsql';
const DB_PATH = join( const DB_PATH = join(process.env.HOME ?? '~', '.config/SubMiner/immersion.sqlite');
process.env.HOME ?? '~',
'.config/SubMiner/immersion.sqlite',
);
function parsePositiveNumber(value: unknown): number | null { function parsePositiveNumber(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null; if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;

View File

@@ -1122,7 +1122,9 @@ export class AnkiIntegration {
this.mediaGenerator.cleanup(); this.mediaGenerator.cleanup();
} }
setRecordCardsMinedCallback(callback: ((count: number, noteIds?: number[]) => void) | null): void { setRecordCardsMinedCallback(
callback: ((count: number, noteIds?: number[]) => void) | null,
): void {
this.recordCardsMinedCallback = callback; this.recordCardsMinedCallback = callback;
} }
} }

View File

@@ -218,7 +218,9 @@ export class KnownWordCacheManager {
private getKnownWordDecks(): string[] { private getKnownWordDecks(): string[] {
const configuredDecks = this.deps.getConfig().knownWords?.decks; const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) { if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
return Object.keys(configuredDecks).map((d) => d.trim()).filter((d) => d.length > 0); return Object.keys(configuredDecks)
.map((d) => d.trim())
.filter((d) => d.length > 0);
} }
const deck = this.deps.getConfig().deck?.trim(); const deck = this.deps.getConfig().deck?.trim();

View File

@@ -6,7 +6,10 @@ import { PollingRunner } from './polling';
test('polling runner records newly added cards after initialization', async () => { test('polling runner records newly added cards after initialization', async () => {
const recordedCards: number[] = []; const recordedCards: number[] = [];
let tracked = new Set<number>(); let tracked = new Set<number>();
const responses = [[10, 11], [10, 11, 12, 13]]; const responses = [
[10, 11],
[10, 11, 12, 13],
];
const runner = new PollingRunner({ const runner = new PollingRunner({
getDeck: () => 'Mining', getDeck: () => 'Mining',
getPollingRate: () => 250, getPollingRate: () => 250,

View File

@@ -143,7 +143,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(dictionaryTarget.dictionary, true); assert.equal(dictionaryTarget.dictionary, true);
assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv'); assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv');
const stats = parseArgs(['--stats', '--stats-response-path', '/tmp/subminer-stats-response.json']); const stats = parseArgs([
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-response.json',
]);
assert.equal(stats.stats, true); assert.equal(stats.stats, true);
assert.equal(stats.statsResponsePath, '/tmp/subminer-stats-response.json'); assert.equal(stats.statsResponsePath, '/tmp/subminer-stats-response.json');
assert.equal(hasExplicitCommand(stats), true); assert.equal(hasExplicitCommand(stats), true);

View File

@@ -1528,10 +1528,7 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne); assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
assert.equal( assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
config.ankiConnect.knownWords.color,
DEFAULT_CONFIG.ankiConnect.knownWords.color,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
}); });
@@ -1586,7 +1583,7 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90); assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, { assert.deepEqual(config.ankiConnect.knownWords.decks, {
'Mining': ['Expression', 'Word', 'Reading', 'Word Reading'], Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'], 'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
}); });
assert.equal(config.ankiConnect.knownWords.color, '#a6da95'); assert.equal(config.ankiConnect.knownWords.color, '#a6da95');

View File

@@ -105,7 +105,8 @@ export function buildIntegrationConfigOptionRegistry(
path: 'ankiConnect.knownWords.decks', path: 'ankiConnect.knownWords.decks',
kind: 'object', kind: 'object',
defaultValue: defaultConfig.ankiConnect.knownWords.decks, defaultValue: defaultConfig.ankiConnect.knownWords.decks,
description: 'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.', description:
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
}, },
{ {
path: 'ankiConnect.nPlusOne.nPlusOne', path: 'ankiConnect.nPlusOne.nPlusOne',

View File

@@ -33,7 +33,10 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled, DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
); );
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'), false); assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'),
false,
);
}); });
test('normalizes ankiConnect tags by trimming and deduping', () => { test('normalizes ankiConnect tags by trimming and deduping', () => {
@@ -52,16 +55,19 @@ test('normalizes ankiConnect tags by trimming and deduping', () => {
test('accepts knownWords.decks object format with field arrays', () => { test('accepts knownWords.decks object format with field arrays', () => {
const { context, warnings } = makeContext({ const { context, warnings } = makeContext({
knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], 'Mining': ['Expression'] } }, knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], Mining: ['Expression'] } },
}); });
applyAnkiConnectResolution(context); applyAnkiConnectResolution(context);
assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, { assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, {
'Core Deck': ['Word', 'Reading'], 'Core Deck': ['Word', 'Reading'],
'Mining': ['Expression'], Mining: ['Expression'],
}); });
assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'), false); assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'),
false,
);
}); });
test('converts legacy knownWords.decks array to object with default fields', () => { test('converts legacy knownWords.decks array to object with default fields', () => {

View File

@@ -624,7 +624,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
); );
} }
const knownWordsConfig = isObject(ac.knownWords) ? (ac.knownWords as Record<string, unknown>) : {}; const knownWordsConfig = isObject(ac.knownWords)
? (ac.knownWords as Record<string, unknown>)
: {};
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {}; const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled); const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
@@ -723,8 +725,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) && Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
legacyBehaviorNPlusOneRefreshMinutes > 0; legacyBehaviorNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) { if (hasValidLegacyRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
legacyBehaviorNPlusOneRefreshMinutes;
context.warn( context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes', 'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes, behavior.nPlusOneRefreshMinutes,

View File

@@ -24,13 +24,23 @@ export function applyStatsConfig(context: ResolveContext): void {
if (autoStartServer !== undefined) { if (autoStartServer !== undefined) {
resolved.stats.autoStartServer = autoStartServer; resolved.stats.autoStartServer = autoStartServer;
} else if (src.stats.autoStartServer !== undefined) { } else if (src.stats.autoStartServer !== undefined) {
warn('stats.autoStartServer', src.stats.autoStartServer, resolved.stats.autoStartServer, 'Expected boolean.'); warn(
'stats.autoStartServer',
src.stats.autoStartServer,
resolved.stats.autoStartServer,
'Expected boolean.',
);
} }
const autoOpenBrowser = asBoolean(src.stats.autoOpenBrowser); const autoOpenBrowser = asBoolean(src.stats.autoOpenBrowser);
if (autoOpenBrowser !== undefined) { if (autoOpenBrowser !== undefined) {
resolved.stats.autoOpenBrowser = autoOpenBrowser; resolved.stats.autoOpenBrowser = autoOpenBrowser;
} else if (src.stats.autoOpenBrowser !== undefined) { } else if (src.stats.autoOpenBrowser !== undefined) {
warn('stats.autoOpenBrowser', src.stats.autoOpenBrowser, resolved.stats.autoOpenBrowser, 'Expected boolean.'); warn(
'stats.autoOpenBrowser',
src.stats.autoOpenBrowser,
resolved.stats.autoOpenBrowser,
'Expected boolean.',
);
} }
} }

View File

@@ -76,7 +76,7 @@ export function runGuessit(target: string): Promise<string> {
export interface GuessAnilistMediaInfoDeps { export interface GuessAnilistMediaInfoDeps {
runGuessit: (target: string) => Promise<string>; runGuessit: (target: string) => Promise<string>;
}; }
function firstString(value: unknown): string | null { function firstString(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value === 'string') {

View File

@@ -111,7 +111,8 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
const db = new Database(dbPath); const db = new Database(dbPath);
ensureSchema(db); ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', { const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', {
canonicalTitle: '[Jellyfin] Little Witch Academia S02E05 - 025 - Pact of the Dragon (2020) [1080p].mkv', canonicalTitle:
'[Jellyfin] Little Witch Academia S02E05 - 025 - Pact of the Dragon (2020) [1080p].mkv',
sourcePath: '/tmp/cover-fetcher-season-test.mkv', sourcePath: '/tmp/cover-fetcher-season-test.mkv',
sourceUrl: null, sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL, sourceType: SOURCE_TYPE_LOCAL,
@@ -138,7 +139,11 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
id: 19, id: 19,
episodes: 24, episodes: 24,
coverImage: { large: 'https://images.test/cover.jpg', medium: null }, coverImage: { large: 'https://images.test/cover.jpg', medium: null },
title: { romaji: 'Little Witch Academia', english: 'Little Witch Academia', native: null }, title: {
romaji: 'Little Witch Academia',
english: 'Little Witch Academia',
native: null,
},
}, },
], ],
}, },

View File

@@ -1,7 +1,11 @@
import type { AnilistRateLimiter } from './rate-limiter'; import type { AnilistRateLimiter } from './rate-limiter';
import type { DatabaseSync } from '../immersion-tracker/sqlite'; import type { DatabaseSync } from '../immersion-tracker/sqlite';
import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query'; import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query';
import { guessAnilistMediaInfo, runGuessit, type GuessAnilistMediaInfoDeps } from './anilist-updater'; import {
guessAnilistMediaInfo,
runGuessit,
type GuessAnilistMediaInfoDeps,
} from './anilist-updater';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const NO_MATCH_RETRY_MS = 5 * 60 * 1000; const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
@@ -91,7 +95,10 @@ export function stripFilenameTags(raw: string): string {
} }
function removeSeasonHint(title: string): string { function removeSeasonHint(title: string): string {
return title.replace(/\bseason\s*\d+\b/gi, '').replace(/\s{2,}/g, ' ').trim(); return title
.replace(/\bseason\s*\d+\b/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
} }
function normalizeTitle(text: string): string { function normalizeTitle(text: string): string {
@@ -134,7 +141,8 @@ function pickBestSearchResult(
.map((value) => value.trim()) .map((value) => value.trim())
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); .filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
const filtered = episode === null const filtered =
episode === null
? media ? media
: media.filter((item) => { : media.filter((item) => {
const total = item.episodes; const total = item.episodes;
@@ -146,11 +154,7 @@ function pickBestSearchResult(
} }
const scored = candidates.map((item) => { const scored = candidates.map((item) => {
const candidateTitles = [ const candidateTitles = [item.title?.romaji, item.title?.english, item.title?.native]
item.title?.romaji,
item.title?.english,
item.title?.native,
]
.filter((value): value is string => typeof value === 'string') .filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value)); .map((value) => normalizeTitle(value));
@@ -186,7 +190,11 @@ function pickBestSearchResult(
}); });
const selected = scored[0]!; const selected = scored[0]!;
const selectedTitle = selected.item.title?.english ?? selected.item.title?.romaji ?? selected.item.title?.native ?? title; const selectedTitle =
selected.item.title?.english ??
selected.item.title?.romaji ??
selected.item.title?.native ??
title;
return { id: selected.item.id, title: selectedTitle }; return { id: selected.item.id, title: selectedTitle };
} }
@@ -311,9 +319,7 @@ export function createCoverArtFetcher(
const parsedInfo = await resolveMediaInfo(canonicalTitle); const parsedInfo = await resolveMediaInfo(canonicalTitle);
const searchBase = parsedInfo?.title ?? cleaned; const searchBase = parsedInfo?.title ?? cleaned;
const searchCandidates = parsedInfo const searchCandidates = parsedInfo ? buildSearchCandidates(parsedInfo) : [cleaned];
? buildSearchCandidates(parsedInfo)
: [cleaned];
const effectiveCandidates = searchCandidates.includes(cleaned) const effectiveCandidates = searchCandidates.includes(cleaned)
? searchCandidates ? searchCandidates

View File

@@ -513,8 +513,10 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
ORDER BY v.source_path ORDER BY v.source_path
`, `,
) )
.all('/tmp/Little Witch Academia S02E05.mkv', '/tmp/Little Witch Academia S02E06.mkv') as .all(
Array<{ '/tmp/Little Witch Academia S02E05.mkv',
'/tmp/Little Witch Academia S02E06.mkv',
) as Array<{
source_path: string | null; source_path: string | null;
anime_id: number | null; anime_id: number | null;
parsed_episode: number | null; parsed_episode: number | null;

View File

@@ -337,11 +337,7 @@ export class ImmersionTrackerService {
return getWordOccurrences(this.db, headword, word, reading, limit, offset); return getWordOccurrences(this.db, headword, word, reading, limit, offset);
} }
async getKanjiOccurrences( async getKanjiOccurrences(kanji: string, limit = 100, offset = 0): Promise<KanjiOccurrenceRow[]> {
kanji: string,
limit = 100,
offset = 0,
): Promise<KanjiOccurrenceRow[]> {
return getKanjiOccurrences(this.db, kanji, limit, offset); return getKanjiOccurrences(this.db, kanji, limit, offset);
} }
@@ -413,7 +409,9 @@ export class ImmersionTrackerService {
deleteVideoQuery(this.db, videoId); deleteVideoQuery(this.db, videoId);
} }
async reassignAnimeAnilist(animeId: number, info: { async reassignAnimeAnilist(
animeId: number,
info: {
anilistId: number; anilistId: number;
titleRomaji?: string | null; titleRomaji?: string | null;
titleEnglish?: string | null; titleEnglish?: string | null;
@@ -421,8 +419,11 @@ export class ImmersionTrackerService {
episodesTotal?: number | null; episodesTotal?: number | null;
description?: string | null; description?: string | null;
coverUrl?: string | null; coverUrl?: string | null;
}): Promise<void> { },
this.db.prepare(` ): Promise<void> {
this.db
.prepare(
`
UPDATE imm_anime UPDATE imm_anime
SET anilist_id = ?, SET anilist_id = ?,
title_romaji = COALESCE(?, title_romaji), title_romaji = COALESCE(?, title_romaji),
@@ -432,7 +433,9 @@ export class ImmersionTrackerService {
description = ?, description = ?,
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE anime_id = ? WHERE anime_id = ?
`).run( `,
)
.run(
info.anilistId, info.anilistId,
info.titleRomaji ?? null, info.titleRomaji ?? null,
info.titleEnglish ?? null, info.titleEnglish ?? null,
@@ -445,25 +448,39 @@ export class ImmersionTrackerService {
// Update cover art for all videos in this anime // Update cover art for all videos in this anime
if (info.coverUrl) { if (info.coverUrl) {
const videos = this.db.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?') const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>; .all(animeId) as Array<{ video_id: number }>;
let coverBlob: Buffer | null = null; let coverBlob: Buffer | null = null;
try { try {
const res = await fetch(info.coverUrl); const res = await fetch(info.coverUrl);
if (res.ok) coverBlob = Buffer.from(await res.arrayBuffer()); if (res.ok) coverBlob = Buffer.from(await res.arrayBuffer());
} catch { /* ignore */ } } catch {
/* ignore */
}
for (const v of videos) { for (const v of videos) {
this.db.prepare(` this.db
.prepare(
`
INSERT INTO imm_media_art (video_id, anilist_id, cover_url, cover_blob, title_romaji, title_english, episodes_total, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE) INSERT INTO imm_media_art (video_id, anilist_id, cover_url, cover_blob, title_romaji, title_english, episodes_total, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(video_id) DO UPDATE SET ON CONFLICT(video_id) DO UPDATE SET
anilist_id = excluded.anilist_id, cover_url = excluded.cover_url, cover_blob = COALESCE(excluded.cover_blob, cover_blob), anilist_id = excluded.anilist_id, cover_url = excluded.cover_url, cover_blob = COALESCE(excluded.cover_blob, cover_blob),
title_romaji = excluded.title_romaji, title_english = excluded.title_english, episodes_total = excluded.episodes_total, title_romaji = excluded.title_romaji, title_english = excluded.title_english, episodes_total = excluded.episodes_total,
fetched_at_ms = excluded.fetched_at_ms, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE fetched_at_ms = excluded.fetched_at_ms, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`).run( `,
v.video_id, info.anilistId, info.coverUrl, coverBlob, )
info.titleRomaji ?? null, info.titleEnglish ?? null, info.episodesTotal ?? null, .run(
Date.now(), Date.now(), Date.now(), v.video_id,
info.anilistId,
info.coverUrl,
coverBlob,
info.titleRomaji ?? null,
info.titleEnglish ?? null,
info.episodesTotal ?? null,
Date.now(),
Date.now(),
Date.now(),
); );
} }
} }
@@ -650,11 +667,7 @@ export class ImmersionTrackerService {
if (!headword || !word) { if (!headword || !word) {
continue; continue;
} }
const wordKey = [ const wordKey = [headword, word, reading].join('\u0000');
headword,
word,
reading,
].join('\u0000');
const storedPartOfSpeech = deriveStoredPartOfSpeech({ const storedPartOfSpeech = deriveStoredPartOfSpeech({
partOfSpeech: token.partOfSpeech, partOfSpeech: token.partOfSpeech,
pos1: token.pos1 ?? '', pos1: token.pos1 ?? '',
@@ -729,7 +742,8 @@ export class ImmersionTrackerService {
const durationMs = Math.round(durationSec * 1000); const durationMs = Math.round(durationSec * 1000);
const current = getVideoDurationMs(this.db, this.sessionState.videoId); const current = getVideoDurationMs(this.db, this.sessionState.videoId);
if (current === 0 || Math.abs(current - durationMs) > 1000) { if (current === 0 || Math.abs(current - durationMs) > 1000) {
this.db.prepare('UPDATE imm_videos SET duration_ms = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?') this.db
.prepare('UPDATE imm_videos SET duration_ms = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?')
.run(durationMs, Date.now(), this.sessionState.videoId); .run(durationMs, Date.now(), this.sessionState.videoId);
} }
} }

View File

@@ -149,7 +149,10 @@ test('getLocalVideoMetadata derives title and falls back to null hash on read er
test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => { test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => {
const seenTargets: string[] = []; const seenTargets: string[] = [];
const parsed = await guessAnimeVideoMetadata('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5', { const parsed = await guessAnimeVideoMetadata(
'/tmp/Little Witch Academia S02E05.mkv',
'Episode 5',
{
runGuessit: async (target) => { runGuessit: async (target) => {
seenTargets.push(target); seenTargets.push(target);
return JSON.stringify({ return JSON.stringify({
@@ -158,7 +161,8 @@ test('guessAnimeVideoMetadata uses guessit basename output first when available'
episode: 5, episode: 5,
}); });
}, },
}); },
);
assert.deepEqual(seenTargets, ['Little Witch Academia S02E05.mkv']); assert.deepEqual(seenTargets, ['Little Witch Academia S02E05.mkv']);
assert.deepEqual(parsed, { assert.deepEqual(parsed, {
@@ -176,11 +180,15 @@ test('guessAnimeVideoMetadata uses guessit basename output first when available'
}); });
test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => { test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => {
const parsed = await guessAnimeVideoMetadata('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5', { const parsed = await guessAnimeVideoMetadata(
'/tmp/Little Witch Academia S02E05.mkv',
'Episode 5',
{
runGuessit: async () => { runGuessit: async () => {
throw new Error('guessit unavailable'); throw new Error('guessit unavailable');
}, },
}); },
);
assert.deepEqual(parsed, { assert.deepEqual(parsed, {
parsedBasename: 'Little Witch Academia S02E05.mkv', parsedBasename: 'Little Witch Academia S02E05.mkv',
@@ -199,13 +207,9 @@ test('guessAnimeVideoMetadata falls back to parser when guessit throws', async (
}); });
test('guessAnimeVideoMetadata falls back when guessit output is incomplete', async () => { test('guessAnimeVideoMetadata falls back when guessit output is incomplete', async () => {
const parsed = await guessAnimeVideoMetadata( const parsed = await guessAnimeVideoMetadata('/tmp/[SubsPlease] Frieren - 03 (1080p).mkv', null, {
'/tmp/[SubsPlease] Frieren - 03 (1080p).mkv',
null,
{
runGuessit: async () => JSON.stringify({ episode: 3 }), runGuessit: async () => JSON.stringify({ episode: 3 }),
}, });
);
assert.deepEqual(parsed, { assert.deepEqual(parsed, {
parsedBasename: '[SubsPlease] Frieren - 03 (1080p).mkv', parsedBasename: '[SubsPlease] Frieren - 03 (1080p).mkv',

View File

@@ -133,27 +133,54 @@ export function getQueryHints(db: DatabaseSync): {
const activeSessions = Number((active.get() as { total?: number } | null)?.total ?? 0); const activeSessions = Number((active.get() as { total?: number } | null)?.total ?? 0);
const now = new Date(); const now = new Date();
const todayLocal = Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000); const todayLocal = Math.floor(
const episodesToday = (db.prepare(` new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
);
const episodesToday =
(
db
.prepare(
`
SELECT COUNT(DISTINCT s.video_id) AS count SELECT COUNT(DISTINCT s.video_id) AS count
FROM imm_sessions s FROM imm_sessions s
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
`).get(todayLocal) as { count: number })?.count ?? 0; `,
)
.get(todayLocal) as { count: number }
)?.count ?? 0;
const thirtyDaysAgoMs = Date.now() - 30 * 86400000; const thirtyDaysAgoMs = Date.now() - 30 * 86400000;
const activeAnimeCount = (db.prepare(` const activeAnimeCount =
(
db
.prepare(
`
SELECT COUNT(DISTINCT v.anime_id) AS count SELECT COUNT(DISTINCT v.anime_id) AS count
FROM imm_sessions s FROM imm_sessions s
JOIN imm_videos v ON v.video_id = s.video_id JOIN imm_videos v ON v.video_id = s.video_id
WHERE v.anime_id IS NOT NULL WHERE v.anime_id IS NOT NULL
AND s.started_at_ms >= ? AND s.started_at_ms >= ?
`).get(thirtyDaysAgoMs) as { count: number })?.count ?? 0; `,
)
.get(thirtyDaysAgoMs) as { count: number }
)?.count ?? 0;
const totalEpisodesWatched = (db.prepare(` const totalEpisodesWatched =
(
db
.prepare(
`
SELECT COUNT(*) AS count FROM imm_videos WHERE watched = 1 SELECT COUNT(*) AS count FROM imm_videos WHERE watched = 1
`).get() as { count: number })?.count ?? 0; `,
)
.get() as { count: number }
)?.count ?? 0;
const totalAnimeCompleted = (db.prepare(` const totalAnimeCompleted =
(
db
.prepare(
`
SELECT COUNT(*) AS count FROM ( SELECT COUNT(*) AS count FROM (
SELECT a.anime_id SELECT a.anime_id
FROM imm_anime a FROM imm_anime a
@@ -163,9 +190,19 @@ export function getQueryHints(db: DatabaseSync): {
GROUP BY a.anime_id GROUP BY a.anime_id
HAVING COUNT(DISTINCT CASE WHEN v.watched = 1 THEN v.video_id END) >= MAX(m.episodes_total) HAVING COUNT(DISTINCT CASE WHEN v.watched = 1 THEN v.video_id END) >= MAX(m.episodes_total)
) )
`).get() as { count: number })?.count ?? 0; `,
)
.get() as { count: number }
)?.count ?? 0;
return { totalSessions, activeSessions, episodesToday, activeAnimeCount, totalEpisodesWatched, totalAnimeCompleted }; return {
totalSessions,
activeSessions,
episodesToday,
activeAnimeCount,
totalEpisodesWatched,
totalAnimeCompleted,
};
} }
export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] { export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] {
@@ -420,7 +457,9 @@ export async function cleanupVocabularyStats(
ON CONFLICT(line_id, word_id) DO UPDATE SET ON CONFLICT(line_id, word_id) DO UPDATE SET
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count`, occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count`,
); );
const deleteOccurrencesStmt = db.prepare('DELETE FROM imm_word_line_occurrences WHERE word_id = ?'); const deleteOccurrencesStmt = db.prepare(
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
);
let kept = 0; let kept = 0;
let deleted = 0; let deleted = 0;
let repaired = 0; let repaired = 0;
@@ -434,8 +473,7 @@ export async function cleanupVocabularyStats(
row.word, row.word,
resolvedPos.reading, resolvedPos.reading,
row.id, row.id,
) as ) as {
| {
id: number; id: number;
part_of_speech: string | null; part_of_speech: string | null;
pos1: string | null; pos1: string | null;
@@ -444,8 +482,7 @@ export async function cleanupVocabularyStats(
first_seen: number | null; first_seen: number | null;
last_seen: number | null; last_seen: number | null;
frequency: number | null; frequency: number | null;
} } | null;
| null;
if (duplicate) { if (duplicate) {
moveOccurrencesStmt.run(duplicate.id, row.id); moveOccurrencesStmt.run(duplicate.id, row.id);
deleteOccurrencesStmt.run(row.id); deleteOccurrencesStmt.run(row.id);
@@ -493,7 +530,10 @@ export async function cleanupVocabularyStats(
!normalizePosField(effectiveRow.pos1) && !normalizePosField(effectiveRow.pos1) &&
!normalizePosField(effectiveRow.pos2) && !normalizePosField(effectiveRow.pos2) &&
!normalizePosField(effectiveRow.pos3); !normalizePosField(effectiveRow.pos3);
if (missingPos || shouldExcludeTokenFromVocabularyPersistence(toStoredWordToken(effectiveRow))) { if (
missingPos ||
shouldExcludeTokenFromVocabularyPersistence(toStoredWordToken(effectiveRow))
) {
deleteStmt.run(row.id); deleteStmt.run(row.id);
deleted += 1; deleted += 1;
continue; continue;
@@ -605,7 +645,9 @@ export function getSessionEvents(
} }
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
a.anime_id AS animeId, a.anime_id AS animeId,
a.canonical_title AS canonicalTitle, a.canonical_title AS canonicalTitle,
@@ -631,11 +673,15 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
) sm ON sm.session_id = s.session_id ) sm ON sm.session_id = s.session_id
GROUP BY a.anime_id GROUP BY a.anime_id
ORDER BY totalActiveMs DESC, lastWatchedMs DESC, canonicalTitle ASC ORDER BY totalActiveMs DESC, lastWatchedMs DESC, canonicalTitle ASC
`).all() as unknown as AnimeLibraryRow[]; `,
)
.all() as unknown as AnimeLibraryRow[];
} }
export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null { export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
a.anime_id AS animeId, a.anime_id AS animeId,
a.canonical_title AS canonicalTitle, a.canonical_title AS canonicalTitle,
@@ -670,11 +716,15 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
) sm ON sm.session_id = s.session_id ) sm ON sm.session_id = s.session_id
WHERE a.anime_id = ? WHERE a.anime_id = ?
GROUP BY a.anime_id GROUP BY a.anime_id
`).get(animeId) as unknown as AnimeDetailRow | null; `,
)
.get(animeId) as unknown as AnimeDetailRow | null;
} }
export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): AnimeAnilistEntryRow[] { export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): AnimeAnilistEntryRow[] {
return db.prepare(` return db
.prepare(
`
SELECT DISTINCT SELECT DISTINCT
m.anilist_id AS anilistId, m.anilist_id AS anilistId,
m.title_romaji AS titleRomaji, m.title_romaji AS titleRomaji,
@@ -685,11 +735,15 @@ export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): Anime
WHERE v.anime_id = ? WHERE v.anime_id = ?
AND m.anilist_id IS NOT NULL AND m.anilist_id IS NOT NULL
ORDER BY v.parsed_season ASC ORDER BY v.parsed_season ASC
`).all(animeId) as unknown as AnimeAnilistEntryRow[]; `,
)
.all(animeId) as unknown as AnimeAnilistEntryRow[];
} }
export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] { export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
v.anime_id AS animeId, v.anime_id AS animeId,
v.video_id AS videoId, v.video_id AS videoId,
@@ -723,11 +777,15 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
CASE WHEN v.parsed_episode IS NULL THEN 1 ELSE 0 END, CASE WHEN v.parsed_episode IS NULL THEN 1 ELSE 0 END,
v.parsed_episode ASC, v.parsed_episode ASC,
v.video_id ASC v.video_id ASC
`).all(animeId) as unknown as AnimeEpisodeRow[]; `,
)
.all(animeId) as unknown as AnimeEpisodeRow[];
} }
export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] { export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
v.video_id AS videoId, v.video_id AS videoId,
v.canonical_title AS canonicalTitle, v.canonical_title AS canonicalTitle,
@@ -751,11 +809,15 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id
GROUP BY v.video_id GROUP BY v.video_id
ORDER BY lastWatchedMs DESC ORDER BY lastWatchedMs DESC
`).all() as unknown as MediaLibraryRow[]; `,
)
.all() as unknown as MediaLibraryRow[];
} }
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null { export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
v.video_id AS videoId, v.video_id AS videoId,
v.canonical_title AS canonicalTitle, v.canonical_title AS canonicalTitle,
@@ -782,11 +844,19 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
) sm ON sm.session_id = s.session_id ) sm ON sm.session_id = s.session_id
WHERE v.video_id = ? WHERE v.video_id = ?
GROUP BY v.video_id GROUP BY v.video_id
`).get(videoId) as unknown as MediaDetailRow | null; `,
)
.get(videoId) as unknown as MediaDetailRow | null;
} }
export function getMediaSessions(db: DatabaseSync, videoId: number, limit = 100): SessionSummaryQueryRow[] { export function getMediaSessions(
return db.prepare(` db: DatabaseSync,
videoId: number,
limit = 100,
): SessionSummaryQueryRow[] {
return db
.prepare(
`
SELECT SELECT
s.session_id AS sessionId, s.session_id AS sessionId,
s.video_id AS videoId, s.video_id AS videoId,
@@ -808,11 +878,19 @@ export function getMediaSessions(db: DatabaseSync, videoId: number, limit = 100)
GROUP BY s.session_id GROUP BY s.session_id
ORDER BY s.started_at_ms DESC ORDER BY s.started_at_ms DESC
LIMIT ? LIMIT ?
`).all(videoId, limit) as unknown as SessionSummaryQueryRow[]; `,
)
.all(videoId, limit) as unknown as SessionSummaryQueryRow[];
} }
export function getMediaDailyRollups(db: DatabaseSync, videoId: number, limit = 90): ImmersionSessionRollupRow[] { export function getMediaDailyRollups(
return db.prepare(` db: DatabaseSync,
videoId: number,
limit = 90,
): ImmersionSessionRollupRow[] {
return db
.prepare(
`
SELECT SELECT
rollup_day AS rollupDayOrMonth, rollup_day AS rollupDayOrMonth,
video_id AS videoId, video_id AS videoId,
@@ -829,11 +907,15 @@ export function getMediaDailyRollups(db: DatabaseSync, videoId: number, limit =
WHERE video_id = ? WHERE video_id = ?
ORDER BY rollup_day DESC ORDER BY rollup_day DESC
LIMIT ? LIMIT ?
`).all(videoId, limit) as unknown as ImmersionSessionRollupRow[]; `,
)
.all(videoId, limit) as unknown as ImmersionSessionRollupRow[];
} }
export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null { export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
a.video_id AS videoId, a.video_id AS videoId,
a.anilist_id AS anilistId, a.anilist_id AS anilistId,
@@ -848,11 +930,15 @@ export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow
WHERE v.anime_id = ? WHERE v.anime_id = ?
AND a.cover_blob IS NOT NULL AND a.cover_blob IS NOT NULL
LIMIT 1 LIMIT 1
`).get(animeId) as unknown as MediaArtRow | null; `,
)
.get(animeId) as unknown as MediaArtRow | null;
} }
export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null { export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
video_id AS videoId, video_id AS videoId,
anilist_id AS anilistId, anilist_id AS anilistId,
@@ -864,7 +950,9 @@ export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | nu
fetched_at_ms AS fetchedAtMs fetched_at_ms AS fetchedAtMs
FROM imm_media_art FROM imm_media_art
WHERE video_id = ? WHERE video_id = ?
`).get(videoId) as unknown as MediaArtRow | null; `,
)
.get(videoId) as unknown as MediaArtRow | null;
} }
export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] { export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] {
@@ -872,17 +960,23 @@ export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRo
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const todayLocalDay = Math.floor(localMidnight / 86_400_000); const todayLocalDay = Math.floor(localMidnight / 86_400_000);
const cutoffDay = todayLocalDay - days; const cutoffDay = todayLocalDay - days;
return db.prepare(` return db
.prepare(
`
SELECT rollup_day AS epochDay, SUM(total_active_min) AS totalActiveMin SELECT rollup_day AS epochDay, SUM(total_active_min) AS totalActiveMin
FROM imm_daily_rollups FROM imm_daily_rollups
WHERE rollup_day >= ? WHERE rollup_day >= ?
GROUP BY rollup_day GROUP BY rollup_day
ORDER BY rollup_day ASC ORDER BY rollup_day ASC
`).all(cutoffDay) as StreakCalendarRow[]; `,
)
.all(cutoffDay) as StreakCalendarRow[];
} }
export function getAnimeWords(db: DatabaseSync, animeId: number, limit = 50): AnimeWordRow[] { export function getAnimeWords(db: DatabaseSync, animeId: number, limit = 50): AnimeWordRow[] {
return db.prepare(` return db
.prepare(
`
SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech, SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech,
SUM(o.occurrence_count) AS frequency SUM(o.occurrence_count) AS frequency
FROM imm_word_line_occurrences o FROM imm_word_line_occurrences o
@@ -892,11 +986,19 @@ export function getAnimeWords(db: DatabaseSync, animeId: number, limit = 50): An
GROUP BY w.id GROUP BY w.id
ORDER BY frequency DESC ORDER BY frequency DESC
LIMIT ? LIMIT ?
`).all(animeId, limit) as unknown as AnimeWordRow[]; `,
)
.all(animeId, limit) as unknown as AnimeWordRow[];
} }
export function getAnimeDailyRollups(db: DatabaseSync, animeId: number, limit = 90): ImmersionSessionRollupRow[] { export function getAnimeDailyRollups(
return db.prepare(` db: DatabaseSync,
animeId: number,
limit = 90,
): ImmersionSessionRollupRow[] {
return db
.prepare(
`
SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId, SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId,
r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin, r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin,
r.total_lines_seen AS totalLinesSeen, r.total_words_seen AS totalWordsSeen, r.total_lines_seen AS totalLinesSeen, r.total_words_seen AS totalWordsSeen,
@@ -908,22 +1010,30 @@ export function getAnimeDailyRollups(db: DatabaseSync, animeId: number, limit =
WHERE v.anime_id = ? WHERE v.anime_id = ?
ORDER BY r.rollup_day DESC ORDER BY r.rollup_day DESC
LIMIT ? LIMIT ?
`).all(animeId, limit) as unknown as ImmersionSessionRollupRow[]; `,
)
.all(animeId, limit) as unknown as ImmersionSessionRollupRow[];
} }
export function getEpisodesPerDay(db: DatabaseSync, limit = 90): EpisodesPerDayRow[] { export function getEpisodesPerDay(db: DatabaseSync, limit = 90): EpisodesPerDayRow[] {
return db.prepare(` return db
.prepare(
`
SELECT CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay, SELECT CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay,
COUNT(DISTINCT s.video_id) AS episodeCount COUNT(DISTINCT s.video_id) AS episodeCount
FROM imm_sessions s FROM imm_sessions s
GROUP BY epochDay GROUP BY epochDay
ORDER BY epochDay DESC ORDER BY epochDay DESC
LIMIT ? LIMIT ?
`).all(limit) as EpisodesPerDayRow[]; `,
)
.all(limit) as EpisodesPerDayRow[];
} }
export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayRow[] { export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayRow[] {
return db.prepare(` return db
.prepare(
`
SELECT first_day AS epochDay, COUNT(*) AS newAnimeCount SELECT first_day AS epochDay, COUNT(*) AS newAnimeCount
FROM ( FROM (
SELECT CAST(julianday(MIN(s.started_at_ms) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS first_day SELECT CAST(julianday(MIN(s.started_at_ms) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS first_day
@@ -935,13 +1045,20 @@ export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayR
GROUP BY first_day GROUP BY first_day
ORDER BY first_day DESC ORDER BY first_day DESC
LIMIT ? LIMIT ?
`).all(limit) as NewAnimePerDayRow[]; `,
)
.all(limit) as NewAnimePerDayRow[];
} }
export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePerAnimeRow[] { export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePerAnimeRow[] {
const nowD = new Date(); const nowD = new Date();
const cutoffDay = Math.floor(new Date(nowD.getFullYear(), nowD.getMonth(), nowD.getDate()).getTime() / 86_400_000) - limit; const cutoffDay =
return db.prepare(` Math.floor(
new Date(nowD.getFullYear(), nowD.getMonth(), nowD.getDate()).getTime() / 86_400_000,
) - limit;
return db
.prepare(
`
SELECT r.rollup_day AS epochDay, a.anime_id AS animeId, SELECT r.rollup_day AS epochDay, a.anime_id AS animeId,
a.canonical_title AS animeTitle, a.canonical_title AS animeTitle,
SUM(r.total_active_min) AS totalActiveMin SUM(r.total_active_min) AS totalActiveMin
@@ -951,20 +1068,31 @@ export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePer
WHERE r.rollup_day >= ? WHERE r.rollup_day >= ?
GROUP BY r.rollup_day, a.anime_id GROUP BY r.rollup_day, a.anime_id
ORDER BY r.rollup_day ASC ORDER BY r.rollup_day ASC
`).all(cutoffDay) as WatchTimePerAnimeRow[]; `,
)
.all(cutoffDay) as WatchTimePerAnimeRow[];
} }
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null { export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
return db.prepare(` return db
.prepare(
`
SELECT id AS wordId, headword, word, reading, SELECT id AS wordId, headword, word, reading,
part_of_speech AS partOfSpeech, pos1, pos2, pos3, part_of_speech AS partOfSpeech, pos1, pos2, pos3,
frequency, first_seen AS firstSeen, last_seen AS lastSeen frequency, first_seen AS firstSeen, last_seen AS lastSeen
FROM imm_words WHERE id = ? FROM imm_words WHERE id = ?
`).get(wordId) as WordDetailRow | null; `,
)
.get(wordId) as WordDetailRow | null;
} }
export function getWordAnimeAppearances(db: DatabaseSync, wordId: number): WordAnimeAppearanceRow[] { export function getWordAnimeAppearances(
return db.prepare(` db: DatabaseSync,
wordId: number,
): WordAnimeAppearanceRow[] {
return db
.prepare(
`
SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle, SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
SUM(o.occurrence_count) AS occurrenceCount SUM(o.occurrence_count) AS occurrenceCount
FROM imm_word_line_occurrences o FROM imm_word_line_occurrences o
@@ -973,20 +1101,29 @@ export function getWordAnimeAppearances(db: DatabaseSync, wordId: number): WordA
WHERE o.word_id = ? AND sl.anime_id IS NOT NULL WHERE o.word_id = ? AND sl.anime_id IS NOT NULL
GROUP BY a.anime_id GROUP BY a.anime_id
ORDER BY occurrenceCount DESC ORDER BY occurrenceCount DESC
`).all(wordId) as WordAnimeAppearanceRow[]; `,
)
.all(wordId) as WordAnimeAppearanceRow[];
} }
export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] { export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] {
const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as { headword: string; reading: string } | null; const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as {
headword: string;
reading: string;
} | null;
if (!word) return []; if (!word) return [];
return db.prepare(` return db
.prepare(
`
SELECT id AS wordId, headword, word, reading, frequency SELECT id AS wordId, headword, word, reading, frequency
FROM imm_words FROM imm_words
WHERE id != ? WHERE id != ?
AND (reading = ? OR headword LIKE ? OR headword LIKE ?) AND (reading = ? OR headword LIKE ? OR headword LIKE ?)
ORDER BY frequency DESC ORDER BY frequency DESC
LIMIT ? LIMIT ?
`).all( `,
)
.all(
wordId, wordId,
word.reading, word.reading,
`%${word.headword.charAt(0)}%`, `%${word.headword.charAt(0)}%`,
@@ -996,14 +1133,23 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
} }
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null { export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
return db.prepare(` return db
.prepare(
`
SELECT id AS kanjiId, kanji, frequency, first_seen AS firstSeen, last_seen AS lastSeen SELECT id AS kanjiId, kanji, frequency, first_seen AS firstSeen, last_seen AS lastSeen
FROM imm_kanji WHERE id = ? FROM imm_kanji WHERE id = ?
`).get(kanjiId) as KanjiDetailRow | null; `,
)
.get(kanjiId) as KanjiDetailRow | null;
} }
export function getKanjiAnimeAppearances(db: DatabaseSync, kanjiId: number): KanjiAnimeAppearanceRow[] { export function getKanjiAnimeAppearances(
return db.prepare(` db: DatabaseSync,
kanjiId: number,
): KanjiAnimeAppearanceRow[] {
return db
.prepare(
`
SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle, SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
SUM(o.occurrence_count) AS occurrenceCount SUM(o.occurrence_count) AS occurrenceCount
FROM imm_kanji_line_occurrences o FROM imm_kanji_line_occurrences o
@@ -1012,23 +1158,33 @@ export function getKanjiAnimeAppearances(db: DatabaseSync, kanjiId: number): Kan
WHERE o.kanji_id = ? AND sl.anime_id IS NOT NULL WHERE o.kanji_id = ? AND sl.anime_id IS NOT NULL
GROUP BY a.anime_id GROUP BY a.anime_id
ORDER BY occurrenceCount DESC ORDER BY occurrenceCount DESC
`).all(kanjiId) as KanjiAnimeAppearanceRow[]; `,
)
.all(kanjiId) as KanjiAnimeAppearanceRow[];
} }
export function getKanjiWords(db: DatabaseSync, kanjiId: number, limit = 20): KanjiWordRow[] { export function getKanjiWords(db: DatabaseSync, kanjiId: number, limit = 20): KanjiWordRow[] {
const kanjiRow = db.prepare('SELECT kanji FROM imm_kanji WHERE id = ?').get(kanjiId) as { kanji: string } | null; const kanjiRow = db.prepare('SELECT kanji FROM imm_kanji WHERE id = ?').get(kanjiId) as {
kanji: string;
} | null;
if (!kanjiRow) return []; if (!kanjiRow) return [];
return db.prepare(` return db
.prepare(
`
SELECT id AS wordId, headword, word, reading, frequency SELECT id AS wordId, headword, word, reading, frequency
FROM imm_words FROM imm_words
WHERE headword LIKE ? WHERE headword LIKE ?
ORDER BY frequency DESC ORDER BY frequency DESC
LIMIT ? LIMIT ?
`).all(`%${kanjiRow.kanji}%`, limit) as KanjiWordRow[]; `,
)
.all(`%${kanjiRow.kanji}%`, limit) as KanjiWordRow[];
} }
export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50): AnimeWordRow[] { export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50): AnimeWordRow[] {
return db.prepare(` return db
.prepare(
`
SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech, SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech,
SUM(o.occurrence_count) AS frequency SUM(o.occurrence_count) AS frequency
FROM imm_word_line_occurrences o FROM imm_word_line_occurrences o
@@ -1038,11 +1194,15 @@ export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50):
GROUP BY w.id GROUP BY w.id
ORDER BY frequency DESC ORDER BY frequency DESC
LIMIT ? LIMIT ?
`).all(videoId, limit) as unknown as AnimeWordRow[]; `,
)
.all(videoId, limit) as unknown as AnimeWordRow[];
} }
export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] { export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
return db.prepare(` return db
.prepare(
`
SELECT SELECT
s.session_id AS sessionId, s.video_id AS videoId, s.session_id AS sessionId, s.video_id AS videoId,
v.canonical_title AS canonicalTitle, v.canonical_title AS canonicalTitle,
@@ -1061,11 +1221,15 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
WHERE s.video_id = ? WHERE s.video_id = ?
GROUP BY s.session_id GROUP BY s.session_id
ORDER BY s.started_at_ms DESC ORDER BY s.started_at_ms DESC
`).all(videoId) as SessionSummaryQueryRow[]; `,
)
.all(videoId) as SessionSummaryQueryRow[];
} }
export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] { export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] {
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT e.event_id AS eventId, e.session_id AS sessionId, SELECT e.event_id AS eventId, e.session_id AS sessionId,
e.ts_ms AS tsMs, e.cards_delta AS cardsDelta, e.ts_ms AS tsMs, e.cards_delta AS cardsDelta,
e.payload_json AS payloadJson e.payload_json AS payloadJson
@@ -1073,9 +1237,17 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
JOIN imm_sessions s ON s.session_id = e.session_id JOIN imm_sessions s ON s.session_id = e.session_id
WHERE s.video_id = ? AND e.event_type = 4 WHERE s.video_id = ? AND e.event_type = 4
ORDER BY e.ts_ms DESC ORDER BY e.ts_ms DESC
`).all(videoId) as Array<{ eventId: number; sessionId: number; tsMs: number; cardsDelta: number; payloadJson: string | null }>; `,
)
.all(videoId) as Array<{
eventId: number;
sessionId: number;
tsMs: number;
cardsDelta: number;
payloadJson: string | null;
}>;
return rows.map(row => { return rows.map((row) => {
let noteIds: number[] = []; let noteIds: number[] = [];
if (row.payloadJson) { if (row.payloadJson) {
try { try {
@@ -1083,7 +1255,13 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
if (Array.isArray(parsed.noteIds)) noteIds = parsed.noteIds; if (Array.isArray(parsed.noteIds)) noteIds = parsed.noteIds;
} catch {} } catch {}
} }
return { eventId: row.eventId, sessionId: row.sessionId, tsMs: row.tsMs, cardsDelta: row.cardsDelta, noteIds }; return {
eventId: row.eventId,
sessionId: row.sessionId,
tsMs: row.tsMs,
cardsDelta: row.cardsDelta,
noteIds,
};
}); });
} }
@@ -1100,7 +1278,8 @@ export function upsertCoverArt(
}, },
): void { ): void {
const nowMs = Date.now(); const nowMs = Date.now();
db.prepare(` db.prepare(
`
INSERT INTO imm_media_art ( INSERT INTO imm_media_art (
video_id, anilist_id, cover_url, cover_blob, video_id, anilist_id, cover_url, cover_blob,
title_romaji, title_english, episodes_total, title_romaji, title_english, episodes_total,
@@ -1115,10 +1294,18 @@ export function upsertCoverArt(
episodes_total = excluded.episodes_total, episodes_total = excluded.episodes_total,
fetched_at_ms = excluded.fetched_at_ms, fetched_at_ms = excluded.fetched_at_ms,
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`).run( `,
videoId, art.anilistId, art.coverUrl, art.coverBlob, ).run(
art.titleRomaji, art.titleEnglish, art.episodesTotal, videoId,
nowMs, nowMs, nowMs, art.anilistId,
art.coverUrl,
art.coverBlob,
art.titleRomaji,
art.titleEnglish,
art.episodesTotal,
nowMs,
nowMs,
nowMs,
); );
} }
@@ -1138,7 +1325,8 @@ export function updateAnimeAnilistInfo(
} | null; } | null;
if (!row?.anime_id) return; if (!row?.anime_id) return;
db.prepare(` db.prepare(
`
UPDATE imm_anime UPDATE imm_anime
SET SET
anilist_id = COALESCE(?, anilist_id), anilist_id = COALESCE(?, anilist_id),
@@ -1148,7 +1336,8 @@ export function updateAnimeAnilistInfo(
episodes_total = COALESCE(?, episodes_total), episodes_total = COALESCE(?, episodes_total),
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE anime_id = ? WHERE anime_id = ?
`).run( `,
).run(
info.anilistId, info.anilistId,
info.titleRomaji, info.titleRomaji,
info.titleEnglish, info.titleEnglish,
@@ -1160,8 +1349,11 @@ export function updateAnimeAnilistInfo(
} }
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void { export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?') db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
.run(watched ? 1 : 0, Date.now(), videoId); watched ? 1 : 0,
Date.now(),
videoId,
);
} }
export function getVideoDurationMs(db: DatabaseSync, videoId: number): number { export function getVideoDurationMs(db: DatabaseSync, videoId: number): number {
@@ -1186,7 +1378,9 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
} }
export function deleteVideo(db: DatabaseSync, videoId: number): void { export function deleteVideo(db: DatabaseSync, videoId: number): void {
const sessions = db.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?').all(videoId) as Array<{ session_id: number }>; const sessions = db
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
.all(videoId) as Array<{ session_id: number }>;
for (const s of sessions) { for (const s of sessions) {
deleteSession(db, s.session_id); deleteSession(db, s.session_id);
} }

View File

@@ -425,8 +425,9 @@ test('ensureSchema adds subtitle-line occurrence tables to schema version 6 data
const tableNames = new Set( const tableNames = new Set(
( (
db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`).all() as db
Array<{ name: string }> .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`)
.all() as Array<{ name: string }>
).map((row) => row.name), ).map((row) => row.name),
); );
@@ -731,8 +732,28 @@ test('word upsert replaces legacy other part_of_speech when better POS metadata
ensureSchema(db); ensureSchema(db);
const stmts = createTrackerPreparedStatements(db); const stmts = createTrackerPreparedStatements(db);
stmts.wordUpsertStmt.run('知っている', '知っている', 'しっている', 'other', '動詞', '自立', '', 10, 10); stmts.wordUpsertStmt.run(
stmts.wordUpsertStmt.run('知っている', '知っている', 'っている', 'verb', '動詞', '自立', '', 11, 12); 'っている',
'知っている',
'しっている',
'other',
'動詞',
'自立',
'',
10,
10,
);
stmts.wordUpsertStmt.run(
'知っている',
'知っている',
'しっている',
'verb',
'動詞',
'自立',
'',
11,
12,
);
const row = db const row = db
.prepare('SELECT frequency, part_of_speech, pos1, pos2 FROM imm_words WHERE headword = ?') .prepare('SELECT frequency, part_of_speech, pos1, pos2 FROM imm_words WHERE headword = ?')

View File

@@ -78,11 +78,7 @@ export function normalizeAnimeIdentityKey(title: string): string {
} }
function looksLikeEpisodeOnlyTitle(title: string): boolean { function looksLikeEpisodeOnlyTitle(title: string): boolean {
const normalized = title const normalized = title.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
.normalize('NFKC')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized); return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized);
} }
@@ -757,7 +753,9 @@ export function ensureSchema(db: DatabaseSync): void {
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) { if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
db.exec('DELETE FROM imm_daily_rollups'); db.exec('DELETE FROM imm_daily_rollups');
db.exec('DELETE FROM imm_monthly_rollups'); db.exec('DELETE FROM imm_monthly_rollups');
db.exec(`UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`); db.exec(
`UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`,
);
} }
db.exec(` db.exec(`
@@ -954,7 +952,9 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
return; return;
} }
if (write.kind === 'subtitleLine') { if (write.kind === 'subtitleLine') {
const animeRow = stmts.videoAnimeIdSelectStmt.get(write.videoId) as { anime_id: number | null } | null; const animeRow = stmts.videoAnimeIdSelectStmt.get(write.videoId) as {
anime_id: number | null;
} | null;
const lineResult = stmts.subtitleLineInsertStmt.run( const lineResult = stmts.subtitleLineInsertStmt.run(
write.sessionId, write.sessionId,
null, null,

View File

@@ -29,7 +29,10 @@ export {
} from './startup'; } from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings'; export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer'; export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export { addYomitanNoteViaSearch, clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime'; export {
addYomitanNoteViaSearch,
clearYomitanParserCachesForWindow,
} from './tokenizer/yomitan-parser-runtime';
export { export {
deleteYomitanDictionaryByTitle, deleteYomitanDictionaryByTitle,
getYomitanDictionaryInfo, getYomitanDictionaryInfo,

View File

@@ -313,7 +313,12 @@ test('registerIpcHandlers validates and clamps stats request limits', async () =
calls.push(['monthly', limit]); calls.push(['monthly', limit]);
return []; return [];
}, },
getQueryHints: async () => ({ totalSessions: 0, activeSessions: 0, episodesToday: 0, activeAnimeCount: 0 }), getQueryHints: async () => ({
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
}),
getSessionTimeline: async (sessionId: number, limit = 0) => { getSessionTimeline: async (sessionId: number, limit = 0) => {
calls.push(['timeline', limit, sessionId]); calls.push(['timeline', limit, sessionId]);
return []; return [];

View File

@@ -73,7 +73,12 @@ export interface IpcServiceDeps {
getSessionSummaries: (limit?: number) => Promise<unknown>; getSessionSummaries: (limit?: number) => Promise<unknown>;
getDailyRollups: (limit?: number) => Promise<unknown>; getDailyRollups: (limit?: number) => Promise<unknown>;
getMonthlyRollups: (limit?: number) => Promise<unknown>; getMonthlyRollups: (limit?: number) => Promise<unknown>;
getQueryHints: () => Promise<{ totalSessions: number; activeSessions: number; episodesToday: number; activeAnimeCount: number }>; getQueryHints: () => Promise<{
totalSessions: number;
activeSessions: number;
episodesToday: number;
activeAnimeCount: number;
}>;
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>; getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>; getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
getVocabularyStats: (limit?: number) => Promise<unknown>; getVocabularyStats: (limit?: number) => Promise<unknown>;
@@ -512,13 +517,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.immersionTracker?.getMediaLibrary() ?? []; return deps.immersionTracker?.getMediaLibrary() ?? [];
}); });
ipc.handle( ipc.handle(IPC_CHANNELS.request.statsGetMediaDetail, async (_event, videoId: unknown) => {
IPC_CHANNELS.request.statsGetMediaDetail,
async (_event, videoId: unknown) => {
if (typeof videoId !== 'number') return null; if (typeof videoId !== 'number') return null;
return deps.immersionTracker?.getMediaDetail(videoId) ?? null; return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
}, });
);
ipc.handle( ipc.handle(
IPC_CHANNELS.request.statsGetMediaSessions, IPC_CHANNELS.request.statsGetMediaSessions,
@@ -538,11 +540,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
}, },
); );
ipc.handle( ipc.handle(IPC_CHANNELS.request.statsGetMediaCover, async (_event, videoId: unknown) => {
IPC_CHANNELS.request.statsGetMediaCover,
async (_event, videoId: unknown) => {
if (typeof videoId !== 'number') return null; if (typeof videoId !== 'number') return null;
return deps.immersionTracker?.getCoverArt(videoId) ?? null; return deps.immersionTracker?.getCoverArt(videoId) ?? null;
}, });
);
} }

View File

@@ -128,10 +128,7 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
emitSubtitleTrackListChange: (payload) => state.events.push(payload), emitSubtitleTrackListChange: (payload) => state.events.push(payload),
}); });
await dispatchMpvProtocolMessage( await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps);
{ event: 'property-change', name: 'sid', data: '3' },
deps,
);
await dispatchMpvProtocolMessage( await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] }, { event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
deps, deps,

View File

@@ -51,7 +51,10 @@ function resolveStatsStaticPath(staticDir: string, requestPath: string): string
const decodedPath = decodeURIComponent(normalizedPath); const decodedPath = decodeURIComponent(normalizedPath);
const absoluteStaticDir = resolve(staticDir); const absoluteStaticDir = resolve(staticDir);
const absolutePath = resolve(absoluteStaticDir, decodedPath); const absolutePath = resolve(absoluteStaticDir, decodedPath);
if (absolutePath !== absoluteStaticDir && !absolutePath.startsWith(`${absoluteStaticDir}${sep}`)) { if (
absolutePath !== absoluteStaticDir &&
!absolutePath.startsWith(`${absoluteStaticDir}${sep}`)
) {
return null; return null;
} }
if (!existsSync(absolutePath)) { if (!existsSync(absolutePath)) {
@@ -71,8 +74,7 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
} }
const extension = extname(absolutePath).toLowerCase(); const extension = extname(absolutePath).toLowerCase();
const contentType = const contentType = STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream';
STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream';
const body = readFileSync(absolutePath); const body = readFileSync(absolutePath);
return new Response(body, { return new Response(body, {
headers: { headers: {
@@ -86,7 +88,13 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
export function createStatsApp( export function createStatsApp(
tracker: ImmersionTrackerService, tracker: ImmersionTrackerService,
options?: { staticDir?: string; knownWordCachePath?: string; mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; addYomitanNote?: (word: string) => Promise<number | null> }, options?: {
staticDir?: string;
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
addYomitanNote?: (word: string) => Promise<number | null>;
},
) { ) {
const app = new Hono(); const app = new Hono();
@@ -304,7 +312,7 @@ export function createStatsApp(
variables: { search: query }, variables: { search: query },
}), }),
}); });
const json = await res.json() as { data?: { Page?: { media?: unknown[] } } }; const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
return c.json(json.data?.Page?.media ?? []); return c.json(json.data?.Page?.media ?? []);
} catch { } catch {
return c.json([]); return c.json([]);
@@ -315,9 +323,14 @@ export function createStatsApp(
const cachePath = options?.knownWordCachePath; const cachePath = options?.knownWordCachePath;
if (!cachePath || !existsSync(cachePath)) return c.json([]); if (!cachePath || !existsSync(cachePath)) return c.json([]);
try { try {
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: number; words?: string[] }; const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
version?: number;
words?: string[];
};
if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words); if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words);
} catch { /* ignore */ } } catch {
/* ignore */
}
return c.json([]); return c.json([]);
}); });
@@ -377,7 +390,11 @@ export function createStatsApp(
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({ action: 'guiBrowse', version: 6, params: { query: `nid:${noteId}` } }), body: JSON.stringify({
action: 'guiBrowse',
version: 6,
params: { query: `nid:${noteId}` },
}),
}); });
const result = await response.json(); const result = await response.json();
return c.json(result); return c.json(result);
@@ -401,7 +418,9 @@ export function createStatsApp(
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }), body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }),
}); });
const result = await response.json() as { result?: Array<{ noteId: number; fields: Record<string, { value: string }> }> }; const result = (await response.json()) as {
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
};
return c.json(result.result ?? []); return c.json(result.result ?? []);
} catch { } catch {
return c.json([], 502); return c.json([], 502);
@@ -445,7 +464,10 @@ export function createStatsApp(
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec; const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
const highlightedSentence = word const highlightedSentence = word
? sentence.replace(new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `<b>${word}</b>`) ? sentence.replace(
new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
`<b>${word}</b>`,
)
: sentence; : sentence;
const generateAudio = ankiConfig.media?.generateAudio !== false; const generateAudio = ankiConfig.media?.generateAudio !== false;
@@ -460,12 +482,18 @@ export function createStatsApp(
if (!generateImage) { if (!generateImage) {
imagePromise = Promise.resolve(null); imagePromise = Promise.resolve(null);
} else if (imageType === 'avif') { } else if (imageType === 'avif') {
imagePromise = mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { imagePromise = mediaGen.generateAnimatedImage(
sourcePath,
startSec,
clampedEndSec,
audioPadding,
{
fps: ankiConfig.media?.animatedFps ?? 10, fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight, maxHeight: ankiConfig.media?.animatedMaxHeight,
crf: ankiConfig.media?.animatedCrf ?? 35, crf: ankiConfig.media?.animatedCrf ?? 35,
}); },
);
} else { } else {
const midpointSec = (startSec + clampedEndSec) / 2; const midpointSec = (startSec + clampedEndSec) / 2;
imagePromise = mediaGen.generateScreenshot(sourcePath, midpointSec, { imagePromise = mediaGen.generateScreenshot(sourcePath, midpointSec, {
@@ -491,14 +519,21 @@ export function createStatsApp(
]); ]);
if (yomitanResult.status === 'rejected' || !yomitanResult.value) { if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
return c.json({ error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}` }, 502); return c.json(
{
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
},
502,
);
} }
noteId = yomitanResult.value; noteId = yomitanResult.value;
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected') errors.push(`audio: ${(audioResult.reason as Error).message}`); if (audioResult.status === 'rejected')
if (imageResult.status === 'rejected') errors.push(`image: ${(imageResult.reason as Error).message}`); errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
const mediaFields: Record<string, string> = {}; const mediaFields: Record<string, string> = {};
const timestamp = Date.now(); const timestamp = Date.now();
@@ -566,8 +601,10 @@ export function createStatsApp(
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null; const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected') errors.push(`audio: ${(audioResult.reason as Error).message}`); if (audioResult.status === 'rejected')
if (imageResult.status === 'rejected') errors.push(`image: ${(imageResult.reason as Error).message}`); errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
@@ -684,7 +721,13 @@ export function createStatsApp(
} }
export function startStatsServer(config: StatsServerConfig): { close: () => void } { export function startStatsServer(config: StatsServerConfig): { close: () => void } {
const app = createStatsApp(config.tracker, { staticDir: config.staticDir, knownWordCachePath: config.knownWordCachePath, mpvSocketPath: config.mpvSocketPath, ankiConnectConfig: config.ankiConnectConfig, addYomitanNote: config.addYomitanNote }); const app = createStatsApp(config.tracker, {
staticDir: config.staticDir,
knownWordCachePath: config.knownWordCachePath,
mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig,
addYomitanNote: config.addYomitanNote,
});
const server = serve({ const server = serve({
fetch: app.fetch, fetch: app.fetch,

View File

@@ -16,13 +16,9 @@ function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean
); );
} }
export function shouldHideStatsWindowForInput( export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey: string): boolean {
input: Electron.Input,
toggleKey: string,
): boolean {
return ( return (
(input.type === 'keyDown' && input.key === 'Escape') || (input.type === 'keyDown' && input.key === 'Escape') || isBareToggleKeyInput(input, toggleKey)
isBareToggleKeyInput(input, toggleKey)
); );
} }

View File

@@ -27,13 +27,7 @@ test('parseSrtCues parses basic SRT content', () => {
}); });
test('parseSrtCues handles multi-line subtitle text', () => { test('parseSrtCues handles multi-line subtitle text', () => {
const content = [ const content = ['1', '00:01:00,000 --> 00:01:05,000', 'これは', 'テストです', ''].join('\n');
'1',
'00:01:00,000 --> 00:01:05,000',
'これは',
'テストです',
'',
].join('\n');
const cues = parseSrtCues(content); const cues = parseSrtCues(content);
@@ -42,12 +36,7 @@ test('parseSrtCues handles multi-line subtitle text', () => {
}); });
test('parseSrtCues handles hours in timestamps', () => { test('parseSrtCues handles hours in timestamps', () => {
const content = [ const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
'1',
'01:30:00,000 --> 01:30:05,000',
'テスト',
'',
].join('\n');
const cues = parseSrtCues(content); const cues = parseSrtCues(content);
@@ -56,12 +45,7 @@ test('parseSrtCues handles hours in timestamps', () => {
}); });
test('parseSrtCues handles VTT-style dot separator', () => { test('parseSrtCues handles VTT-style dot separator', () => {
const content = [ const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTスタイル', ''].join('\n');
'1',
'00:00:01.000 --> 00:00:04.000',
'VTTスタイル',
'',
].join('\n');
const cues = parseSrtCues(content); const cues = parseSrtCues(content);
@@ -151,10 +135,7 @@ test('parseAssCues handles \\N line breaks', () => {
}); });
test('parseAssCues returns empty for content without Events section', () => { test('parseAssCues returns empty for content without Events section', () => {
const content = [ const content = ['[Script Info]', 'Title: Test'].join('\n');
'[Script Info]',
'Title: Test',
].join('\n');
assert.deepEqual(parseAssCues(content), []); assert.deepEqual(parseAssCues(content), []);
}); });
@@ -202,12 +183,7 @@ test('parseAssCues respects dynamic field ordering from the Format row', () => {
}); });
test('parseSubtitleCues auto-detects SRT format', () => { test('parseSubtitleCues auto-detects SRT format', () => {
const content = [ const content = ['1', '00:00:01,000 --> 00:00:04,000', 'SRTテスト', ''].join('\n');
'1',
'00:00:01,000 --> 00:00:04,000',
'SRTテスト',
'',
].join('\n');
const cues = parseSubtitleCues(content, 'test.srt'); const cues = parseSubtitleCues(content, 'test.srt');
assert.equal(cues.length, 1); assert.equal(cues.length, 1);
@@ -227,12 +203,7 @@ test('parseSubtitleCues auto-detects ASS format', () => {
}); });
test('parseSubtitleCues auto-detects VTT format', () => { test('parseSubtitleCues auto-detects VTT format', () => {
const content = [ const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTテスト', ''].join('\n');
'1',
'00:00:01.000 --> 00:00:04.000',
'VTTテスト',
'',
].join('\n');
const cues = parseSubtitleCues(content, 'test.vtt'); const cues = parseSubtitleCues(content, 'test.vtt');
assert.equal(cues.length, 1); assert.equal(cues.length, 1);

View File

@@ -34,8 +34,18 @@ export function parseSrtCues(content: string): SubtitleCue[] {
continue; continue;
} }
const startTime = parseTimestamp(timingMatch[1], timingMatch[2]!, timingMatch[3]!, timingMatch[4]!); const startTime = parseTimestamp(
const endTime = parseTimestamp(timingMatch[5], timingMatch[6]!, timingMatch[7]!, timingMatch[8]!); timingMatch[1],
timingMatch[2]!,
timingMatch[3]!,
timingMatch[4]!,
);
const endTime = parseTimestamp(
timingMatch[5],
timingMatch[6]!,
timingMatch[7]!,
timingMatch[8]!,
);
i += 1; i += 1;
const textLines: string[] = []; const textLines: string[] = [];
@@ -144,7 +154,8 @@ export function parseAssCues(content: string): SubtitleCue[] {
} }
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null { function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
const [normalizedSource = source] = (() => { const [normalizedSource = source] =
(() => {
try { try {
return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source; return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source;
} catch { } catch {

View File

@@ -1,9 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import { computePriorityWindow, createSubtitlePrefetchService } from './subtitle-prefetch';
computePriorityWindow,
createSubtitlePrefetchService,
} from './subtitle-prefetch';
import type { SubtitleCue } from './subtitle-cue-parser'; import type { SubtitleCue } from './subtitle-cue-parser';
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
@@ -169,7 +166,9 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
service.stop(); service.stop();
// After seek to 80.0, cues starting after 80.0 (line-17, line-18, line-19) should appear in cached // After seek to 80.0, cues starting after 80.0 (line-17, line-18, line-19) should appear in cached
const hasPostSeekCue = cachedTexts.some((t) => t === 'line-17' || t === 'line-18' || t === 'line-19'); const hasPostSeekCue = cachedTexts.some(
(t) => t === 'line-17' || t === 'line-18' || t === 'line-19',
);
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position'); assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
}); });

View File

@@ -3227,7 +3227,9 @@ test('tokenizeSubtitle excludes merged function/content token from frequency hig
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => { test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'張り切ってんじゃ', '張り切ってんじゃ',
makeDepsFromYomitanTokens([{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }], { makeDepsFromYomitanTokens(
[{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }],
{
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null), getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null),
tokenizeWithMecab: async () => [ tokenizeWithMecab: async () => [
@@ -3272,7 +3274,8 @@ test('tokenizeSubtitle keeps frequency for content-led merged token with trailin
}, },
], ],
getMinSentenceWordsForNPlusOne: () => 1, getMinSentenceWordsForNPlusOne: () => 1,
}), },
),
); );
assert.equal(result.tokens?.length, 1); assert.equal(result.tokens?.length, 1);

View File

@@ -188,7 +188,9 @@ async function filterSubtitleAnnotationTokens(tokens: MergedToken[]): Promise<Me
} }
const annotationStage = await annotationStageModulePromise; const annotationStage = await annotationStageModulePromise;
return tokens.filter((token) => !annotationStage.shouldExcludeTokenFromSubtitleAnnotations(token)); return tokens.filter(
(token) => !annotationStage.shouldExcludeTokenFromSubtitleAnnotations(token),
);
} }
export function createTokenizerDepsRuntime( export function createTokenizerDepsRuntime(
@@ -449,7 +451,11 @@ function buildYomitanFrequencyIndex(
reading, reading,
frequency: rank, frequency: rank,
}; };
appendYomitanFrequencyEntry(byPair, makeYomitanFrequencyPairKey(term, reading), normalizedEntry); appendYomitanFrequencyEntry(
byPair,
makeYomitanFrequencyPairKey(term, reading),
normalizedEntry,
);
appendYomitanFrequencyEntry(byTerm, term, normalizedEntry); appendYomitanFrequencyEntry(byTerm, term, normalizedEntry);
} }
@@ -486,11 +492,15 @@ function getYomitanFrequencyRank(
} }
const reading = const reading =
typeof token.reading === 'string' && token.reading.trim().length > 0 ? token.reading.trim() : null; typeof token.reading === 'string' && token.reading.trim().length > 0
? token.reading.trim()
: null;
const pairEntries = const pairEntries =
frequencyIndex.byPair.get(makeYomitanFrequencyPairKey(normalizedCandidateText, reading)) ?? []; frequencyIndex.byPair.get(makeYomitanFrequencyPairKey(normalizedCandidateText, reading)) ?? [];
const candidateEntries = const candidateEntries =
pairEntries.length > 0 ? pairEntries : (frequencyIndex.byTerm.get(normalizedCandidateText) ?? []); pairEntries.length > 0
? pairEntries
: (frequencyIndex.byTerm.get(normalizedCandidateText) ?? []);
if (candidateEntries.length === 0) { if (candidateEntries.length === 0) {
return null; return null;
} }

View File

@@ -54,7 +54,6 @@ function resolveKnownWordText(
return matchMode === 'surface' ? surface : headword; return matchMode === 'surface' ? surface : headword;
} }
function normalizePos1Tag(pos1: string | undefined): string { function normalizePos1Tag(pos1: string | undefined): string {
return typeof pos1 === 'string' ? pos1.trim() : ''; return typeof pos1 === 'string' ? pos1.trim() : '';
} }
@@ -243,7 +242,6 @@ export function shouldExcludeTokenFromVocabularyPersistence(
); );
} }
function getCachedJlptLevel( function getCachedJlptLevel(
lookupText: string, lookupText: string,
getJlptLevel: (text: string) => JlptLevel | null, getJlptLevel: (text: string) => JlptLevel | null,
@@ -634,9 +632,7 @@ export function annotateTokens(
? filterTokenFrequencyRank(token, pos1Exclusions, pos2Exclusions) ? filterTokenFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined; : undefined;
const jlptLevel = jlptEnabled const jlptLevel = jlptEnabled ? computeTokenJlptLevel(token, deps.getJlptLevel) : undefined;
? computeTokenJlptLevel(token, deps.getJlptLevel)
: undefined;
return { return {
...token, ...token,

View File

@@ -188,7 +188,9 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
const rawFrequency = parsePositiveFrequencyValue(value.frequency); const rawFrequency = parsePositiveFrequencyValue(value.frequency);
const displayValueRaw = value.displayValue; const displayValueRaw = value.displayValue;
const parsedDisplayFrequency = const parsedDisplayFrequency =
displayValueRaw !== null && displayValueRaw !== undefined ? parseDisplayFrequencyValue(displayValueRaw) : null; displayValueRaw !== null && displayValueRaw !== undefined
? parseDisplayFrequencyValue(displayValueRaw)
: null;
const frequency = parsedDisplayFrequency ?? rawFrequency; const frequency = parsedDisplayFrequency ?? rawFrequency;
if (!term || !dictionary || frequency === null) { if (!term || !dictionary || frequency === null) {
return null; return null;

View File

@@ -1135,7 +1135,10 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
subtitlePrefetchInitController.cancelPendingInit(); subtitlePrefetchInitController.cancelPendingInit();
return; return;
} }
await subtitlePrefetchInitController.initSubtitlePrefetch(externalFilename, lastObservedTimePos); await subtitlePrefetchInitController.initSubtitlePrefetch(
externalFilename,
lastObservedTimePos,
);
} catch { } catch {
// Track list query failed; skip subtitle prefetch refresh. // Track list query failed; skip subtitle prefetch refresh.
} }
@@ -2512,11 +2515,17 @@ const ensureStatsServerStarted = (): string => {
getYomitanExt: () => appState.yomitanExt, getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession, getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow, getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (w: BrowserWindow | null) => { appState.yomitanParserWindow = w; }, setYomitanParserWindow: (w: BrowserWindow | null) => {
appState.yomitanParserWindow = w;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (p: Promise<void> | null) => { appState.yomitanParserReadyPromise = p; }, setYomitanParserReadyPromise: (p: Promise<void> | null) => {
appState.yomitanParserReadyPromise = p;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (p: Promise<boolean> | null) => { appState.yomitanParserInitPromise = p; }, setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
appState.yomitanParserInitPromise = p;
},
}; };
const yomitanLogger = createLogger('main:yomitan-stats'); const yomitanLogger = createLogger('main:yomitan-stats');
statsServer = startStatsServer({ statsServer = startStatsServer({
@@ -2528,7 +2537,9 @@ const ensureStatsServerStarted = (): string => {
ankiConnectConfig: getResolvedConfig().ankiConnect, ankiConnectConfig: getResolvedConfig().ankiConnect,
addYomitanNote: async (word: string) => { addYomitanNote: async (word: string) => {
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { forceOverride: true }); await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: true,
});
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
}, },
}); });

View File

@@ -60,6 +60,11 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
assert.deepEqual(first, { title: 'Show', season: null, episode: 1, source: 'guessit' }); assert.deepEqual(first, { title: 'Show', season: null, episode: 1, source: 'guessit' });
assert.deepEqual(second, { title: 'Show', season: null, episode: 1, source: 'guessit' }); assert.deepEqual(second, { title: 'Show', season: null, episode: 1, source: 'guessit' });
assert.equal(calls, 1); assert.equal(calls, 1);
assert.deepEqual(state.mediaGuess, { title: 'Show', season: null, episode: 1, source: 'guessit' }); assert.deepEqual(state.mediaGuess, {
title: 'Show',
season: null,
episode: 1,
source: 'guessit',
});
assert.equal(state.mediaGuessPromise, null); assert.equal(state.mediaGuessPromise, null);
}); });

View File

@@ -85,7 +85,11 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
deps.resetTrackedMedia('media'); deps.resetTrackedMedia('media');
assert.equal(deps.getWatchedSeconds(), 100); assert.equal(deps.getWatchedSeconds(), 100);
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120); assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', season: null, episode: 1 }); assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
title: 'x',
season: null,
episode: 1,
});
assert.equal(deps.hasAttemptedUpdateKey('k'), false); assert.equal(deps.hasAttemptedUpdateKey('k'), false);
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' }); assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); assert.equal(await deps.refreshAnilistClientSecretState(), 'token');

View File

@@ -145,7 +145,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
}, },
reportJellyfinRemoteProgress: (forceImmediate: boolean) => reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined, onTimePosUpdate: deps.onTimePosUpdate
? (time: number) => deps.onTimePosUpdate!(time)
: undefined,
recordPauseState: (paused: boolean) => { recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused; deps.appState.playbackPaused = paused;
deps.ensureImmersionTrackerInitialized(); deps.ensureImmersionTrackerInitialized();

View File

@@ -2,9 +2,14 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createRunStatsCliCommandHandler } from './stats-cli-command'; import { createRunStatsCliCommandHandler } from './stats-cli-command';
function makeHandler(overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {}) { function makeHandler(
overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {},
) {
const calls: string[] = []; const calls: string[] = [];
const responses: Array<{ responsePath: string; payload: { ok: boolean; url?: string; error?: string } }> = []; const responses: Array<{
responsePath: string;
payload: { ok: boolean; url?: string; error?: string };
}> = [];
const handler = createRunStatsCliCommandHandler({ const handler = createRunStatsCliCommandHandler({
getResolvedConfig: () => ({ getResolvedConfig: () => ({

View File

@@ -31,7 +31,9 @@ export function createRunStatsCliCommandHandler(deps: {
getResolvedConfig: () => StatsCliConfig; getResolvedConfig: () => StatsCliConfig;
ensureImmersionTrackerStarted: () => void; ensureImmersionTrackerStarted: () => void;
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void; ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
getImmersionTracker: () => { cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary> } | null; getImmersionTracker: () => {
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
} | null;
ensureStatsServerStarted: () => string; ensureStatsServerStarted: () => string;
openExternal: (url: string) => Promise<unknown>; openExternal: (url: string) => Promise<unknown>;
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void; writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;

View File

@@ -27,13 +27,16 @@ test('getActiveExternalSubtitleSource returns null when the selected track is no
}); });
test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => { test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => {
const fileUrl = process.platform === 'win32' const fileUrl =
process.platform === 'win32'
? 'file:///C:/Users/test/Sub%20Folder/subs.ass' ? 'file:///C:/Users/test/Sub%20Folder/subs.ass'
: 'file:///tmp/Sub%20Folder/subs.ass'; : 'file:///tmp/Sub%20Folder/subs.ass';
const resolved = resolveSubtitleSourcePath(fileUrl); const resolved = resolveSubtitleSourcePath(fileUrl);
assert.ok(resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass')); assert.ok(
resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass'),
);
}); });
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => { test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {

View File

@@ -2,8 +2,7 @@ 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> => getOverview: (): Promise<unknown> => ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
getDailyRollups: (limit?: number): Promise<unknown> => getDailyRollups: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit), ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit),

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@@ -44,12 +44,24 @@ export function App() {
</header> </header>
<main className="flex-1 overflow-y-auto p-4"> <main className="flex-1 overflow-y-auto p-4">
{activeTab === 'overview' ? ( {activeTab === 'overview' ? (
<section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" key="overview" className="animate-fade-in"> <section
id="panel-overview"
role="tabpanel"
aria-labelledby="tab-overview"
key="overview"
className="animate-fade-in"
>
<OverviewTab /> <OverviewTab />
</section> </section>
) : null} ) : null}
{activeTab === 'anime' ? ( {activeTab === 'anime' ? (
<section id="panel-anime" role="tabpanel" aria-labelledby="tab-anime" key="anime" className="animate-fade-in"> <section
id="panel-anime"
role="tabpanel"
aria-labelledby="tab-anime"
key="anime"
className="animate-fade-in"
>
<AnimeTab <AnimeTab
initialAnimeId={selectedAnimeId} initialAnimeId={selectedAnimeId}
onClearInitialAnime={() => setSelectedAnimeId(null)} onClearInitialAnime={() => setSelectedAnimeId(null)}
@@ -58,12 +70,24 @@ export function App() {
</section> </section>
) : null} ) : null}
{activeTab === 'trends' ? ( {activeTab === 'trends' ? (
<section id="panel-trends" role="tabpanel" aria-labelledby="tab-trends" key="trends" className="animate-fade-in"> <section
id="panel-trends"
role="tabpanel"
aria-labelledby="tab-trends"
key="trends"
className="animate-fade-in"
>
<TrendsTab /> <TrendsTab />
</section> </section>
) : null} ) : null}
{activeTab === 'vocabulary' ? ( {activeTab === 'vocabulary' ? (
<section id="panel-vocabulary" role="tabpanel" aria-labelledby="tab-vocabulary" key="vocabulary" className="animate-fade-in"> <section
id="panel-vocabulary"
role="tabpanel"
aria-labelledby="tab-vocabulary"
key="vocabulary"
className="animate-fade-in"
>
<VocabularyTab <VocabularyTab
onNavigateToAnime={navigateToAnime} onNavigateToAnime={navigateToAnime}
onOpenWordDetail={openWordDetail} onOpenWordDetail={openWordDetail}
@@ -75,7 +99,13 @@ export function App() {
</section> </section>
) : null} ) : null}
{activeTab === 'sessions' ? ( {activeTab === 'sessions' ? (
<section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" key="sessions" className="animate-fade-in"> <section
id="panel-sessions"
role="tabpanel"
aria-labelledby="tab-sessions"
key="sessions"
className="animate-fade-in"
>
<SessionsTab /> <SessionsTab />
</section> </section>
) : null} ) : null}

View File

@@ -18,7 +18,12 @@ interface AnilistSelectorProps {
onLinked: () => void; onLinked: () => void;
} }
export function AnilistSelector({ animeId, initialQuery, onClose, onLinked }: AnilistSelectorProps) { export function AnilistSelector({
animeId,
initialQuery,
onClose,
onLinked,
}: AnilistSelectorProps) {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<AnilistMedia[]>([]); const [results, setResults] = useState<AnilistMedia[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -32,7 +37,10 @@ export function AnilistSelector({ animeId, initialQuery, onClose, onLinked }: An
}, []); }, []);
const doSearch = async (q: string) => { const doSearch = async (q: string) => {
if (!q.trim()) { setResults([]); return; } if (!q.trim()) {
setResults([]);
return;
}
setLoading(true); setLoading(true);
try { try {
const data = await apiClient.searchAnilist(q.trim()); const data = await apiClient.searchAnilist(q.trim());

View File

@@ -37,7 +37,9 @@ export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
{withCards.map((ep) => ( {withCards.map((ep) => (
<Fragment key={ep.videoId}> <Fragment key={ep.videoId}>
<tr <tr
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)} onClick={() =>
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors" className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
> >
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6"> <td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">

View File

@@ -13,7 +13,9 @@ export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverIm
if (failed) { if (failed) {
return ( return (
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}> <div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar} {fallbackChar}
</div> </div>
); );

View File

@@ -32,9 +32,15 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
let cancelled = false; let cancelled = false;
getStatsClient() getStatsClient()
.getAnimeRollups(animeId, 90) .getAnimeRollups(animeId, 90)
.then((data) => { if (!cancelled) setRollups(data); }) .then((data) => {
.catch(() => { if (!cancelled) setRollups([]); }); if (!cancelled) setRollups(data);
return () => { cancelled = true; }; })
.catch(() => {
if (!cancelled) setRollups([]);
});
return () => {
cancelled = true;
};
}, [animeId]); }, [animeId]);
const byDay = new Map<number, number>(); const byDay = new Map<number, number>();
@@ -75,8 +81,18 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
</div> </div>
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}> <BarChart data={chartData}>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} /> <XAxis
<YAxis tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} width={30} /> dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
/>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
background: CHART_THEME.tooltipBg, background: CHART_THEME.tooltipBg,
@@ -104,9 +120,8 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>; if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data; const { detail, episodes, anilistEntries } = data;
const avgSessionMs = detail.totalSessions > 0 const avgSessionMs =
? Math.round(detail.totalActiveMs / detail.totalSessions) detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
: 0;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -123,9 +138,17 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
onChangeAnilist={() => setShowAnilistSelector(true)} onChangeAnilist={() => setShowAnilistSelector(true)}
/> />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
<StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" /> <StatCard
label="Watch Time"
value={formatDuration(detail.totalActiveMs)}
color="text-ctp-blue"
/>
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" /> <StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
<StatCard label="Words" value={formatNumber(detail.totalWordsSeen)} color="text-ctp-mauve" /> <StatCard
label="Words"
value={formatNumber(detail.totalWordsSeen)}
color="text-ctp-mauve"
/>
<StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" /> <StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" />
<StatCard label="Avg Session" value={formatDuration(avgSessionMs)} /> <StatCard label="Avg Session" value={formatDuration(avgSessionMs)} />
</div> </div>

View File

@@ -8,9 +8,10 @@ interface AnimeHeaderProps {
} }
function AnilistButton({ entry }: { entry: AnilistEntry }) { function AnilistButton({ entry }: { entry: AnilistEntry }) {
const label = entry.season != null const label =
entry.season != null
? `Season ${entry.season}` ? `Season ${entry.season}`
: entry.titleEnglish ?? entry.titleRomaji ?? 'AniList'; : (entry.titleEnglish ?? entry.titleRomaji ?? 'AniList');
return ( return (
<a <a
@@ -26,8 +27,9 @@ function AnilistButton({ entry }: { entry: AnilistEntry }) {
} }
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) { export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative] const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
.filter((t): t is string => t != null && t !== detail.canonicalTitle); (t): t is string => t != null && t !== detail.canonicalTitle,
);
const uniqueAltTitles = [...new Set(altTitles)]; const uniqueAltTitles = [...new Set(altTitles)];
const hasMultipleEntries = anilistEntries.length > 1; const hasMultipleEntries = anilistEntries.length > 1;
@@ -52,9 +54,7 @@ export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHe
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{anilistEntries.length > 0 ? ( {anilistEntries.length > 0 ? (
hasMultipleEntries ? ( hasMultipleEntries ? (
anilistEntries.map((entry) => ( anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
<AnilistButton key={entry.anilistId} entry={entry} />
))
) : ( ) : (
<a <a
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`} href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
@@ -82,7 +82,9 @@ export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHe
title="Search AniList and manually select the correct anime entry" title="Search AniList and manually select the correct anime entry"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
> >
{anilistEntries.length > 0 || detail.anilistId ? 'Change AniList Entry' : 'Link to AniList'} {anilistEntries.length > 0 || detail.anilistId
? 'Change AniList Entry'
: 'Link to AniList'}
</button> </button>
)} )}
</div> </div>

View File

@@ -23,10 +23,14 @@ const SORT_OPTIONS: { key: SortKey; label: string }[] = [
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) { function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
return [...list].sort((a, b) => { return [...list].sort((a, b) => {
switch (key) { switch (key) {
case 'lastWatched': return b.lastWatchedMs - a.lastWatchedMs; case 'lastWatched':
case 'watchTime': return b.totalActiveMs - a.totalActiveMs; return b.lastWatchedMs - a.lastWatchedMs;
case 'cards': return b.totalCards - a.totalCards; case 'watchTime':
case 'episodes': return b.episodeCount - a.episodeCount; return b.totalActiveMs - a.totalActiveMs;
case 'cards':
return b.totalCards - a.totalCards;
case 'episodes':
return b.episodeCount - a.episodeCount;
} }
}); });
} }
@@ -89,7 +93,9 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue" className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
> >
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
<option key={opt.key} value={opt.key}>{opt.label}</option> <option key={opt.key} value={opt.key}>
{opt.label}
</option>
))} ))}
</select> </select>
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0"> <div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">

View File

@@ -18,10 +18,18 @@ export function AnimeWordList({ animeId, onNavigateToWord }: AnimeWordListProps)
setLoading(true); setLoading(true);
getStatsClient() getStatsClient()
.getAnimeWords(animeId, 50) .getAnimeWords(animeId, 50)
.then((data) => { if (!cancelled) setWords(data); }) .then((data) => {
.catch(() => { if (!cancelled) setWords([]); }) if (!cancelled) setWords(data);
.finally(() => { if (!cancelled) setLoading(false); }); })
return () => { cancelled = true; }; .catch(() => {
if (!cancelled) setWords([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [animeId]); }, [animeId]);
if (loading) return <div className="text-ctp-overlay2 text-sm p-4">Loading words...</div>; if (loading) return <div className="text-ctp-overlay2 text-sm p-4">Loading words...</div>;

View File

@@ -6,7 +6,11 @@ interface CollapsibleSectionProps {
children: React.ReactNode; children: React.ReactNode;
} }
export function CollapsibleSection({ title, defaultOpen = true, children }: CollapsibleSectionProps) { export function CollapsibleSection({
title,
defaultOpen = true,
children,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const contentId = useId(); const contentId = useId();
@@ -20,9 +24,15 @@ export function CollapsibleSection({ title, defaultOpen = true, children }: Coll
className="w-full flex items-center justify-between p-4 text-left" className="w-full flex items-center justify-between p-4 text-left"
> >
<h3 className="text-sm font-semibold text-ctp-text">{title}</h3> <h3 className="text-sm font-semibold text-ctp-text">{title}</h3>
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">{open ? '▲' : '▼'}</span> <span className="text-ctp-overlay2 text-xs" aria-hidden="true">
{open ? '▲' : '▼'}
</span>
</button> </button>
{open && <div id={contentId} className="px-4 pb-4">{children}</div>} {open && (
<div id={contentId} className="px-4 pb-4">
{children}
</div>
)}
</div> </div>
); );
} }

View File

@@ -23,19 +23,28 @@ const COLOR_TO_BORDER: Record<string, string> = {
'text-ctp-text': 'border-l-ctp-surface2', 'text-ctp-text': 'border-l-ctp-surface2',
}; };
export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) { export function StatCard({
label,
value,
subValue,
color = 'text-ctp-text',
trend,
}: StatCardProps) {
const borderClass = COLOR_TO_BORDER[color] ?? 'border-l-ctp-surface2'; const borderClass = COLOR_TO_BORDER[color] ?? 'border-l-ctp-surface2';
return ( return (
<div className={`bg-ctp-surface0 border border-ctp-surface1 border-l-[3px] ${borderClass} rounded-lg p-4 text-center`}> <div
className={`bg-ctp-surface0 border border-ctp-surface1 border-l-[3px] ${borderClass} rounded-lg p-4 text-center`}
>
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>{value}</div> <div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>{value}</div>
<div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div> <div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div>
{subValue && ( {subValue && <div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>}
<div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>
)}
{trend && ( {trend && (
<div className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}> <div
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'} {trend.text} className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}
>
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'}{' '}
{trend.text}
</div> </div>
)} )}
</div> </div>

View File

@@ -13,7 +13,9 @@ export function CoverImage({ videoId, title, className = '' }: CoverImageProps)
if (failed) { if (failed) {
return ( return (
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}> <div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar} {fallbackChar}
</div> </div>
); );

View File

@@ -7,12 +7,10 @@ interface MediaHeaderProps {
} }
export function MediaHeader({ detail }: MediaHeaderProps) { export function MediaHeader({ detail }: MediaHeaderProps) {
const hitRate = detail.totalLookupCount > 0 const hitRate =
? detail.totalLookupHits / detail.totalLookupCount detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
: null; const avgSessionMs =
const avgSessionMs = detail.totalSessions > 0 detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
? Math.round(detail.totalActiveMs / detail.totalSessions)
: 0;
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">

View File

@@ -58,8 +58,18 @@ export function MediaWatchChart({ rollups }: MediaWatchChartProps) {
</div> </div>
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}> <BarChart data={chartData}>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} /> <XAxis
<YAxis tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} width={30} /> dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
/>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
background: CHART_THEME.tooltipBg, background: CHART_THEME.tooltipBg,

View File

@@ -10,9 +10,7 @@ interface HeroStatsProps {
export function HeroStats({ summary, sessions }: HeroStatsProps) { export function HeroStats({ summary, sessions }: HeroStatsProps) {
const today = todayLocalDay(); const today = todayLocalDay();
const sessionsToday = sessions.filter( const sessionsToday = sessions.filter((s) => localDayFromMs(s.startedAtMs) === today).length;
(s) => localDayFromMs(s.startedAtMs) === today,
).length;
return ( return (
<div className="grid grid-cols-2 xl:grid-cols-6 gap-3"> <div className="grid grid-cols-2 xl:grid-cols-6 gap-3">
@@ -36,11 +34,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
value={formatNumber(summary.episodesToday)} value={formatNumber(summary.episodesToday)}
color="text-ctp-teal" color="text-ctp-teal"
/> />
<StatCard <StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
label="Current Streak"
value={`${summary.streakDays}d`}
color="text-ctp-peach"
/>
<StatCard <StatCard
label="Active Anime" label="Active Anime"
value={formatNumber(summary.activeAnimeCount)} value={formatNumber(summary.activeAnimeCount)}

View File

@@ -37,7 +37,8 @@ export function OverviewTab() {
<h3 className="text-sm font-semibold text-ctp-text mb-3">Tracking Snapshot</h3> <h3 className="text-sm font-semibold text-ctp-text mb-3">Tracking Snapshot</h3>
{showTrackedCardNote && ( {showTrackedCardNote && (
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0"> <div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
No tracked card-add events in the current immersion DB yet. New cards mined after this fix will show here. No tracked card-add events in the current immersion DB yet. New cards mined after this
fix will show here.
</div> </div>
)} )}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm"> <div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm">
@@ -72,7 +73,9 @@ export function OverviewTab() {
</div> </div>
</div> </div>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Completed</div> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Episodes Completed
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue"> <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)} {formatNumber(summary.totalEpisodesWatched)}
</div> </div>

View File

@@ -1,5 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { formatDuration, formatRelativeDate, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters'; import {
formatDuration,
formatRelativeDate,
formatNumber,
todayLocalDay,
localDayFromMs,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client'; import { BASE_URL } from '../../lib/api-client';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
@@ -50,7 +56,8 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
const map = new Map<string, AnimeGroup>(); const map = new Map<string, AnimeGroup>();
for (const session of sessions) { for (const session of sessions) {
const key = session.animeId != null const key =
session.animeId != null
? `anime-${session.animeId}` ? `anime-${session.animeId}`
: session.videoId != null : session.videoId != null
? `video-${session.videoId}` ? `video-${session.videoId}`
@@ -99,7 +106,8 @@ function CoverThumbnail({ videoId, title }: { videoId: number | null; title: str
const target = e.currentTarget; const target = e.currentTarget;
target.style.display = 'none'; target.style.display = 'none';
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.className = 'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0'; placeholder.className =
'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0';
placeholder.textContent = fallbackChar; placeholder.textContent = fallbackChar;
target.parentElement?.insertBefore(placeholder, target); target.parentElement?.insertBefore(placeholder, target);
}} }}
@@ -116,16 +124,21 @@ function SessionItem({ session }: { session: SessionSummary }) {
{session.canonicalTitle ?? 'Unknown Media'} {session.canonicalTitle ?? 'Unknown Media'}
</div> </div>
<div className="text-xs text-ctp-overlay2"> <div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)} active {formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
active
</div> </div>
</div> </div>
<div className="flex gap-4 text-xs text-center shrink-0"> <div className="flex gap-4 text-xs text-center shrink-0">
<div> <div>
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(session.cardsMined)}</div> <div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div> <div className="text-ctp-overlay2">cards</div>
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(session.wordsSeen)}</div> <div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
</div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
@@ -152,20 +165,22 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
> >
<CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} /> <CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate"> <div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
{displayTitle}
</div>
<div className="text-xs text-ctp-overlay2"> <div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active {group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div> </div>
</div> </div>
<div className="flex gap-4 text-xs text-center shrink-0"> <div className="flex gap-4 text-xs text-center shrink-0">
<div> <div>
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(group.totalCards)}</div> <div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div> <div className="text-ctp-overlay2">cards</div>
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(group.totalWords)}</div> <div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>
@@ -193,11 +208,15 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</div> </div>
<div className="flex gap-4 text-xs text-center shrink-0"> <div className="flex gap-4 text-xs text-center shrink-0">
<div> <div>
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(s.cardsMined)}</div> <div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(s.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div> <div className="text-ctp-overlay2">cards</div>
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(s.wordsSeen)}</div> <div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(s.wordsSeen)}
</div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,13 @@
import { import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ComposedChart,
ReferenceArea, ReferenceLine, Area,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceArea,
ReferenceLine,
} from 'recharts'; } from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions'; import { useSessionDetail } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme'; import { CHART_THEME } from '../../lib/chart-theme';
@@ -28,7 +35,10 @@ function formatTime(ms: number): string {
}); });
} }
interface PauseRegion { startMs: number; endMs: number } interface PauseRegion {
startMs: number;
endMs: number;
}
function buildPauseRegions(events: SessionEvent[]): PauseRegion[] { function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
const regions: PauseRegion[] = []; const regions: PauseRegion[] = [];
@@ -216,7 +226,13 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
<div className="flex flex-wrap items-center gap-4 text-[11px]"> <div className="flex flex-wrap items-center gap-4 text-[11px]">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-2 rounded-sm" style={{ background: 'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))' }} /> <span
className="inline-block w-3 h-2 rounded-sm"
style={{
background:
'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))',
}}
/>
<span className="text-ctp-overlay2">New words</span> <span className="text-ctp-overlay2">New words</span>
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@@ -225,19 +241,35 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
</span> </span>
{pauseCount > 0 && ( {pauseCount > 0 && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-2 rounded-sm" style={{ background: 'rgba(245,169,127,0.2)', border: '1px solid rgba(245,169,127,0.5)' }} /> <span
<span className="text-ctp-overlay2">{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span> className="inline-block w-3 h-2 rounded-sm"
style={{
background: 'rgba(245,169,127,0.2)',
border: '1px solid rgba(245,169,127,0.5)',
}}
/>
<span className="text-ctp-overlay2">
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
</span>
</span> </span>
)} )}
{seekCount > 0 && ( {seekCount > 0 && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#91d7e3', opacity: 0.7 }} /> <span
<span className="text-ctp-overlay2">{seekCount} seek{seekCount !== 1 ? 's' : ''}</span> className="inline-block w-3 h-0.5 rounded"
style={{ background: '#91d7e3', opacity: 0.7 }}
/>
<span className="text-ctp-overlay2">
{seekCount} seek{seekCount !== 1 ? 's' : ''}
</span>
</span> </span>
)} )}
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-[12px]"></span> <span className="text-[12px]"></span>
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span> <span className="text-ctp-green">
{Math.max(cardEventCount, cardsMined)} card
{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
</span>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -56,7 +56,7 @@ export function DateRangeSelector({
options={['7d', '30d', '90d', 'all'] as TimeRange[]} options={['7d', '30d', '90d', 'all'] as TimeRange[]}
value={range} value={range}
onChange={onRangeChange} onChange={onRangeChange}
formatLabel={(r) => r === 'all' ? 'All' : r} formatLabel={(r) => (r === 'all' ? 'All' : r)}
/> />
<SegmentedControl <SegmentedControl
label="Group by" label="Group by"

View File

@@ -1,6 +1,4 @@
import { import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from 'recharts';
import { epochDayToDate } from '../../lib/formatters'; import { epochDayToDate } from '../../lib/formatters';
export interface PerAnimeDataPoint { export interface PerAnimeDataPoint {
@@ -15,8 +13,14 @@ interface StackedTrendChartProps {
} }
const LINE_COLORS = [ const LINE_COLORS = [
'#8aadf4', '#c6a0f6', '#a6da95', '#f5a97f', '#f5bde6', '#8aadf4',
'#91d7e3', '#ee99a0', '#f4dbd6', '#c6a0f6',
'#a6da95',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
]; ];
function buildLineData(raw: PerAnimeDataPoint[]) { function buildLineData(raw: PerAnimeDataPoint[]) {
@@ -41,7 +45,10 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
.sort(([a], [b]) => a - b) .sort(([a], [b]) => a - b)
.map(([epochDay, values]) => { .map(([epochDay, values]) => {
const row: Record<string, string | number> = { const row: Record<string, string | number> = {
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
}; };
for (const title of topTitles) { for (const title of topTitles) {
row[title] = values[title] ?? 0; row[title] = values[title] ?? 0;
@@ -56,7 +63,11 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data); const { points, seriesKeys } = buildLineData(data);
const tooltipStyle = { const tooltipStyle = {
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12, background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
}; };
if (points.length === 0) { if (points.length === 0) {
@@ -73,8 +84,18 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3> <h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={120}> <ResponsiveContainer width="100%" height={120}>
<AreaChart data={points}> <AreaChart data={points}>
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} /> <XAxis
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} /> dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} /> <Tooltip contentStyle={tooltipStyle} />
{seriesKeys.map((key, i) => ( {seriesKeys.map((key, i) => (
<Area <Area

View File

@@ -1,6 +1,12 @@
import { import {
BarChart, Bar, LineChart, Line, BarChart,
XAxis, YAxis, Tooltip, ResponsiveContainer, Bar,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts'; } from 'recharts';
interface TrendChartProps { interface TrendChartProps {
@@ -14,10 +20,14 @@ interface TrendChartProps {
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) { export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
const tooltipStyle = { const tooltipStyle = {
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12, background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
}; };
const formatValue = (v: number) => formatter ? [formatter(v), title] : [String(v), title]; const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -25,21 +35,43 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
<ResponsiveContainer width="100%" height={120}> <ResponsiveContainer width="100%" height={120}>
{type === 'bar' ? ( {type === 'bar' ? (
<BarChart data={data}> <BarChart data={data}>
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} /> <XAxis
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} /> dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} /> <Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
<Bar <Bar
dataKey="value" dataKey="value"
fill={color} fill={color}
radius={[2, 2, 0, 0]} radius={[2, 2, 0, 0]}
cursor={onBarClick ? 'pointer' : undefined} cursor={onBarClick ? 'pointer' : undefined}
onClick={onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined} onClick={
onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined
}
/> />
</BarChart> </BarChart>
) : ( ) : (
<LineChart data={data}> <LineChart data={data}>
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} /> <XAxis
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} /> dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} /> <Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} /> <Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
</LineChart> </LineChart>

View File

@@ -129,7 +129,9 @@ export function TrendsTab() {
const watchByHour = buildWatchTimeByHour(data.sessions); const watchByHour = buildWatchTimeByHour(data.sessions);
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({ const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
epochDay: e.epochDay, animeTitle: e.animeTitle, value: e.totalActiveMin, epochDay: e.epochDay,
animeTitle: e.animeTitle,
value: e.totalActiveMin,
})); }));
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions); const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined); const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
@@ -149,7 +151,12 @@ export function TrendsTab() {
/> />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<SectionHeader>Activity</SectionHeader> <SectionHeader>Activity</SectionHeader>
<TrendChart title="Watch Time (min)" data={dashboard.watchTime} color="#8aadf4" type="bar" /> <TrendChart
title="Watch Time (min)"
data={dashboard.watchTime}
color="#8aadf4"
type="bar"
/>
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" /> <TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" /> <TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" /> <TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
@@ -172,8 +179,18 @@ export function TrendsTab() {
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} /> <StackedTrendChart title="Words Seen Progress" data={wordsProgress} />
<SectionHeader>Patterns</SectionHeader> <SectionHeader>Patterns</SectionHeader>
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" /> <TrendChart
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" /> title="Watch Time by Day of Week (min)"
data={watchByDow}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Watch Time by Hour (min)"
data={watchByHour}
color="#c6a0f6"
type="bar"
/>
</div> </div>
</div> </div>
); );

View File

@@ -7,7 +7,12 @@ interface ExclusionManagerProps {
onClose: () => void; onClose: () => void;
} }
export function ExclusionManager({ excluded, onRemove, onClearAll, onClose }: ExclusionManagerProps) { export function ExclusionManager({
excluded,
onRemove,
onClearAll,
onClose,
}: ExclusionManagerProps) {
return ( return (
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-50">
<button <button
@@ -44,11 +49,12 @@ export function ExclusionManager({ excluded, onRemove, onClearAll, onClose }: Ex
<div className="max-h-80 overflow-y-auto px-5 py-3"> <div className="max-h-80 overflow-y-auto px-5 py-3">
{excluded.length === 0 ? ( {excluded.length === 0 ? (
<div className="py-6 text-center text-sm text-ctp-overlay2"> <div className="py-6 text-center text-sm text-ctp-overlay2">
No excluded words yet. Use the Exclude button on a word's detail panel to hide it from stats. No excluded words yet. Use the Exclude button on a word's detail panel to hide it from
stats.
</div> </div>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{excluded.map(w => ( {excluded.map((w) => (
<div <div
key={`${w.headword}\0${w.word}\0${w.reading}`} key={`${w.headword}\0${w.word}\0${w.reading}`}
className="flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2" className="flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2"

View File

@@ -56,7 +56,8 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3> <h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3>
<div className="text-xs text-ctp-overlay2"> <div className="text-xs text-ctp-overlay2">
No frequency rank data available. Run the frequency backfill script or install a frequency dictionary. No frequency rank data available. Run the frequency backfill script or install a frequency
dictionary.
</div> </div>
</div> </div>
); );
@@ -73,14 +74,21 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors" className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
> >
<span className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}>{'\u25B6'}</span> <span
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
>
{'\u25B6'}
</span>
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'} {hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{hasKnownData && ( {hasKnownData && (
<button <button
type="button" type="button"
onClick={() => { setHideKnown(!hideKnown); setPage(0); }} onClick={() => {
setHideKnown(!hideKnown);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${ className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50' ? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
@@ -90,9 +98,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
Hide Known Hide Known
</button> </button>
)} )}
<span className="text-xs text-ctp-overlay2"> <span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
{ranked.length} words
</span>
</div> </div>
</div> </div>
{collapsed ? null : ranked.length === 0 ? ( {collapsed ? null : ranked.length === 0 ? (
@@ -122,9 +128,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs"> <td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()} #{w.frequencyRank!.toLocaleString()}
</td> </td>
<td className="py-1.5 pr-3 text-ctp-text font-medium"> <td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
{w.headword}
</td>
<td className="py-1.5 pr-3 text-ctp-subtext0"> <td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword} {fullReading(w.headword, w.reading) || w.headword}
</td> </td>
@@ -149,7 +153,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
> >
Prev Prev
</button> </button>
<span className="text-ctp-overlay2">{page + 1} / {totalPages}</span> <span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button <button
type="button" type="button"
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}

View File

@@ -21,7 +21,12 @@ function formatSegment(ms: number | null): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`; return `${minutes}:${String(seconds).padStart(2, '0')}`;
} }
export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToAnime }: KanjiDetailPanelProps) { export function KanjiDetailPanel({
kanjiId,
onClose,
onSelectWord,
onNavigateToAnime,
}: KanjiDetailPanelProps) {
const { data, loading, error } = useKanjiDetail(kanjiId); const { data, loading, error } = useKanjiDetail(kanjiId);
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]); const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
const [occLoading, setOccLoading] = useState(false); const [occLoading, setOccLoading] = useState(false);
@@ -44,7 +49,7 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
try { try {
const rows = await apiClient.getKanjiOccurrences(kanji, OCCURRENCES_PAGE_SIZE, offset); const rows = await apiClient.getKanjiOccurrences(kanji, OCCURRENCES_PAGE_SIZE, offset);
if (reqId !== requestIdRef.current) return; if (reqId !== requestIdRef.current) return;
setOccurrences(prev => append ? [...prev, ...rows] : rows); setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE); setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
} catch (err) { } catch (err) {
if (reqId !== requestIdRef.current) return; if (reqId !== requestIdRef.current) return;
@@ -83,7 +88,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4"> <div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">Kanji Detail</div> <div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
Kanji Detail
</div>
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>} {loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>} {error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
{data && ( {data && (
@@ -109,28 +116,39 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
<> <>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-lg font-bold text-ctp-teal">{formatNumber(data.detail.frequency)}</div> <div className="text-lg font-bold text-ctp-teal">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div> <div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div> <div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div> <div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div> </div>
</div> </div>
{data.animeAppearances.length > 0 && ( {data.animeAppearances.length > 0 && (
<section> <section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
</h3>
<div className="space-y-1.5"> <div className="space-y-1.5">
{data.animeAppearances.map(a => ( {data.animeAppearances.map((a) => (
<button <button
key={a.animeId} key={a.animeId}
type="button" type="button"
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }} onClick={() => {
onClose();
onNavigateToAnime?.(a.animeId);
}}
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-teal hover:ring-1 hover:ring-ctp-teal text-left" className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-teal hover:ring-1 hover:ring-ctp-teal text-left"
> >
<span className="truncate text-ctp-text">{a.animeTitle}</span> <span className="truncate text-ctp-text">{a.animeTitle}</span>
@@ -145,9 +163,11 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
{data.words.length > 0 && ( {data.words.length > 0 && (
<section> <section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Words Using This Kanji</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Words Using This Kanji
</h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{data.words.map(w => ( {data.words.map((w) => (
<button <button
key={w.wordId} key={w.wordId}
type="button" type="button"
@@ -163,7 +183,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
)} )}
<section> <section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && ( {!occLoaded && !occLoading && (
<button <button
type="button" type="button"
@@ -173,7 +195,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
Load example lines Load example lines
</button> </button>
)} )}
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>} {occLoading && (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
)}
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>} {occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
{occLoaded && !occLoading && occurrences.length === 0 && ( {occLoaded && !occLoading && occurrences.length === 0 && (
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div> <div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
@@ -199,7 +223,8 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
</div> </div>
</div> </div>
<div className="mt-3 text-xs text-ctp-overlay1"> <div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId} {formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} ·
session {occ.sessionId}
</div> </div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text"> <p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occ.text} {occ.text}

View File

@@ -90,7 +90,9 @@ export function VocabularyOccurrencesDrawer({
</div> </div>
<div className="flex-1 overflow-y-auto px-4 py-4"> <div className="flex-1 overflow-y-auto px-4 py-4">
{loading ? <div className="text-sm text-ctp-overlay2">Loading occurrences...</div> : null} {loading ? (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
) : null}
{!loading && error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null} {!loading && error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
{!loading && !error && occurrences.length === 0 ? ( {!loading && !error && occurrences.length === 0 ? (
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div> <div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
@@ -116,8 +118,8 @@ export function VocabularyOccurrencesDrawer({
</div> </div>
</div> </div>
<div className="mt-3 text-xs text-ctp-overlay1"> <div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occurrence.segmentStartMs)}-{formatSegment(occurrence.segmentEndMs)} · session{' '} {formatSegment(occurrence.segmentStartMs)}-
{occurrence.sessionId} {formatSegment(occurrence.segmentEndMs)} · session {occurrence.sessionId}
</div> </div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text"> <p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occurrence.text} {occurrence.text}

View File

@@ -26,7 +26,14 @@ function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞'; return w.pos2 === '固有名詞';
} }
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, isExcluded, onRemoveExclusion, onClearExclusions }: VocabularyTabProps) { export function VocabularyTab({
onNavigateToAnime,
onOpenWordDetail,
excluded,
isExcluded,
onRemoveExclusion,
onClearExclusions,
}: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error } = useVocabulary(); const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null); const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -63,7 +70,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
}; };
const handleBarClick = (headword: string): void => { const handleBarClick = (headword: string): void => {
const match = filteredWords.find(w => w.headword === headword); const match = filteredWords.find((w) => w.headword === headword);
if (match) onOpenWordDetail?.(match.wordId); if (match) onOpenWordDetail?.(match.wordId);
}; };
@@ -74,8 +81,16 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3"> <div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
<StatCard label="Unique Words" value={formatNumber(summary.uniqueWords)} color="text-ctp-blue" /> <StatCard
<StatCard label="Unique Kanji" value={formatNumber(summary.uniqueKanji)} color="text-ctp-green" /> label="Unique Words"
value={formatNumber(summary.uniqueWords)}
color="text-ctp-blue"
/>
<StatCard
label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)}
color="text-ctp-green"
/>
<StatCard <StatCard
label="New This Week" label="New This Week"
value={`+${formatNumber(summary.newThisWeek)}`} value={`+${formatNumber(summary.newThisWeek)}`}
@@ -133,9 +148,17 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
/> />
</div> </div>
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} /> <FrequencyRankTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<CrossAnimeWordsTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} /> <CrossAnimeWordsTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<WordList <WordList
words={filteredWords} words={filteredWords}

View File

@@ -24,15 +24,22 @@ function highlightWord(text: string, words: string[]): React.ReactNode {
const needles = words.filter(Boolean); const needles = words.filter(Boolean);
if (needles.length === 0) return text; if (needles.length === 0) return text;
const escaped = needles.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const escaped = needles.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(${escaped.join('|')})`, 'g'); const pattern = new RegExp(`(${escaped.join('|')})`, 'g');
const parts = text.split(pattern); const parts = text.split(pattern);
const needleSet = new Set(needles); const needleSet = new Set(needles);
return parts.map((part, i) => return parts.map((part, i) =>
needleSet.has(part) needleSet.has(part) ? (
? <mark key={i} className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2">{part}</mark> <mark
: part key={i}
className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2"
>
{part}
</mark>
) : (
part
),
); );
} }
@@ -44,7 +51,14 @@ function formatSegment(ms: number | null): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`; return `${minutes}:${String(seconds).padStart(2, '0')}`;
} }
export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime, isExcluded, onToggleExclusion }: WordDetailPanelProps) { export function WordDetailPanel({
wordId,
onClose,
onSelectWord,
onNavigateToAnime,
isExcluded,
onToggleExclusion,
}: WordDetailPanelProps) {
const { data, loading, error } = useWordDetail(wordId); const { data, loading, error } = useWordDetail(wordId);
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]); const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
const [occLoading, setOccLoading] = useState(false); const [occLoading, setOccLoading] = useState(false);
@@ -68,7 +82,12 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
if (wordId === null) return null; if (wordId === null) return null;
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: number, limit: number, append: boolean) => { const loadOccurrences = async (
detail: NonNullable<typeof data>['detail'],
offset: number,
limit: number,
append: boolean,
) => {
const reqId = ++requestIdRef.current; const reqId = ++requestIdRef.current;
if (append) { if (append) {
setOccLoadingMore(true); setOccLoadingMore(true);
@@ -78,11 +97,14 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
} }
try { try {
const rows = await apiClient.getWordOccurrences( const rows = await apiClient.getWordOccurrences(
detail.headword, detail.word, detail.reading, detail.headword,
limit, offset, detail.word,
detail.reading,
limit,
offset,
); );
if (reqId !== requestIdRef.current) return; if (reqId !== requestIdRef.current) return;
setOccurrences(prev => append ? [...prev, ...rows] : rows); setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
setHasMore(rows.length === limit); setHasMore(rows.length === limit);
} catch (err) { } catch (err) {
if (reqId !== requestIdRef.current) return; if (reqId !== requestIdRef.current) return;
@@ -109,9 +131,12 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true); void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true);
}; };
const handleMine = async (occ: VocabularyOccurrenceEntry, mode: 'word' | 'sentence' | 'audio') => { const handleMine = async (
occ: VocabularyOccurrenceEntry,
mode: 'word' | 'sentence' | 'audio',
) => {
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`; const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
setMineStatus(prev => ({ ...prev, [key]: { loading: true } })); setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try { try {
const result = await apiClient.mineCard({ const result = await apiClient.mineCard({
sourcePath: occ.sourcePath!, sourcePath: occ.sourcePath!,
@@ -124,20 +149,28 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
mode, mode,
}); });
if (result.error) { if (result.error) {
setMineStatus(prev => ({ ...prev, [key]: { error: result.error } })); setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } }));
} else { } else {
setMineStatus(prev => ({ ...prev, [key]: { success: true } })); setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
const label = mode === 'audio' ? 'Audio card' : mode === 'word' ? data!.detail.headword : occ.text.slice(0, 30); const label =
mode === 'audio'
? 'Audio card'
: mode === 'word'
? data!.detail.headword
: occ.text.slice(0, 30);
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' }); new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') { } else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
Notification.requestPermission().then(p => { Notification.requestPermission().then((p) => {
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` }); if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });
}); });
} }
} }
} catch (err) { } catch (err) {
setMineStatus(prev => ({ ...prev, [key]: { error: err instanceof Error ? err.message : String(err) } })); setMineStatus((prev) => ({
...prev,
[key]: { error: err instanceof Error ? err.message : String(err) },
}));
} }
}; };
@@ -153,23 +186,35 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4"> <div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">Word Detail</div> <div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
Word Detail
</div>
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>} {loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>} {error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
{data && ( {data && (
<> <>
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">{data.detail.headword}</h2> <h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">
<div className="mt-1 text-sm text-ctp-subtext0">{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}</div> {data.detail.headword}
</h2>
<div className="mt-1 text-sm text-ctp-subtext0">
{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}
</div>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />} {data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && ( {data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos1}</span> <span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos1}
</span>
)} )}
{data.detail.pos2 && ( {data.detail.pos2 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos2}</span> <span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos2}
</span>
)} )}
{data.detail.pos3 && ( {data.detail.pos3 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos3}</span> <span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos3}
</span>
)} )}
</div> </div>
</> </>
@@ -204,28 +249,39 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
<> <>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-lg font-bold text-ctp-blue">{formatNumber(data.detail.frequency)}</div> <div className="text-lg font-bold text-ctp-blue">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div> <div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div> <div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div> </div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center"> <div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div> <div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div> <div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div> </div>
</div> </div>
{data.animeAppearances.length > 0 && ( {data.animeAppearances.length > 0 && (
<section> <section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
</h3>
<div className="space-y-1.5"> <div className="space-y-1.5">
{data.animeAppearances.map(a => ( {data.animeAppearances.map((a) => (
<button <button
key={a.animeId} key={a.animeId}
type="button" type="button"
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }} onClick={() => {
onClose();
onNavigateToAnime?.(a.animeId);
}}
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-blue hover:ring-1 hover:ring-ctp-blue text-left" className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-blue hover:ring-1 hover:ring-ctp-blue text-left"
> >
<span className="truncate text-ctp-text">{a.animeTitle}</span> <span className="truncate text-ctp-text">{a.animeTitle}</span>
@@ -240,9 +296,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
{data.similarWords.length > 0 && ( {data.similarWords.length > 0 && (
<section> <section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Similar Words</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Similar Words
</h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{data.similarWords.map(sw => ( {data.similarWords.map((sw) => (
<button <button
key={sw.wordId} key={sw.wordId}
type="button" type="button"
@@ -258,7 +316,9 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
)} )}
<section> <section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && ( {!occLoaded && !occLoading && (
<button <button
type="button" type="button"
@@ -268,10 +328,15 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
Load example lines Load example lines
</button> </button>
)} )}
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>} {occLoading && (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
)}
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>} {occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
{occLoaded && !occLoading && occurrences.length === 0 && ( {occLoaded && !occLoading && occurrences.length === 0 && (
<div className="text-sm text-ctp-overlay2">No example lines tracked yet. Lines are stored for sessions recorded after the subtitle tracking update.</div> <div className="text-sm text-ctp-overlay2">
No example lines tracked yet. Lines are stored for sessions recorded after the
subtitle tracking update.
</div>
)} )}
{occurrences.length > 0 && ( {occurrences.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
@@ -294,8 +359,14 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
</div> </div>
</div> </div>
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1"> <div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
<span>{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}</span> <span>
{occ.sourcePath && occ.segmentStartMs != null && occ.segmentEndMs != null && (() => { {formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
· session {occ.sessionId}
</span>
{occ.sourcePath &&
occ.segmentStartMs != null &&
occ.segmentEndMs != null &&
(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const wordStatus = mineStatus[`${baseKey}-word`]; const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`]; const sentenceStatus = mineStatus[`${baseKey}-sentence`];
@@ -308,7 +379,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
disabled={wordStatus?.loading} disabled={wordStatus?.loading}
onClick={() => void handleMine(occ, 'word')} onClick={() => void handleMine(occ, 'word')}
> >
{wordStatus?.loading ? 'Mining...' : wordStatus?.success ? 'Mined!' : 'Mine Word'} {wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
: 'Mine Word'}
</button> </button>
<button <button
type="button" type="button"
@@ -316,7 +391,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
disabled={sentenceStatus?.loading} disabled={sentenceStatus?.loading}
onClick={() => void handleMine(occ, 'sentence')} onClick={() => void handleMine(occ, 'sentence')}
> >
{sentenceStatus?.loading ? 'Mining...' : sentenceStatus?.success ? 'Mined!' : 'Mine Sentence'} {sentenceStatus?.loading
? 'Mining...'
: sentenceStatus?.success
? 'Mined!'
: 'Mine Sentence'}
</button> </button>
<button <button
type="button" type="button"
@@ -324,7 +403,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
disabled={audioStatus?.loading} disabled={audioStatus?.loading}
onClick={() => void handleMine(occ, 'audio')} onClick={() => void handleMine(occ, 'audio')}
> >
{audioStatus?.loading ? 'Mining...' : audioStatus?.success ? 'Mined!' : 'Mine Audio'} {audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
: 'Mine Audio'}
</button> </button>
</> </>
); );
@@ -333,9 +416,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
{(() => { {(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const errors = ['word', 'sentence', 'audio'] const errors = ['word', 'sentence', 'audio']
.map(m => mineStatus[`${baseKey}-${m}`]?.error) .map((m) => mineStatus[`${baseKey}-${m}`]?.error)
.filter(Boolean); .filter(Boolean);
return errors.length > 0 ? <div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div> : null; return errors.length > 0 ? (
<div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div>
) : null;
})()} })()}
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text"> <p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])} {highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}

View File

@@ -31,9 +31,10 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
const needle = search.trim().toLowerCase(); const needle = search.trim().toLowerCase();
if (!needle) return words; if (!needle) return words;
return words.filter( return words.filter(
w => w.headword.toLowerCase().includes(needle) (w) =>
|| w.word.toLowerCase().includes(needle) w.headword.toLowerCase().includes(needle) ||
|| w.reading.toLowerCase().includes(needle), w.word.toLowerCase().includes(needle) ||
w.reading.toLowerCase().includes(needle),
); );
}, [words, search]); }, [words, search]);
@@ -61,11 +62,16 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text"> <h3 className="text-sm font-semibold text-ctp-text">
{titleBySort[sortBy]} {titleBySort[sortBy]}
{search && <span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>} {search && (
<span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>
)}
</h3> </h3>
<select <select
value={sortBy} value={sortBy}
onChange={(e) => { setSortBy(e.target.value as SortKey); setPage(0); }} onChange={(e) => {
setSortBy(e.target.value as SortKey);
setPage(0);
}}
className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1" className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1"
> >
<option value="frequency">Frequency</option> <option value="frequency">Frequency</option>
@@ -78,9 +84,9 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
<button <button
type="button" type="button"
key={toWordKey(w)} key={toWordKey(w)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${ className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${getFrequencyColor(
getFrequencyColor(w.frequency) w.frequency,
} ${ )} ${
selectedKey === toWordKey(w) selectedKey === toWordKey(w)
? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0' ? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0'
: 'hover:ring-1 hover:ring-ctp-surface2' : 'hover:ring-1 hover:ring-ctp-surface2'
@@ -89,9 +95,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
onClick={() => onSelectWord?.(w)} onClick={() => onSelectWord?.(w)}
> >
{w.headword} {w.headword}
{w.partOfSpeech && ( {w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
<PosBadge pos={w.partOfSpeech} />
)}
<span className="opacity-60">({w.frequency})</span> <span className="opacity-60">({w.frequency})</span>
</button> </button>
))} ))}
@@ -102,7 +106,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
type="button" type="button"
disabled={page === 0} disabled={page === 0}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed" className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage(p => p - 1)} onClick={() => setPage((p) => p - 1)}
> >
Prev Prev
</button> </button>
@@ -113,7 +117,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
type="button" type="button"
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed" className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage(p => p + 1)} onClick={() => setPage((p) => p + 1)}
> >
Next Next
</button> </button>

View File

@@ -32,6 +32,7 @@ const PARTICLE_POS = new Set(['particle', 'auxiliary_verb', 'conjunction']);
export function isFilterable(entry: VocabularyEntry): boolean { export function isFilterable(entry: VocabularyEntry): boolean {
if (PARTICLE_POS.has(entry.partOfSpeech ?? '')) return true; if (PARTICLE_POS.has(entry.partOfSpeech ?? '')) return true;
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword)) return true; if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword))
return true;
return false; return false;
} }

View File

@@ -11,10 +11,18 @@ export function useAnimeLibrary() {
let cancelled = false; let cancelled = false;
getStatsClient() getStatsClient()
.getAnimeLibrary() .getAnimeLibrary()
.then((data) => { if (!cancelled) setAnime(data); }) .then((data) => {
.catch((err: Error) => { if (!cancelled) setError(err.message); }) if (!cancelled) setAnime(data);
.finally(() => { if (!cancelled) setLoading(false); }); })
return () => { cancelled = true; }; .catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []); }, []);
return { anime, loading, error }; return { anime, loading, error };

View File

@@ -57,25 +57,19 @@ export function useExcludedWords() {
[excluded], [excluded],
); );
const toggleExclusion = useCallback( const toggleExclusion = useCallback((w: ExcludedWord) => {
(w: ExcludedWord) => {
const key = toKey(w); const key = toKey(w);
const current = load(); const current = load();
if (getKeySet().has(key)) { if (getKeySet().has(key)) {
persist(current.filter(e => toKey(e) !== key)); persist(current.filter((e) => toKey(e) !== key));
} else { } else {
persist([...current, w]); persist([...current, w]);
} }
}, }, []);
[],
);
const removeExclusion = useCallback( const removeExclusion = useCallback((w: ExcludedWord) => {
(w: ExcludedWord) => { persist(load().filter((e) => toKey(e) !== toKey(w)));
persist(load().filter(e => toKey(e) !== toKey(w))); }, []);
},
[],
);
const clearAll = useCallback(() => persist([]), []); const clearAll = useCallback(() => persist([]), []);

View File

@@ -11,10 +11,18 @@ export function useStreakCalendar(days = 90) {
let cancelled = false; let cancelled = false;
getStatsClient() getStatsClient()
.getStreakCalendar(days) .getStreakCalendar(days)
.then((data) => { if (!cancelled) setCalendar(data); }) .then((data) => {
.catch((err: Error) => { if (!cancelled) setError(err.message); }) if (!cancelled) setCalendar(data);
.finally(() => { if (!cancelled) setLoading(false); }); })
return () => { cancelled = true; }; .catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [days]); }, [days]);
return { calendar, loading, error }; return { calendar, loading, error };

View File

@@ -1,6 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { DailyRollup, MonthlyRollup, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime, SessionSummary, AnimeLibraryItem } from '../types/stats'; import type {
DailyRollup,
MonthlyRollup,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
SessionSummary,
AnimeLibraryItem,
} from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | 'all'; export type TimeRange = '7d' | '30d' | '90d' | 'all';
export type GroupBy = 'day' | 'month'; export type GroupBy = 'day' | 'month';
@@ -35,9 +43,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
const monthlyLimit = Math.max(1, Math.ceil(limit / 30)); const monthlyLimit = Math.max(1, Math.ceil(limit / 30));
const rollupFetcher = const rollupFetcher =
groupBy === 'month' groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit);
? client.getMonthlyRollups(monthlyLimit)
: client.getDailyRollups(limit);
Promise.all([ Promise.all([
rollupFetcher, rollupFetcher,
@@ -47,9 +53,18 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
client.getSessions(500), client.getSessions(500),
client.getAnimeLibrary(), client.getAnimeLibrary(),
]) ])
.then(([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => { .then(
setData({ rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary }); ([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
}) setData({
rollups,
episodesPerDay,
newAnimePerDay,
watchTimePerAnime,
sessions,
animeLibrary,
});
},
)
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [range, groupBy]); }, [range, groupBy]);

View File

@@ -1,7 +1,13 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { DailyRollup, OverviewData, SessionSummary, StreakCalendarDay, VocabularyEntry } from '../types/stats'; import type {
DailyRollup,
OverviewData,
SessionSummary,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import { import {
buildOverviewSummary, buildOverviewSummary,
buildStreakCalendar, buildStreakCalendar,
@@ -49,7 +55,14 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
const overview: OverviewData = { const overview: OverviewData = {
sessions, sessions,
rollups, rollups,
hints: { totalSessions: 1, activeSessions: 0, episodesToday: 2, activeAnimeCount: 3, totalEpisodesWatched: 5, totalAnimeCompleted: 1 }, hints: {
totalSessions: 1,
activeSessions: 0,
episodesToday: 2,
activeAnimeCount: 3,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
},
}; };
const summary = buildOverviewSummary(overview, now); const summary = buildOverviewSummary(overview, now);

View File

@@ -1,4 +1,10 @@
import type { DailyRollup, KanjiEntry, OverviewData, StreakCalendarDay, VocabularyEntry } from '../types/stats'; import type {
DailyRollup,
KanjiEntry,
OverviewData,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import { epochDayToDate, localDayFromMs } from './formatters'; import { epochDayToDate, localDayFromMs } from './formatters';
export interface ChartPoint { export interface ChartPoint {
@@ -110,7 +116,9 @@ function buildAggregatedDailyRows(rollups: DailyRollup[]) {
averageSessionMinutes: averageSessionMinutes:
value.sessions > 0 ? +(value.activeMin / value.sessions).toFixed(1) : 0, value.sessions > 0 ? +(value.activeMin / value.sessions).toFixed(1) : 0,
lookupHitRate: lookupHitRate:
value.lookupWeight > 0 ? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100) : 0, value.lookupWeight > 0
? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100)
: 0,
})); }));
} }
@@ -142,7 +150,10 @@ export function buildOverviewSummary(
return { return {
todayActiveMs: Math.max(todayActiveFromRollup, todayActiveFromSessions), todayActiveMs: Math.max(todayActiveFromRollup, todayActiveFromSessions),
todayCards: Math.max(todayRow?.cards ?? 0, sumBy(todaySessions, (session) => session.cardsMined)), todayCards: Math.max(
todayRow?.cards ?? 0,
sumBy(todaySessions, (session) => session.cardsMined),
),
streakDays, streakDays,
allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60), allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60),
totalTrackedCards: Math.max(sessionCards, rollupCards), totalTrackedCards: Math.max(sessionCards, rollupCards),
@@ -152,17 +163,21 @@ export function buildOverviewSummary(
totalAnimeCompleted: overview.hints.totalAnimeCompleted ?? 0, totalAnimeCompleted: overview.hints.totalAnimeCompleted ?? 0,
averageSessionMinutes: averageSessionMinutes:
overview.sessions.length > 0 overview.sessions.length > 0
? Math.round(sumBy(overview.sessions, (session) => session.activeWatchedMs) / overview.sessions.length / 60_000) ? Math.round(
sumBy(overview.sessions, (session) => session.activeWatchedMs) /
overview.sessions.length /
60_000,
)
: 0, : 0,
totalSessions: overview.hints.totalSessions, totalSessions: overview.hints.totalSessions,
activeDays: daysWithActivity.size, activeDays: daysWithActivity.size,
recentWatchTime: aggregated.slice(-14).map((row) => ({ label: row.label, value: row.activeMin })), recentWatchTime: aggregated
.slice(-14)
.map((row) => ({ label: row.label, value: row.activeMin })),
}; };
} }
export function buildTrendDashboard( export function buildTrendDashboard(rollups: DailyRollup[]): TrendDashboard {
rollups: DailyRollup[],
): TrendDashboard {
const aggregated = buildAggregatedDailyRows(rollups); const aggregated = buildAggregatedDailyRows(rollups);
return { return {
watchTime: aggregated.map((row) => ({ label: row.label, value: row.activeMin })), watchTime: aggregated.map((row) => ({ label: row.label, value: row.activeMin })),

View File

@@ -1,12 +1,24 @@
import type { import type {
OverviewData, DailyRollup, MonthlyRollup, OverviewData,
SessionSummary, SessionTimelinePoint, SessionEvent, DailyRollup,
VocabularyEntry, KanjiEntry, MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry, VocabularyOccurrenceEntry,
MediaLibraryItem, MediaDetailData, MediaLibraryItem,
AnimeLibraryItem, AnimeDetailData, AnimeWord, MediaDetailData,
StreakCalendarDay, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime, AnimeLibraryItem,
WordDetailData, KanjiDetailData, AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
WordDetailData,
KanjiDetailData,
EpisodeDetailData, EpisodeDetailData,
} from '../types/stats'; } from '../types/stats';
@@ -47,7 +59,9 @@ interface StatsElectronAPI {
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>; getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>; getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
ankiBrowse: (noteId: number) => Promise<void>; ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>; ankiNotesInfo: (
noteIds: number[],
) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
hideOverlay: () => void; hideOverlay: () => void;
}; };
} }

View File

@@ -15,6 +15,6 @@ if (root) {
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode> </StrictMode>,
); );
} }

View File

@@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
@theme { @theme {
--color-ctp-base: #24273a; --color-ctp-base: #24273a;
@@ -28,7 +28,8 @@
--color-ctp-maroon: #ee99a0; --color-ctp-maroon: #ee99a0;
--color-ctp-pink: #f5bde6; --color-ctp-pink: #f5bde6;
--font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --font-sans:
'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; --font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
} }