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,40 +193,34 @@ 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('/'), runAppCommandAttached: async () => 0,
runAppCommandAttached: async () => 0, waitForStatsResponse: async () => ({
waitForStatsResponse: async () => ({ ok: false,
ok: false, error: 'Immersion tracking is disabled in config.',
error: 'Immersion tracking is disabled in config.', }),
}), 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('/'), runAppCommandAttached: async () => 2,
runAppCommandAttached: async () => 2, waitForStatsResponse: async () => {
waitForStatsResponse: async () => { await new Promise((resolve) => setTimeout(resolve, 25));
await new Promise((resolve) => setTimeout(resolve, 25)); return { ok: true, url: 'http://127.0.0.1:5175' };
return { ok: true, url: 'http://127.0.0.1:5175' }; },
}, 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,15 +335,18 @@ 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(
withTempDir((root) => { 'stats command launches attached app flow and waits for response file',
const homeDir = path.join(root, 'home'); { timeout: 15000 },
const xdgConfigHome = path.join(root, 'xdg'); () => {
const appPath = path.join(root, 'fake-subminer.sh'); withTempDir((root) => {
const capturePath = path.join(root, 'captured-args.txt'); const homeDir = path.join(root, 'home');
fs.writeFileSync( const xdgConfigHome = path.join(root, 'xdg');
appPath, const appPath = path.join(root, 'fake-subminer.sh');
`#!/bin/sh const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
`#!/bin/sh
set -eu set -eu
response_path="" response_path=""
prev="" prev=""
@@ -369,20 +372,24 @@ mkdir -p "$(dirname "$response_path")"
printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path" printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
exit 0 exit 0
`, `,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
const env = { const env = {
...makeTestEnv(homeDir, xdgConfigHome), ...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath, SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_STATS_CAPTURE: capturePath, SUBMINER_TEST_STATS_CAPTURE: capturePath,
}; };
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,23 +141,20 @@ 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 =
? media episode === null
: media.filter((item) => { ? media
const total = item.episodes; : media.filter((item) => {
return total === null || total >= episode; const total = item.episodes;
}); return total === null || total >= episode;
});
const candidates = filtered.length > 0 ? filtered : media; const candidates = filtered.length > 0 ? filtered : media;
if (candidates.length === 0) { if (candidates.length === 0) {
return null; return null;
} }
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,14 +513,16 @@ 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',
source_path: string | null; '/tmp/Little Witch Academia S02E06.mkv',
anime_id: number | null; ) as Array<{
parsed_episode: number | null; source_path: string | null;
anime_title: string | null; anime_id: number | null;
anilist_id: number | null; parsed_episode: number | null;
}>; anime_title: string | null;
anilist_id: number | null;
}>;
assert.equal(rows.length, 2); assert.equal(rows.length, 2);
assert.ok(rows[0]?.anime_id); assert.ok(rows[0]?.anime_id);

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,16 +409,21 @@ export class ImmersionTrackerService {
deleteVideoQuery(this.db, videoId); deleteVideoQuery(this.db, videoId);
} }
async reassignAnimeAnilist(animeId: number, info: { async reassignAnimeAnilist(
anilistId: number; animeId: number,
titleRomaji?: string | null; info: {
titleEnglish?: string | null; anilistId: number;
titleNative?: string | null; titleRomaji?: string | null;
episodesTotal?: number | null; titleEnglish?: string | null;
description?: string | null; titleNative?: string | null;
coverUrl?: string | null; episodesTotal?: number | null;
}): Promise<void> { description?: string | null;
this.db.prepare(` coverUrl?: string | null;
},
): 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,39 +433,55 @@ export class ImmersionTrackerService {
description = ?, description = ?,
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE anime_id = ? WHERE anime_id = ?
`).run( `,
info.anilistId, )
info.titleRomaji ?? null, .run(
info.titleEnglish ?? null, info.anilistId,
info.titleNative ?? null, info.titleRomaji ?? null,
info.episodesTotal ?? null, info.titleEnglish ?? null,
info.description ?? null, info.titleNative ?? null,
Date.now(), info.episodesTotal ?? null,
animeId, info.description ?? null,
); Date.now(),
animeId,
);
// 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,16 +149,20 @@ 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(
runGuessit: async (target) => { '/tmp/Little Witch Academia S02E05.mkv',
seenTargets.push(target); 'Episode 5',
return JSON.stringify({ {
title: 'Little Witch Academia', runGuessit: async (target) => {
season: 2, seenTargets.push(target);
episode: 5, return JSON.stringify({
}); title: 'Little Witch Academia',
season: 2,
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(
runGuessit: async () => { '/tmp/Little Witch Academia S02E05.mkv',
throw new Error('guessit unavailable'); 'Episode 5',
{
runGuessit: async () => {
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', runGuessit: async () => JSON.stringify({ episode: 3 }),
null, });
{
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,18 +473,16 @@ 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; pos2: string | null;
pos2: string | null; pos3: string | null;
pos3: string | null; 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,37 +1101,55 @@ 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( `,
wordId, )
word.reading, .all(
`%${word.headword.charAt(0)}%`, wordId,
`%${word.headword.charAt(word.headword.length - 1)}%`, word.reading,
limit, `%${word.headword.charAt(0)}%`,
) as SimilarWordRow[]; `%${word.headword.charAt(word.headword.length - 1)}%`,
limit,
) as SimilarWordRow[];
} }
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, if (typeof videoId !== 'number') return null;
async (_event, videoId: unknown) => { return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
if (typeof videoId !== 'number') return 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, if (typeof videoId !== 'number') return null;
async (_event, videoId: unknown) => { return deps.immersionTracker?.getCoverArt(videoId) ?? null;
if (typeof videoId !== 'number') return 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(
fps: ankiConfig.media?.animatedFps ?? 10, sourcePath,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, startSec,
maxHeight: ankiConfig.media?.animatedMaxHeight, clampedEndSec,
crf: ankiConfig.media?.animatedCrf ?? 35, audioPadding,
}); {
fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight,
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,13 +154,14 @@ 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 { (() => {
return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source; try {
} catch { return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source;
return source; } catch {
} return source;
})().split(/[?#]/, 1)[0] ?? ''; }
})().split(/[?#]/, 1)[0] ?? '';
const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? ''; const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? '';
if (ext === 'srt') return 'srt'; if (ext === 'srt') return 'srt';
if (ext === 'vtt') return 'vtt'; if (ext === 'vtt') return 'vtt';

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,52 +3227,55 @@ 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(
getFrequencyDictionaryEnabled: () => true, [{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }],
getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null), {
tokenizeWithMecab: async () => [ getFrequencyDictionaryEnabled: () => true,
{ getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null),
headword: '張り切る', tokenizeWithMecab: async () => [
surface: '張り切っ', {
reading: 'ハリキッ', headword: '張り切る',
startPos: 0, surface: '張り切っ',
endPos: 4, reading: 'ハリキッ',
partOfSpeech: PartOfSpeech.verb, startPos: 0,
pos1: '動詞', endPos: 4,
pos2: '自立', partOfSpeech: PartOfSpeech.verb,
isMerged: false, pos1: '動詞',
isKnown: false, pos2: '自立',
isNPlusOneTarget: false, isMerged: false,
}, isKnown: false,
{ isNPlusOneTarget: false,
headword: 'て', },
surface: 'て', {
reading: '', headword: '',
startPos: 4, surface: 'て',
endPos: 5, reading: 'テ',
partOfSpeech: PartOfSpeech.particle, startPos: 4,
pos1: '助詞', endPos: 5,
pos2: '接続助詞', partOfSpeech: PartOfSpeech.particle,
isMerged: false, pos1: '助詞',
isKnown: false, pos2: '接続助詞',
isNPlusOneTarget: false, isMerged: false,
}, isKnown: false,
{ isNPlusOneTarget: false,
headword: 'んじゃ', },
surface: 'んじゃ', {
reading: 'ンジャ', headword: 'んじゃ',
startPos: 5, surface: 'んじゃ',
endPos: 8, reading: 'ンジャ',
partOfSpeech: PartOfSpeech.other, startPos: 5,
pos1: '接続詞', endPos: 8,
pos2: '*', partOfSpeech: PartOfSpeech.other,
isMerged: false, pos1: '接続詞',
isKnown: false, pos2: '*',
isNPlusOneTarget: false, isMerged: false,
}, isKnown: false,
], isNPlusOneTarget: false,
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 =
? 'file:///C:/Users/test/Sub%20Folder/subs.ass' process.platform === 'win32'
: 'file:///tmp/Sub%20Folder/subs.ass'; ? 'file:///C:/Users/test/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 =
? `Season ${entry.season}` entry.season != null
: entry.titleEnglish ?? entry.titleRomaji ?? 'AniList'; ? `Season ${entry.season}`
: (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,11 +56,12 @@ 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 =
? `anime-${session.animeId}` session.animeId != null
: session.videoId != null ? `anime-${session.animeId}`
? `video-${session.videoId}` : session.videoId != null
: `session-${session.sessionId}`; ? `video-${session.videoId}`
: `session-${session.sessionId}`;
const existing = map.get(key); const existing = map.get(key);
if (existing) { if (existing) {
@@ -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,48 +359,68 @@ 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)}{' '}
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; · session {occ.sessionId}
const wordStatus = mineStatus[`${baseKey}-word`]; </span>
const sentenceStatus = mineStatus[`${baseKey}-sentence`]; {occ.sourcePath &&
const audioStatus = mineStatus[`${baseKey}-audio`]; occ.segmentStartMs != null &&
return ( occ.segmentEndMs != null &&
<> (() => {
<button const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
type="button" const wordStatus = mineStatus[`${baseKey}-word`];
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60" const sentenceStatus = mineStatus[`${baseKey}-sentence`];
disabled={wordStatus?.loading} const audioStatus = mineStatus[`${baseKey}-audio`];
onClick={() => void handleMine(occ, 'word')} return (
> <>
{wordStatus?.loading ? 'Mining...' : wordStatus?.success ? 'Mined!' : 'Mine Word'} <button
</button> type="button"
<button className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
type="button" disabled={wordStatus?.loading}
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60" onClick={() => void handleMine(occ, 'word')}
disabled={sentenceStatus?.loading} >
onClick={() => void handleMine(occ, 'sentence')} {wordStatus?.loading
> ? 'Mining...'
{sentenceStatus?.loading ? 'Mining...' : sentenceStatus?.success ? 'Mined!' : 'Mine Sentence'} : wordStatus?.success
</button> ? 'Mined!'
<button : 'Mine Word'}
type="button" </button>
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60" <button
disabled={audioStatus?.loading} type="button"
onClick={() => void handleMine(occ, 'audio')} className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
> disabled={sentenceStatus?.loading}
{audioStatus?.loading ? 'Mining...' : audioStatus?.success ? 'Mined!' : 'Mine Audio'} onClick={() => void handleMine(occ, 'sentence')}
</button> >
</> {sentenceStatus?.loading
); ? 'Mining...'
})()} : sentenceStatus?.success
? 'Mined!'
: 'Mine Sentence'}
</button>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
disabled={audioStatus?.loading}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
: 'Mine Audio'}
</button>
</>
);
})()}
</div> </div>
{(() => { {(() => {
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;
} }