feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'http:',
origin: 'http://127.0.0.1:6123',
search: '',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1',
});
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(null, { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.deleteSession(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession throws when the stats API delete request fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response('boom', {
status: 500,
statusText: 'Internal Server Error',
})) as typeof globalThis.fetch;
try {
await assert.rejects(() => apiClient.deleteSession(7), /Stats API error: 500 boom/);
} finally {
globalThis.fetch = originalFetch;
}
});
test('getTrendsDashboard requests the chart-ready trends endpoint with range and grouping', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(
JSON.stringify({
activity: { watchTime: [], cards: [], words: [], sessions: [] },
progress: {
watchTime: [],
sessions: [],
words: [],
newWords: [],
cards: [],
episodes: [],
lookups: [],
},
ratios: { lookupsPerHundred: [] },
animePerDay: {
episodes: [],
watchTime: [],
cards: [],
words: [],
lookups: [],
lookupsPerHundred: [],
},
animeCumulative: {
watchTime: [],
episodes: [],
cards: [],
words: [],
},
patterns: {
watchTimeByDayOfWeek: [],
watchTimeByHour: [],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof globalThis.fetch;
try {
await apiClient.getTrendsDashboard('90d', 'month');
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`);
} finally {
globalThis.fetch = originalFetch;
}
});
test('getSessionEvents can request only specific event types', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getSessionEvents(42, 120, [4, 5, 6, 7, 8, 9]);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sessions/42/events?limit=120&types=4%2C5%2C6%2C7%2C8%2C9`,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('getSessionTimeline requests full session history when limit is omitted', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getSessionTimeline(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42/timeline`);
} finally {
globalThis.fetch = originalFetch;
}
});

220
stats/src/lib/api-client.ts Normal file
View File

@@ -0,0 +1,220 @@
import type {
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
TrendsDashboardData,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
StatsAnkiNoteInfo,
} from '../types/stats';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
const resolvedLocation =
location ??
(typeof window === 'undefined'
? { protocol: 'file:', origin: 'null', search: '' }
: window.location);
const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
if (queryApiBase) {
return queryApiBase;
}
return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
}
export const BASE_URL = resolveStatsBaseUrl();
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE_URL}${path}`, init);
if (!res.ok) {
let body = '';
try {
body = (await res.text()).trim();
} catch {
body = '';
}
throw new Error(
body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`,
);
}
return res;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetchResponse(path);
return res.json() as Promise<T>;
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getDailyRollups: (limit = 60) =>
fetchJson<DailyRollup[]>(`/api/stats/daily-rollups?limit=${limit}`),
getMonthlyRollups: (limit = 24) =>
fetchJson<MonthlyRollup[]>(`/api/stats/monthly-rollups?limit=${limit}`),
getSessions: (limit = 50) => fetchJson<SessionSummary[]>(`/api/stats/sessions?limit=${limit}`),
getSessionTimeline: (id: number, limit?: number) =>
fetchJson<SessionTimelinePoint[]>(
limit === undefined
? `/api/stats/sessions/${id}/timeline`
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
),
getSessionEvents: (id: number, limit = 500, eventTypes?: number[]) => {
const params = new URLSearchParams({ limit: String(limit) });
if (eventTypes && eventTypes.length > 0) {
params.set('types', eventTypes.join(','));
}
return fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?${params.toString()}`);
},
getSessionKnownWordsTimeline: (id: number) =>
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number }>>(
`/api/stats/sessions/${id}/known-words-timeline`,
),
getVocabulary: (limit = 100) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
),
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
),
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) =>
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
getNewAnimePerDay: (limit = 90) =>
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: (range: '7d' | '30d' | '90d' | 'all', groupBy: 'day' | 'month') =>
fetchJson<TrendsDashboardData>(
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
),
getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) =>
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),
getEpisodeDetail: (videoId: number) =>
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched }),
});
},
deleteSession: async (sessionId: number): Promise<void> => {
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
},
deleteSessions: async (sessionIds: number[]): Promise<void> => {
await fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds }),
});
},
deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
},
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
getKnownWordsSummary: () =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
'/api/stats/known-words-summary',
),
getAnimeKnownWordsSummary: (animeId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
`/api/stats/anime/${animeId}/known-words-summary`,
),
getMediaKnownWordsSummary: (videoId: number) =>
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
`/api/stats/media/${videoId}/known-words-summary`,
),
searchAnilist: (query: string) =>
fetchJson<
Array<{
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
>(`/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
reassignAnimeAnilist: async (
animeId: number,
info: {
anilistId: number;
titleRomaji?: string | null;
titleEnglish?: string | null;
titleNative?: string | null;
episodesTotal?: number | null;
description?: string | null;
coverUrl?: string | null;
},
): Promise<void> => {
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info),
});
},
mineCard: async (params: {
sourcePath: string;
startMs: number;
endMs: number;
sentence: string;
word: string;
secondaryText?: string | null;
videoTitle: string;
mode: 'word' | 'sentence' | 'audio';
}): Promise<{ noteId?: number; error?: string; errors?: string[] }> => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
return res.json();
},
ankiBrowse: async (noteId: number): Promise<void> => {
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
},
ankiNotesInfo: async (noteIds: number[]): Promise<StatsAnkiNoteInfo[]> => {
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds }),
});
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
return res.json();
},
};

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const APP_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../App.tsx');
test('App lazy-loads non-overview tabs and detail surfaces behind Suspense boundaries', () => {
const source = fs.readFileSync(APP_PATH, 'utf8');
assert.match(source, /\bSuspense\b/, 'expected Suspense boundary in App');
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
assert.doesNotMatch(source, /import \{ AnimeTab \} from '\.\/components\/anime\/AnimeTab';/);
assert.doesNotMatch(source, /import \{ TrendsTab \} from '\.\/components\/trends\/TrendsTab';/);
assert.doesNotMatch(
source,
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
);
assert.doesNotMatch(
source,
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
);
assert.doesNotMatch(
source,
/import \{ MediaDetailView \} from '\.\/components\/library\/MediaDetailView';/,
);
assert.doesNotMatch(
source,
/import \{ WordDetailPanel \} from '\.\/components\/vocabulary\/WordDetailPanel';/,
);
});

View File

@@ -0,0 +1,8 @@
export const CHART_THEME = {
tick: '#a5adcb',
tooltipBg: '#363a4f',
tooltipBorder: '#494d64',
tooltipText: '#cad3f5',
tooltipLabel: '#b8c0e0',
barFill: '#8aadf4',
} as const;

View File

@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type {
DailyRollup,
OverviewData,
SessionSummary,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import {
buildOverviewSummary,
buildStreakCalendar,
buildTrendDashboard,
buildVocabularySummary,
} from './dashboard-data';
test('buildOverviewSummary aggregates tracked totals and recent windows', () => {
const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000);
const sessions: SessionSummary[] = [
{
sessionId: 1,
canonicalTitle: 'A',
videoId: 1,
animeId: null,
animeTitle: null,
startedAtMs: now - 3_600_000,
endedAtMs: now - 1_800_000,
totalWatchedMs: 3_600_000,
activeWatchedMs: 3_000_000,
linesSeen: 20,
tokensSeen: 80,
cardsMined: 2,
lookupCount: 10,
lookupHits: 8,
yomitanLookupCount: 0,
knownWordsSeen: 10,
knownWordRate: 12.5,
},
];
const rollups: DailyRollup[] = [
{
rollupDayOrMonth: today,
videoId: 1,
totalSessions: 1,
totalActiveMin: 50,
totalLinesSeen: 20,
totalTokensSeen: 80,
totalCards: 2,
cardsPerHour: 2.4,
tokensPerMin: 2,
lookupHitRate: 0.8,
},
];
const overview: OverviewData = {
sessions,
rollups,
hints: {
totalSessions: 15,
activeSessions: 0,
episodesToday: 2,
activeAnimeCount: 3,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
totalActiveMin: 50,
activeDays: 2,
totalCards: 9,
totalLookupCount: 100,
totalLookupHits: 80,
totalTokensSeen: 1000,
totalYomitanLookupCount: 23,
newWordsToday: 5,
newWordsThisWeek: 20,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.todayCards, 2);
assert.equal(summary.totalTrackedCards, 9);
assert.equal(summary.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50);
assert.equal(summary.allTimeMinutes, 50);
assert.equal(summary.activeDays, 2);
assert.equal(summary.totalSessions, 15);
assert.deepEqual(summary.lookupRate, {
shortValue: '2.3 / 100 words',
longValue: '2.3 lookups per 100 words',
});
});
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000);
const overview: OverviewData = {
sessions: [
{
sessionId: 2,
canonicalTitle: 'B',
videoId: 2,
animeId: null,
animeTitle: null,
startedAtMs: now - 60_000,
endedAtMs: now,
totalWatchedMs: 60_000,
activeWatchedMs: 60_000,
linesSeen: 10,
tokensSeen: 10,
cardsMined: 10,
lookupCount: 1,
lookupHits: 1,
yomitanLookupCount: 0,
knownWordsSeen: 2,
knownWordRate: 20,
},
],
rollups: [
{
rollupDayOrMonth: today,
videoId: 2,
totalSessions: 1,
totalActiveMin: 1,
totalLinesSeen: 10,
totalTokensSeen: 10,
totalCards: 10,
cardsPerHour: 600,
tokensPerMin: 10,
lookupHitRate: 1,
},
],
hints: {
totalSessions: 50,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalActiveMin: 120,
activeDays: 40,
totalCards: 5,
totalLookupCount: 0,
totalLookupHits: 0,
totalTokensSeen: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.totalTrackedCards, 5);
assert.equal(summary.allTimeMinutes, 120);
assert.equal(summary.activeDays, 40);
assert.equal(summary.lookupRate, null);
});
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
const now = Date.UTC(2026, 2, 13, 12);
const nowSec = now / 1000;
const words: VocabularyEntry[] = [
{
wordId: 1,
headword: '猫',
word: '猫',
reading: 'ねこ',
partOfSpeech: null,
pos1: null,
pos2: null,
pos3: null,
frequency: 4,
frequencyRank: null,
animeCount: 1,
firstSeen: nowSec - 2 * 86_400,
lastSeen: nowSec - 1,
},
];
const summary = buildVocabularySummary(words, [], now);
assert.equal(summary.newThisWeek, 1);
});
test('buildTrendDashboard derives dense chart series', () => {
const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000);
const rollups: DailyRollup[] = [
{
rollupDayOrMonth: today - 1,
videoId: 1,
totalSessions: 2,
totalActiveMin: 60,
totalLinesSeen: 30,
totalTokensSeen: 100,
totalCards: 3,
cardsPerHour: 3,
tokensPerMin: 2,
lookupHitRate: 0.5,
},
{
rollupDayOrMonth: today,
videoId: 1,
totalSessions: 1,
totalActiveMin: 30,
totalLinesSeen: 10,
totalTokensSeen: 30,
totalCards: 1,
cardsPerHour: 2,
tokensPerMin: 1.33,
lookupHitRate: 0.75,
},
];
const dashboard = buildTrendDashboard(rollups);
assert.equal(dashboard.watchTime.length, 2);
assert.equal(dashboard.words[1]?.value, 30);
assert.equal(dashboard.sessions[0]?.value, 2);
});
test('buildStreakCalendar converts epoch days to YYYY-MM-DD dates', () => {
const days: StreakCalendarDay[] = [
{ epochDay: 20525, totalActiveMin: 45 },
{ epochDay: 20526, totalActiveMin: 0 },
{ epochDay: 20527, totalActiveMin: 30 },
];
const points = buildStreakCalendar(days);
assert.equal(points.length, 3);
assert.match(points[0]!.date, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(points[0]!.value, 45);
assert.equal(points[1]!.value, 0);
assert.equal(points[2]!.value, 30);
});

View File

@@ -0,0 +1,272 @@
import type {
DailyRollup,
KanjiEntry,
OverviewData,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import { epochDayToDate, epochMsFromDbTimestamp, localDayFromMs } from './formatters';
import { buildLookupRateDisplay, type LookupRateDisplay } from './yomitan-lookup';
export interface ChartPoint {
label: string;
value: number;
}
export interface OverviewSummary {
todayActiveMs: number;
todayCards: number;
streakDays: number;
allTimeMinutes: number;
totalTrackedCards: number;
episodesToday: number;
activeAnimeCount: number;
totalEpisodesWatched: number;
totalAnimeCompleted: number;
averageSessionMinutes: number;
activeDays: number;
totalSessions: number;
lookupRate: LookupRateDisplay | null;
todayTokens: number;
newWordsToday: number;
newWordsThisWeek: number;
recentWatchTime: ChartPoint[];
}
export interface TrendDashboard {
watchTime: ChartPoint[];
cards: ChartPoint[];
words: ChartPoint[];
sessions: ChartPoint[];
cardsPerHour: ChartPoint[];
lookupHitRate: ChartPoint[];
averageSessionMinutes: ChartPoint[];
}
export interface VocabularySummary {
uniqueWords: number;
uniqueKanji: number;
newThisWeek: number;
topWords: ChartPoint[];
newWordsTimeline: ChartPoint[];
recentDiscoveries: VocabularyEntry[];
}
function normalizeDbTimestampSeconds(ts: number): number {
return Math.floor(epochMsFromDbTimestamp(ts) / 1000);
}
function makeRollupLabel(value: number): string {
if (value > 100_000) {
const year = Math.floor(value / 100);
const month = value % 100;
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, {
month: 'short',
year: '2-digit',
});
}
return epochDayToDate(value).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
function sumBy<T>(values: T[], select: (value: T) => number): number {
return values.reduce((sum, value) => sum + select(value), 0);
}
function buildAggregatedDailyRows(rollups: DailyRollup[]) {
const byKey = new Map<
number,
{
activeMin: number;
cards: number;
words: number;
sessions: number;
lookupHitRateSum: number;
lookupWeight: number;
}
>();
for (const rollup of rollups) {
const existing = byKey.get(rollup.rollupDayOrMonth) ?? {
activeMin: 0,
cards: 0,
words: 0,
sessions: 0,
lookupHitRateSum: 0,
lookupWeight: 0,
};
existing.activeMin += rollup.totalActiveMin;
existing.cards += rollup.totalCards;
existing.words += rollup.totalTokensSeen;
existing.sessions += rollup.totalSessions;
if (rollup.lookupHitRate != null) {
const weight = Math.max(rollup.totalSessions, 1);
existing.lookupHitRateSum += rollup.lookupHitRate * weight;
existing.lookupWeight += weight;
}
byKey.set(rollup.rollupDayOrMonth, existing);
}
return Array.from(byKey.entries())
.sort(([left], [right]) => left - right)
.map(([key, value]) => ({
key,
label: makeRollupLabel(key),
activeMin: Math.round(value.activeMin),
cards: value.cards,
words: value.words,
sessions: value.sessions,
cardsPerHour: value.activeMin > 0 ? +((value.cards * 60) / value.activeMin).toFixed(1) : 0,
averageSessionMinutes:
value.sessions > 0 ? +(value.activeMin / value.sessions).toFixed(1) : 0,
lookupHitRate:
value.lookupWeight > 0
? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100)
: 0,
}));
}
export function buildOverviewSummary(
overview: OverviewData,
nowMs: number = Date.now(),
): OverviewSummary {
const today = localDayFromMs(nowMs);
const aggregated = buildAggregatedDailyRows(overview.rollups);
const todayRow = aggregated.find((row) => row.key === today);
const daysWithActivity = new Set(
aggregated.filter((row) => row.activeMin > 0).map((row) => row.key),
);
const sessionCards = sumBy(overview.sessions, (session) => session.cardsMined);
const rollupCards = sumBy(aggregated, (row) => row.cards);
const lifetimeCards = overview.hints.totalCards ?? Math.max(sessionCards, rollupCards);
const totalActiveMin = overview.hints.totalActiveMin ?? sumBy(aggregated, (row) => row.activeMin);
let streakDays = 0;
const streakStart = daysWithActivity.has(today) ? today : today - 1;
for (let day = streakStart; daysWithActivity.has(day); day -= 1) {
streakDays += 1;
}
const todaySessions = overview.sessions.filter(
(session) => localDayFromMs(session.startedAtMs) === today,
);
const todayActiveFromSessions = sumBy(todaySessions, (session) => session.activeWatchedMs);
const todayActiveFromRollup = (todayRow?.activeMin ?? 0) * 60_000;
return {
todayActiveMs: Math.max(todayActiveFromRollup, todayActiveFromSessions),
todayCards: Math.max(
todayRow?.cards ?? 0,
sumBy(todaySessions, (session) => session.cardsMined),
),
streakDays,
allTimeMinutes: Math.max(0, Math.round(totalActiveMin)),
totalTrackedCards: lifetimeCards,
episodesToday: overview.hints.episodesToday ?? 0,
activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
totalEpisodesWatched: overview.hints.totalEpisodesWatched ?? 0,
totalAnimeCompleted: overview.hints.totalAnimeCompleted ?? 0,
averageSessionMinutes:
overview.sessions.length > 0
? Math.round(
sumBy(overview.sessions, (session) => session.activeWatchedMs) /
overview.sessions.length /
60_000,
)
: 0,
activeDays: overview.hints.activeDays ?? daysWithActivity.size,
totalSessions: overview.hints.totalSessions ?? overview.sessions.length,
lookupRate: buildLookupRateDisplay(
overview.hints.totalYomitanLookupCount,
overview.hints.totalTokensSeen,
),
todayTokens: Math.max(
todayRow?.words ?? 0,
sumBy(todaySessions, (session) => session.tokensSeen),
),
newWordsToday: overview.hints.newWordsToday ?? 0,
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,
recentWatchTime: aggregated
.slice(-14)
.map((row) => ({ label: row.label, value: row.activeMin })),
};
}
export function buildTrendDashboard(rollups: DailyRollup[]): TrendDashboard {
const aggregated = buildAggregatedDailyRows(rollups);
return {
watchTime: aggregated.map((row) => ({ label: row.label, value: row.activeMin })),
cards: aggregated.map((row) => ({ label: row.label, value: row.cards })),
words: aggregated.map((row) => ({ label: row.label, value: row.words })),
sessions: aggregated.map((row) => ({ label: row.label, value: row.sessions })),
cardsPerHour: aggregated.map((row) => ({ label: row.label, value: row.cardsPerHour })),
lookupHitRate: aggregated.map((row) => ({ label: row.label, value: row.lookupHitRate })),
averageSessionMinutes: aggregated.map((row) => ({
label: row.label,
value: row.averageSessionMinutes,
})),
};
}
export function buildVocabularySummary(
words: VocabularyEntry[],
kanji: KanjiEntry[],
nowMs: number = Date.now(),
): VocabularySummary {
const weekAgoSec = nowMs / 1000 - 7 * 86_400;
const byDay = new Map<number, number>();
for (const word of words) {
const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen);
const day = Math.floor(firstSeenSec / 86_400);
byDay.set(day, (byDay.get(day) ?? 0) + 1);
}
return {
uniqueWords: words.length,
uniqueKanji: kanji.length,
newThisWeek: words.filter((word) => {
const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen);
return firstSeenSec >= weekAgoSec;
}).length,
topWords: [...words]
.sort((left, right) => right.frequency - left.frequency)
.slice(0, 12)
.map((word) => ({ label: word.headword, value: word.frequency })),
newWordsTimeline: Array.from(byDay.entries())
.sort(([left], [right]) => left - right)
.slice(-14)
.map(([day, count]) => ({
label: makeRollupLabel(day),
value: count,
})),
recentDiscoveries: [...words]
.sort((left, right) => {
const leftFirst = normalizeDbTimestampSeconds(left.firstSeen);
const rightFirst = normalizeDbTimestampSeconds(right.firstSeen);
return rightFirst - leftFirst;
})
.slice(0, 8),
};
}
export interface StreakCalendarPoint {
date: string;
value: number;
}
export function buildStreakCalendar(days: StreakCalendarDay[]): StreakCalendarPoint[] {
return days.map((d) => {
const dt = epochDayToDate(d.epochDay);
const y = dt.getUTCFullYear();
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
const day = String(dt.getUTCDate()).padStart(2, '0');
return { date: `${y}-${m}-${day}`, value: d.totalActiveMin };
});
}

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
confirmDayGroupDelete,
confirmEpisodeDelete,
confirmSessionDelete,
} from './delete-confirm';
test('confirmSessionDelete uses the shared session delete warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmSessionDelete(), true);
assert.deepEqual(calls, ['Delete this session and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmDayGroupDelete includes the day label and count in the warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmDayGroupDelete('Today', 3), true);
assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmDayGroupDelete uses singular for one session', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmDayGroupDelete('Yesterday', 1), true);
assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return false;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmEpisodeDelete('Episode 4'), false);
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
} finally {
globalThis.confirm = originalConfirm;
}
});

View File

@@ -0,0 +1,19 @@
export function confirmSessionDelete(): boolean {
return globalThis.confirm('Delete this session and all associated data?');
}
export function confirmDayGroupDelete(dayLabel: string, count: number): boolean {
return globalThis.confirm(
`Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`,
);
}
export function confirmAnimeGroupDelete(title: string, count: number): boolean {
return globalThis.confirm(
`Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`,
);
}
export function confirmEpisodeDelete(title: string): boolean {
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
}

View File

@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
test('formatRelativeDate: future timestamps return "just now"', () => {
assert.equal(formatRelativeDate(Date.now() + 60_000), 'just now');
});
test('formatRelativeDate: 0ms ago returns "just now"', () => {
assert.equal(formatRelativeDate(Date.now()), 'just now');
});
test('formatRelativeDate: 30s ago returns "just now"', () => {
assert.equal(formatRelativeDate(Date.now() - 30_000), 'just now');
});
test('formatRelativeDate: 5 minutes ago returns "5m ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 5 * 60_000), '5m ago');
});
test('formatRelativeDate: 59 minutes ago returns "59m ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 59 * 60_000), '59m ago');
});
test('formatRelativeDate: 2 hours ago returns "2h ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago');
});
test('formatRelativeDate: same calendar day can return "23h ago"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 23, 30, 0).getTime();
const sameDayMorning = new Date(2026, 2, 16, 0, 30, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(sameDayMorning), '23h ago');
} finally {
Date.now = realNow;
}
});
test('formatRelativeDate: two calendar days ago returns "2d ago"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 12, 0, 0).getTime();
const twoDaysAgo = new Date(2026, 2, 14, 0, 0, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(twoDaysAgo), '2d ago');
} finally {
Date.now = realNow;
}
});
test('formatRelativeDate: 5 days ago returns "5d ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 5 * 86_400_000), '5d ago');
});
test('formatRelativeDate: 10 days ago returns locale date string', () => {
const ts = Date.now() - 10 * 86_400_000;
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString());
});
test('formatRelativeDate: prior calendar day under 24h returns "Yesterday"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 0, 30, 0).getTime();
const previousDayLate = new Date(2026, 2, 15, 23, 45, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(previousDayLate), 'Yesterday');
} finally {
Date.now = realNow;
}
});
test('epochMsFromDbTimestamp converts seconds to ms', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000), 1_700_000_000_000);
});
test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
});
test('formatSessionDayLabel formats today and yesterday', () => {
const now = Date.now();
const oneDayMs = 24 * 60 * 60_000;
assert.equal(formatSessionDayLabel(now), 'Today');
assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday');
});
test('formatSessionDayLabel includes year for past-year dates', () => {
const now = new Date();
const sameDayLastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()).getTime();
const label = formatSessionDayLabel(sameDayLastYear);
const year = new Date(sameDayLastYear).getFullYear();
assert.ok(label.includes(String(year)));
const withoutYear = new Date(sameDayLastYear).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
});
assert.notEqual(label, withoutYear);
});

View File

@@ -0,0 +1,75 @@
export function formatDuration(ms: number): string {
const totalMin = Math.round(ms / 60_000);
if (totalMin < 60) return `${totalMin}m`;
const hours = Math.floor(totalMin / 60);
const mins = totalMin % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
export function formatNumber(n: number): string {
return n.toLocaleString();
}
export function formatPercent(ratio: number | null): string {
if (ratio == null) return '\u2014';
return `${Math.round(ratio * 100)}%`;
}
export function formatRelativeDate(ms: number): string {
const now = Date.now();
const diffMs = now - ms;
if (diffMs <= 0) return 'just now';
const nowDay = localDayFromMs(now);
const sessionDay = localDayFromMs(ms);
const dayDiff = nowDay - sessionDay;
if (dayDiff <= 0) {
if (diffMs < 60_000) return 'just now';
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHours = Math.floor(diffMs / 3_600_000);
return `${diffHours}h ago`;
}
if (dayDiff === 1) return 'Yesterday';
if (dayDiff < 7) return `${dayDiff}d ago`;
return new Date(ms).toLocaleDateString();
}
export function epochDayToDate(epochDay: number): Date {
return new Date(epochDay * 86_400_000);
}
export function localDayFromMs(ms: number): number {
const d = new Date(ms);
const localMidnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
return Math.floor(localMidnight / 86_400_000);
}
export function todayLocalDay(): number {
return localDayFromMs(Date.now());
}
// Immersion tracker stores word/kanji first_seen/last_seen as epoch seconds.
// Older fixtures or callers may still pass ms, so normalize defensively.
export function epochMsFromDbTimestamp(ts: number): number {
if (!Number.isFinite(ts)) return 0;
return ts < 10_000_000_000 ? Math.round(ts * 1000) : Math.round(ts);
}
export function formatSessionDayLabel(sessionStartedAtMs: number): string {
const today = todayLocalDay();
const day = localDayFromMs(sessionStartedAtMs);
if (day === today) return 'Today';
if (day === today - 1) return 'Yesterday';
const date = new Date(sessionStartedAtMs);
const includeYear = date.getFullYear() !== new Date().getFullYear();
return date.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
});
}

109
stats/src/lib/ipc-client.ts Normal file
View File

@@ -0,0 +1,109 @@
import type {
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
StatsAnkiNoteInfo,
} from '../types/stats';
interface StatsElectronAPI {
stats: {
getOverview: () => Promise<OverviewData>;
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
getSessions: (limit?: number) => Promise<SessionSummary[]>;
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
getSessionEvents: (id: number, limit?: number) => Promise<SessionEvent[]>;
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
getWordOccurrences: (
headword: string,
word: string,
reading: string,
limit?: number,
offset?: number,
) => Promise<VocabularyOccurrenceEntry[]>;
getKanji: (limit?: number) => Promise<KanjiEntry[]>;
getKanjiOccurrences: (
kanji: string,
limit?: number,
offset?: number,
) => Promise<VocabularyOccurrenceEntry[]>;
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
getAnimeCoverUrl: (animeId: number) => string;
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
getWordDetail: (wordId: number) => Promise<WordDetailData>;
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
hideOverlay: () => void;
};
}
declare global {
interface Window {
electronAPI?: StatsElectronAPI;
}
}
function getIpc(): StatsElectronAPI['stats'] {
const api = window.electronAPI?.stats;
if (!api) throw new Error('Electron IPC not available');
return api;
}
export const ipcClient = {
getOverview: () => getIpc().getOverview(),
getDailyRollups: (limit = 60) => getIpc().getDailyRollups(limit),
getMonthlyRollups: (limit = 24) => getIpc().getMonthlyRollups(limit),
getSessions: (limit = 50) => getIpc().getSessions(limit),
getSessionTimeline: (id: number, limit?: number) => getIpc().getSessionTimeline(id, limit),
getSessionEvents: (id: number, limit = 500) => getIpc().getSessionEvents(id, limit),
getVocabulary: (limit = 100) => getIpc().getVocabulary(limit),
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
getIpc().getWordOccurrences(headword, word, reading, limit, offset),
getKanji: (limit = 100) => getIpc().getKanji(limit),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
getIpc().getKanjiOccurrences(kanji, limit, offset),
getMediaLibrary: () => getIpc().getMediaLibrary(),
getMediaDetail: (videoId: number) => getIpc().getMediaDetail(videoId),
getAnimeLibrary: () => getIpc().getAnimeLibrary(),
getAnimeDetail: (animeId: number) => getIpc().getAnimeDetail(animeId),
getAnimeWords: (animeId: number, limit = 50) => getIpc().getAnimeWords(animeId, limit),
getAnimeRollups: (animeId: number, limit = 90) => getIpc().getAnimeRollups(animeId, limit),
getAnimeCoverUrl: (animeId: number) => getIpc().getAnimeCoverUrl(animeId),
getStreakCalendar: (days = 90) => getIpc().getStreakCalendar(days),
getEpisodesPerDay: (limit = 90) => getIpc().getEpisodesPerDay(limit),
getNewAnimePerDay: (limit = 90) => getIpc().getNewAnimePerDay(limit),
getWatchTimePerAnime: (limit = 90) => getIpc().getWatchTimePerAnime(limit),
getWordDetail: (wordId: number) => getIpc().getWordDetail(wordId),
getKanjiDetail: (kanjiId: number) => getIpc().getKanjiDetail(kanjiId),
getEpisodeDetail: (videoId: number) => getIpc().getEpisodeDetail(videoId),
ankiBrowse: (noteId: number) => getIpc().ankiBrowse(noteId),
ankiNotesInfo: (noteIds: number[]) => getIpc().ankiNotesInfo(noteIds),
};

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { MediaSessionList } from '../components/library/MediaSessionList';
test('MediaSessionList renders expandable session rows with delete affordance', () => {
const markup = renderToStaticMarkup(
<MediaSessionList
sessions={[
{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 9,
animeId: 3,
animeTitle: 'Anime',
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 1_000,
activeWatchedMs: 900,
linesSeen: 12,
tokensSeen: 24,
cardsMined: 2,
lookupCount: 3,
lookupHits: 2,
yomitanLookupCount: 1,
knownWordsSeen: 6,
knownWordRate: 25,
},
]}
onDeleteSession={() => {}}
initialExpandedSessionId={7}
/>,
);
assert.match(markup, /Session History/);
assert.match(markup, /aria-expanded="true"/);
assert.match(markup, /Delete session Episode 7/);
assert.match(markup, /words/);
assert.match(markup, /No word data for this session/);
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { fullReading } from './reading-utils';
describe('fullReading', () => {
it('prefixes leading hiragana from headword', () => {
// お前 with reading まえ → おまえ
expect(fullReading('お前', 'まえ')).toBe('おまえ');
});
it('handles katakana stored readings', () => {
// お前 with katakana reading マエ → おまえ
expect(fullReading('お前', 'マエ')).toBe('おまえ');
});
it('returns stored reading when it already includes leading kana', () => {
// Reading already correct
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
});
it('handles trailing hiragana', () => {
// 隠す with reading かくす — す is trailing hiragana
expect(fullReading('隠す', 'かくす')).toBe('かくす');
});
it('handles pure kanji headwords', () => {
expect(fullReading('様', 'さま')).toBe('さま');
});
it('returns empty for empty reading', () => {
expect(fullReading('前', '')).toBe('');
});
it('returns empty for empty headword', () => {
expect(fullReading('', 'まえ')).toBe('まえ');
});
it('handles all-kana headword', () => {
// Headword is already all hiragana
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
});
it('handles mixed leading and trailing kana', () => {
// お気に入り: お=leading, に入り=trailing around 気
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
});
it('handles katakana in headword', () => {
// カズマ様 — leading katakana + kanji
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
});
});

View File

@@ -0,0 +1,73 @@
function isHiragana(ch: string): boolean {
const code = ch.charCodeAt(0);
return code >= 0x3040 && code <= 0x309f;
}
function isKatakana(ch: string): boolean {
const code = ch.charCodeAt(0);
return code >= 0x30a0 && code <= 0x30ff;
}
function katakanaToHiragana(text: string): string {
let result = '';
for (const ch of text) {
const code = ch.charCodeAt(0);
if (code >= 0x30a1 && code <= 0x30f6) {
result += String.fromCharCode(code - 0x60);
} else {
result += ch;
}
}
return result;
}
/**
* Reconstruct the full word reading from the surface form and the stored
* (possibly partial) reading.
*
* MeCab/Yomitan sometimes stores only the kanji portion's reading. For example,
* お前 (surface) with reading まえ — the stored reading covers only 前, missing
* the leading お. This function walks through the surface form: hiragana/katakana
* characters pass through as-is (converted to hiragana), and the remaining kanji
* portion is filled in from the stored reading.
*/
export function fullReading(headword: string, storedReading: string): string {
if (!storedReading || !headword) return storedReading || '';
const reading = katakanaToHiragana(storedReading);
const leadingKana: string[] = [];
const trailingKana: string[] = [];
const chars = [...headword];
let i = 0;
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
leadingKana.push(katakanaToHiragana(chars[i]));
i++;
}
if (i === chars.length) {
return reading;
}
let j = chars.length - 1;
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
trailingKana.unshift(katakanaToHiragana(chars[j]));
j--;
}
// Strip matching trailing kana from the stored reading to get the core kanji reading
let coreReading = reading;
const trailStr = trailingKana.join('');
if (trailStr && coreReading.endsWith(trailStr)) {
coreReading = coreReading.slice(0, -trailStr.length);
}
// Strip matching leading kana from the stored reading if it already includes them
const leadStr = leadingKana.join('');
if (leadStr && coreReading.startsWith(leadStr)) {
return reading;
}
return leadStr + coreReading + trailStr;
}

View File

@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { SessionDetail, getKnownPctAxisMax } from '../components/sessions/SessionDetail';
import { buildSessionChartEvents } from './session-events';
import { EventType } from '../types/stats';
test('SessionDetail omits the misleading new words metric', () => {
const markup = renderToStaticMarkup(
<SessionDetail
session={{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 7,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 12,
tokensSeen: 24,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
}}
/>,
);
assert.match(markup, /No word data/);
assert.doesNotMatch(markup, /New words/);
});
test('buildSessionChartEvents keeps only chart-relevant events and pairs pause ranges', () => {
const chartEvents = buildSessionChartEvents([
{ eventType: EventType.SUBTITLE_LINE, tsMs: 1_000, payload: '{"line":"ignored"}' },
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
{ eventType: EventType.SEEK_FORWARD, tsMs: 3_000, payload: null },
{ eventType: EventType.PAUSE_END, tsMs: 4_000, payload: null },
{ eventType: EventType.CARD_MINED, tsMs: 5_000, payload: '{"cardsMined":1}' },
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 6_000, payload: null },
{ eventType: EventType.SEEK_BACKWARD, tsMs: 7_000, payload: null },
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
]);
assert.deepEqual(
chartEvents.seekEvents.map((event) => event.eventType),
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
);
assert.deepEqual(
chartEvents.cardEvents.map((event) => event.tsMs),
[5_000],
);
assert.deepEqual(
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
[6_000],
);
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
});
test('getKnownPctAxisMax adds headroom above the highest known percentage', () => {
assert.equal(getKnownPctAxisMax([22.4, 31.2, 29.8]), 40);
});
test('getKnownPctAxisMax caps the chart top at 100%', () => {
assert.equal(getKnownPctAxisMax([97.1, 98.6]), 100);
});

View File

@@ -0,0 +1,226 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventType } from '../types/stats';
import {
buildSessionChartEvents,
collectPendingSessionEventNoteIds,
extractSessionEventNoteInfo,
getSessionEventCardRequest,
mergeSessionEventNoteInfos,
projectSessionMarkerLeftPx,
resolveActiveSessionMarkerKey,
togglePinnedSessionMarkerKey,
} from './session-events';
test('buildSessionChartEvents produces typed hover markers with parsed payload metadata', () => {
const chartEvents = buildSessionChartEvents([
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
{
eventType: EventType.SEEK_FORWARD,
tsMs: 3_000,
payload: '{"fromMs":1000,"toMs":5500}',
},
{ eventType: EventType.PAUSE_END, tsMs: 5_000, payload: null },
{
eventType: EventType.CARD_MINED,
tsMs: 6_000,
payload: '{"cardsMined":2,"noteIds":[11,22]}',
},
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
]);
assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind),
['seek', 'pause', 'card'],
);
const seekMarker = chartEvents.markers[0]!;
assert.equal(seekMarker.kind, 'seek');
assert.equal(seekMarker.direction, 'forward');
assert.equal(seekMarker.fromMs, 1_000);
assert.equal(seekMarker.toMs, 5_500);
const pauseMarker = chartEvents.markers[1]!;
assert.equal(pauseMarker.kind, 'pause');
assert.equal(pauseMarker.startMs, 2_000);
assert.equal(pauseMarker.endMs, 5_000);
assert.equal(pauseMarker.durationMs, 3_000);
assert.equal(pauseMarker.anchorTsMs, 3_500);
const cardMarker = chartEvents.markers[2]!;
assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]);
assert.equal(cardMarker.cardsDelta, 2);
assert.deepEqual(
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
[7_000],
);
});
test('projectSessionMarkerLeftPx respects chart plot offsets instead of full-width percentages', () => {
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 1_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
5,
);
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 6_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
484,
);
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 11_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
963,
);
});
test('extractSessionEventNoteInfo prefers expression-like fields and strips html', () => {
const info = extractSessionEventNoteInfo({
noteId: 91,
fields: {
Sentence: { value: '<div>この呪いの剣は危険だ</div>' },
Vocabulary: { value: '<span>呪いの剣</span>' },
Meaning: { value: '<div>cursed sword</div>' },
},
});
assert.deepEqual(info, {
noteId: 91,
expression: '呪いの剣',
context: 'この呪いの剣は危険だ',
meaning: 'cursed sword',
});
});
test('extractSessionEventNoteInfo prefers explicit preview payload over field-name guessing', () => {
const info = extractSessionEventNoteInfo({
noteId: 92,
preview: {
word: '連れる',
sentence: 'このまま 連れてって',
translation: 'to take along',
},
fields: {
UnexpectedWordField: { value: 'should not win' },
UnexpectedSentenceField: { value: 'should not win either' },
},
});
assert.deepEqual(info, {
noteId: 92,
expression: '連れる',
context: 'このまま 連れてって',
meaning: 'to take along',
});
});
test('extractSessionEventNoteInfo ignores malformed notes without a numeric note id', () => {
assert.equal(
extractSessionEventNoteInfo({
noteId: Number.NaN,
fields: {
Vocabulary: { value: '呪い' },
},
}),
null,
);
});
test('mergeSessionEventNoteInfos keys previews by both requested and returned note ids', () => {
const noteInfos = mergeSessionEventNoteInfos(
[111],
[
{
noteId: 222,
fields: {
Expression: { value: '呪い' },
Sentence: { value: 'この剣は呪いだ' },
},
},
],
);
assert.deepEqual(noteInfos.get(111), {
noteId: 222,
expression: '呪い',
context: 'この剣は呪いだ',
meaning: null,
});
assert.deepEqual(noteInfos.get(222), {
noteId: 222,
expression: '呪い',
context: 'この剣は呪いだ',
meaning: null,
});
});
test('collectPendingSessionEventNoteIds supports strict-mode cleanup and refetch', () => {
const noteInfos = new Map();
const pendingNoteIds = new Set<number>();
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]);
pendingNoteIds.add(177);
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []);
pendingNoteIds.delete(177);
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]);
noteInfos.set(177, {
noteId: 177,
expression: '対抗',
context: 'ダクネス 無理して 対抗 するな',
meaning: null,
});
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []);
});
test('getSessionEventCardRequest stays stable across rebuilt marker objects', () => {
const events = [
{
eventType: EventType.CARD_MINED,
tsMs: 6_000,
payload: '{"cardsMined":1,"noteIds":[1773808840964]}',
},
];
const firstMarker = buildSessionChartEvents(events).markers[0]!;
const secondMarker = buildSessionChartEvents(events).markers[0]!;
assert.notEqual(firstMarker, secondMarker);
assert.deepEqual(getSessionEventCardRequest(firstMarker), {
noteIds: [1773808840964],
requestKey: 'card-6000:1773808840964',
});
assert.deepEqual(getSessionEventCardRequest(secondMarker), {
noteIds: [1773808840964],
requestKey: 'card-6000:1773808840964',
});
});
test('session marker pin helpers prefer pinned markers and toggle on repeat clicks', () => {
assert.equal(resolveActiveSessionMarkerKey('card-1', 'seek-2'), 'seek-2');
assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1');
assert.equal(togglePinnedSessionMarkerKey(null, 'card-1'), 'card-1');
assert.equal(togglePinnedSessionMarkerKey('card-1', 'card-1'), null);
assert.equal(togglePinnedSessionMarkerKey('card-1', 'seek-2'), 'seek-2');
});

View File

@@ -0,0 +1,384 @@
import { EventType, type SessionEvent } from '../types/stats';
export const SESSION_CHART_EVENT_TYPES = [
EventType.CARD_MINED,
EventType.SEEK_FORWARD,
EventType.SEEK_BACKWARD,
EventType.PAUSE_START,
EventType.PAUSE_END,
EventType.YOMITAN_LOOKUP,
] as const;
export interface PauseRegion {
startMs: number;
endMs: number;
}
export interface SessionChartEvents {
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
markers: SessionChartMarker[];
}
export interface SessionEventNoteInfo {
noteId: number;
expression: string;
context: string | null;
meaning: string | null;
}
export interface SessionChartPlotArea {
left: number;
width: number;
}
interface SessionEventNoteField {
value: string;
}
interface SessionEventNoteRecord {
noteId: unknown;
preview?: {
word?: unknown;
sentence?: unknown;
translation?: unknown;
} | null;
fields?: Record<string, SessionEventNoteField> | null;
}
export type SessionChartMarker =
| {
key: string;
kind: 'pause';
anchorTsMs: number;
eventTsMs: number;
startMs: number;
endMs: number;
durationMs: number;
}
| {
key: string;
kind: 'seek';
anchorTsMs: number;
eventTsMs: number;
direction: 'forward' | 'backward';
fromMs: number | null;
toMs: number | null;
}
| {
key: string;
kind: 'card';
anchorTsMs: number;
eventTsMs: number;
noteIds: number[];
cardsDelta: number;
};
function parsePayload(payload: string | null): Record<string, unknown> | null {
if (!payload) return null;
try {
const parsed = JSON.parse(payload);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function readNumberField(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function readNoteIds(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value.filter(
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry),
);
}
function stripHtml(value: string): string {
return value
.replace(/\[sound:[^\]]+\]/gi, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function pickFieldValue(
fields: Record<string, SessionEventNoteField>,
patterns: RegExp[],
excludeValues: Set<string> = new Set(),
): string | null {
const entries = Object.entries(fields);
for (const pattern of patterns) {
for (const [fieldName, field] of entries) {
if (!pattern.test(fieldName)) continue;
const cleaned = stripHtml(field?.value ?? '');
if (cleaned && !excludeValues.has(cleaned)) return cleaned;
}
}
return null;
}
function pickExpressionField(fields: Record<string, SessionEventNoteField>): string {
const entries = Object.entries(fields);
const preferredPatterns = [
/^(expression|word|vocab|vocabulary|target|target word|front)$/i,
/(expression|word|vocab|vocabulary|target)/i,
];
const preferredValue = pickFieldValue(fields, preferredPatterns);
if (preferredValue) return preferredValue;
for (const [, field] of entries) {
const cleaned = stripHtml(field?.value ?? '');
if (cleaned) return cleaned;
}
return '';
}
export function extractSessionEventNoteInfo(
note: SessionEventNoteRecord,
): SessionEventNoteInfo | null {
if (typeof note.noteId !== 'number' || !Number.isInteger(note.noteId) || note.noteId <= 0) {
return null;
}
const previewExpression =
typeof note.preview?.word === 'string' ? stripHtml(note.preview.word) : '';
const previewContext =
typeof note.preview?.sentence === 'string' ? stripHtml(note.preview.sentence) : '';
const previewMeaning =
typeof note.preview?.translation === 'string' ? stripHtml(note.preview.translation) : '';
if (previewExpression || previewContext || previewMeaning) {
return {
noteId: note.noteId,
expression: previewExpression,
context: previewContext || null,
meaning: previewMeaning || null,
};
}
const fields = note.fields ?? {};
const expression = pickExpressionField(fields);
const usedValues = new Set<string>(expression ? [expression] : []);
const context =
pickFieldValue(
fields,
[/^(sentence|context|example)$/i, /(sentence|context|example)/i],
usedValues,
) ?? null;
if (context) {
usedValues.add(context);
}
const meaning =
pickFieldValue(
fields,
[
/^(meaning|definition|gloss|translation|back)$/i,
/(meaning|definition|gloss|translation|back)/i,
],
usedValues,
) ?? null;
return {
noteId: note.noteId,
expression,
context,
meaning,
};
}
export function mergeSessionEventNoteInfos(
requestedNoteIds: number[],
notes: SessionEventNoteRecord[],
): Map<number, SessionEventNoteInfo> {
const next = new Map<number, SessionEventNoteInfo>();
notes.forEach((note, index) => {
const info = extractSessionEventNoteInfo(note);
if (!info) return;
next.set(info.noteId, info);
const requestedNoteId = requestedNoteIds[index];
if (requestedNoteId && requestedNoteId > 0) {
next.set(requestedNoteId, info);
}
});
return next;
}
export function collectPendingSessionEventNoteIds(
noteIds: number[],
noteInfos: ReadonlyMap<number, SessionEventNoteInfo>,
pendingNoteIds: ReadonlySet<number>,
): number[] {
const next: number[] = [];
const seen = new Set<number>();
for (const noteId of noteIds) {
if (!Number.isInteger(noteId) || noteId <= 0 || seen.has(noteId)) {
continue;
}
seen.add(noteId);
if (noteInfos.has(noteId) || pendingNoteIds.has(noteId)) {
continue;
}
next.push(noteId);
}
return next;
}
export function getSessionEventCardRequest(marker: SessionChartMarker | null): {
noteIds: number[];
requestKey: string | null;
} {
if (!marker || marker.kind !== 'card' || marker.noteIds.length === 0) {
return { noteIds: [], requestKey: null };
}
const noteIds = Array.from(
new Set(marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
);
return {
noteIds,
requestKey: noteIds.length > 0 ? `${marker.key}:${noteIds.join(',')}` : null,
};
}
export function resolveActiveSessionMarkerKey(
hoveredMarkerKey: string | null,
pinnedMarkerKey: string | null,
): string | null {
return pinnedMarkerKey ?? hoveredMarkerKey;
}
export function togglePinnedSessionMarkerKey(
currentPinnedMarkerKey: string | null,
nextMarkerKey: string,
): string | null {
return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey;
}
export function formatEventSeconds(ms: number | null): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
return `${(ms / 1000).toFixed(1)}s`;
}
export function projectSessionMarkerLeftPx({
anchorTsMs,
tsMin,
tsMax,
plotLeftPx,
plotWidthPx,
}: {
anchorTsMs: number;
tsMin: number;
tsMax: number;
plotLeftPx: number;
plotWidthPx: number;
}): number {
if (plotWidthPx <= 0) return plotLeftPx;
if (tsMax <= tsMin) return Math.round(plotLeftPx + plotWidthPx / 2);
const ratio = Math.max(0, Math.min(1, (anchorTsMs - tsMin) / (tsMax - tsMin)));
return Math.round(plotLeftPx + plotWidthPx * ratio);
}
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = [];
const seekEvents: SessionEvent[] = [];
const yomitanLookupEvents: SessionEvent[] = [];
const pauseRegions: PauseRegion[] = [];
const markers: SessionChartMarker[] = [];
let pendingPauseStartMs: number | null = null;
for (const event of events) {
switch (event.eventType) {
case EventType.CARD_MINED:
cardEvents.push(event);
{
const payload = parsePayload(event.payload);
markers.push({
key: `card-${event.tsMs}`,
kind: 'card',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
noteIds: readNoteIds(payload?.noteIds),
cardsDelta: readNumberField(payload?.cardsMined) ?? 1,
});
}
break;
case EventType.SEEK_FORWARD:
case EventType.SEEK_BACKWARD:
seekEvents.push(event);
{
const payload = parsePayload(event.payload);
markers.push({
key: `seek-${event.tsMs}-${event.eventType}`,
kind: 'seek',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
direction: event.eventType === EventType.SEEK_BACKWARD ? 'backward' : 'forward',
fromMs: readNumberField(payload?.fromMs),
toMs: readNumberField(payload?.toMs),
});
}
break;
case EventType.YOMITAN_LOOKUP:
yomitanLookupEvents.push(event);
break;
case EventType.PAUSE_START:
pendingPauseStartMs = event.tsMs;
break;
case EventType.PAUSE_END:
if (pendingPauseStartMs !== null) {
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: event.tsMs });
markers.push({
key: `pause-${pendingPauseStartMs}-${event.tsMs}`,
kind: 'pause',
anchorTsMs: pendingPauseStartMs + Math.round((event.tsMs - pendingPauseStartMs) / 2),
eventTsMs: pendingPauseStartMs,
startMs: pendingPauseStartMs,
endMs: event.tsMs,
durationMs: Math.max(0, event.tsMs - pendingPauseStartMs),
});
pendingPauseStartMs = null;
}
break;
default:
break;
}
}
if (pendingPauseStartMs !== null) {
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: pendingPauseStartMs + 2_000 });
markers.push({
key: `pause-${pendingPauseStartMs}-${pendingPauseStartMs + 2_000}`,
kind: 'pause',
anchorTsMs: pendingPauseStartMs + 1_000,
eventTsMs: pendingPauseStartMs,
startMs: pendingPauseStartMs,
endMs: pendingPauseStartMs + 2_000,
durationMs: 2_000,
});
}
markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs);
return {
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
};
}

View File

@@ -0,0 +1,7 @@
type SessionWordCountLike = {
tokensSeen: number;
};
export function getSessionDisplayWordCount(value: SessionWordCountLike): number {
return value.tokensSeen;
}

View File

@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
closeMediaDetail,
createInitialStatsView,
getSessionNavigationTarget,
navigateToAnime,
openAnimeEpisodeDetail,
openOverviewMediaDetail,
switchTab,
type StatsViewState,
} from './stats-navigation';
test('openAnimeEpisodeDetail opens dedicated media detail from anime context', () => {
const state = createInitialStatsView();
assert.deepEqual(openAnimeEpisodeDetail(state, 42, 7), {
activeTab: 'anime',
selectedAnimeId: 42,
focusedSessionId: null,
mediaDetail: {
videoId: 7,
initialSessionId: null,
origin: {
type: 'anime',
animeId: 42,
},
},
} satisfies StatsViewState);
});
test('closeMediaDetail returns to originating anime detail state', () => {
const state = openAnimeEpisodeDetail(navigateToAnime(createInitialStatsView(), 42), 42, 7);
assert.deepEqual(closeMediaDetail(state), {
activeTab: 'anime',
selectedAnimeId: 42,
focusedSessionId: null,
mediaDetail: null,
} satisfies StatsViewState);
});
test('openOverviewMediaDetail opens dedicated media detail from overview context', () => {
assert.deepEqual(openOverviewMediaDetail(createInitialStatsView(), 9), {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId: 9,
initialSessionId: null,
origin: {
type: 'overview',
},
},
} satisfies StatsViewState);
});
test('closeMediaDetail returns to overview when media detail originated there', () => {
const state = openOverviewMediaDetail(createInitialStatsView(), 9);
assert.deepEqual(closeMediaDetail(state), createInitialStatsView());
});
test('switchTab clears dedicated media detail state', () => {
const state = openAnimeEpisodeDetail(navigateToAnime(createInitialStatsView(), 42), 42, 7);
assert.deepEqual(switchTab(state, 'sessions'), {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
} satisfies StatsViewState);
});
test('getSessionNavigationTarget prefers media detail when video id exists', () => {
assert.deepEqual(getSessionNavigationTarget({ sessionId: 4, videoId: 12 }), {
type: 'media-detail',
videoId: 12,
sessionId: 4,
});
});
test('getSessionNavigationTarget falls back to session page when video id is missing', () => {
assert.deepEqual(getSessionNavigationTarget({ sessionId: 4, videoId: null }), {
type: 'session',
sessionId: 4,
});
});
test('openOverviewMediaDetail can carry a target session id for auto-expansion', () => {
assert.deepEqual(openOverviewMediaDetail(createInitialStatsView(), 9, 33), {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId: 9,
initialSessionId: 33,
origin: {
type: 'overview',
},
},
} satisfies StatsViewState);
});

View File

@@ -0,0 +1,166 @@
import type { SessionSummary } from '../types/stats';
import type { TabId } from '../components/layout/TabBar';
export type MediaDetailOrigin =
| { type: 'anime'; animeId: number }
| { type: 'overview' }
| { type: 'sessions' };
export interface MediaDetailState {
videoId: number;
initialSessionId: number | null;
origin: MediaDetailOrigin;
}
export interface StatsViewState {
activeTab: TabId;
selectedAnimeId: number | null;
focusedSessionId: number | null;
mediaDetail: MediaDetailState | null;
}
export function createInitialStatsView(): StatsViewState {
return {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
};
}
export function switchTab(state: StatsViewState, tabId: TabId): StatsViewState {
return {
activeTab: tabId,
selectedAnimeId: null,
focusedSessionId: tabId === 'sessions' ? state.focusedSessionId : null,
mediaDetail: null,
};
}
export function navigateToAnime(state: StatsViewState, animeId: number): StatsViewState {
return {
...state,
activeTab: 'anime',
selectedAnimeId: animeId,
mediaDetail: null,
};
}
export function navigateToSession(state: StatsViewState, sessionId: number): StatsViewState {
return {
...state,
activeTab: 'sessions',
focusedSessionId: sessionId,
mediaDetail: null,
};
}
export function openAnimeEpisodeDetail(
state: StatsViewState,
animeId: number,
videoId: number,
sessionId: number | null = null,
): StatsViewState {
return {
activeTab: 'anime',
selectedAnimeId: animeId,
focusedSessionId: null,
mediaDetail: {
videoId,
initialSessionId: sessionId,
origin: {
type: 'anime',
animeId,
},
},
};
}
export function openOverviewMediaDetail(
state: StatsViewState,
videoId: number,
sessionId: number | null = null,
): StatsViewState {
return {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId,
initialSessionId: sessionId,
origin: {
type: 'overview',
},
},
};
}
export function openSessionsMediaDetail(state: StatsViewState, videoId: number): StatsViewState {
return {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId,
initialSessionId: null,
origin: {
type: 'sessions',
},
},
};
}
export function closeMediaDetail(state: StatsViewState): StatsViewState {
if (!state.mediaDetail) {
return state;
}
if (state.mediaDetail.origin.type === 'overview') {
return {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
};
}
if (state.mediaDetail.origin.type === 'sessions') {
return {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
};
}
return {
activeTab: 'anime',
selectedAnimeId: state.mediaDetail.origin.animeId,
focusedSessionId: null,
mediaDetail: null,
};
}
export function getSessionNavigationTarget(session: Pick<SessionSummary, 'sessionId' | 'videoId'>):
| {
type: 'media-detail';
videoId: number;
sessionId: number;
}
| {
type: 'session';
sessionId: number;
} {
if (session.videoId != null) {
return {
type: 'media-detail',
videoId: session.videoId,
sessionId: session.sessionId,
};
}
return {
type: 'session',
sessionId: session.sessionId,
};
}

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { TabBar } from '../components/layout/TabBar';
import { EpisodeList } from '../components/anime/EpisodeList';
test('TabBar renders Library instead of Anime for the media library tab', () => {
const markup = renderToStaticMarkup(<TabBar activeTab="overview" onTabChange={() => {}} />);
assert.doesNotMatch(markup, />Anime</);
assert.match(markup, />Overview</);
assert.match(markup, />Library</);
});
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
const markup = renderToStaticMarkup(
<EpisodeList
episodes={[
{
videoId: 9,
episode: 9,
season: 1,
durationMs: 1,
endedMediaMs: null,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalTokensSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
onOpenDetail={() => {}}
/>,
);
assert.match(markup, />Details</);
assert.match(markup, /Episode 9/);
});

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const VOCABULARY_TAB_PATH = fileURLToPath(
new URL('../components/vocabulary/VocabularyTab.tsx', import.meta.url),
);
test('VocabularyTab declares all hooks before loading and error early returns', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
const loadingGuardIndex = source.indexOf('if (loading) {');
assert.notEqual(loadingGuardIndex, -1, 'expected loading early return');
const hooksAfterLoadingGuard = source
.slice(loadingGuardIndex)
.match(/\buse(?:State|Effect|Memo|Callback|Ref|Reducer)\s*\(/g);
assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
});
test('VocabularyTab memoizes summary and known-word aggregate calculations', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(
source,
/const summary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/,
);
assert.match(
source,
/const knownWordCount = useMemo\(\(\) => \{[\s\S]*for \(const w of filteredWords\) \{[\s\S]*knownWords\.has\(w\.headword\)[\s\S]*\}\s*return count;\s*\}, \[filteredWords, knownWords\]\);/,
);
});

View File

@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { MediaHeader } from '../components/library/MediaHeader';
import { EpisodeList } from '../components/anime/EpisodeList';
import { AnimeOverviewStats } from '../components/anime/AnimeOverviewStats';
import { SessionRow } from '../components/sessions/SessionRow';
import { EventType, type SessionEvent } from '../types/stats';
import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup';
test('buildLookupRateDisplay formats lookups per 100 words in short and long forms', () => {
assert.deepEqual(buildLookupRateDisplay(23, 1000), {
shortValue: '2.3 / 100 words',
longValue: '2.3 lookups per 100 words',
});
assert.equal(buildLookupRateDisplay(0, 0), null);
});
test('getYomitanLookupEvents keeps only Yomitan lookup events', () => {
const events: SessionEvent[] = [
{ eventType: EventType.LOOKUP, tsMs: 1, payload: null },
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 2, payload: null },
{ eventType: EventType.CARD_MINED, tsMs: 3, payload: null },
];
assert.deepEqual(
getYomitanLookupEvents(events).map((event) => event.tsMs),
[2],
);
});
test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
const markup = renderToStaticMarkup(
<MediaHeader
detail={{
videoId: 7,
canonicalTitle: 'Episode 7',
animeId: null,
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalTokensSeen: 1000,
totalLinesSeen: 120,
totalLookupCount: 30,
totalLookupHits: 21,
totalYomitanLookupCount: 23,
}}
/>,
);
assert.match(markup, /23/);
assert.match(markup, /2\.3 \/ 100 words/);
assert.match(markup, /2\.3 lookups per 100 words/);
});
test('MediaHeader distinguishes word occurrences from known unique words', () => {
const markup = renderToStaticMarkup(
<MediaHeader
detail={{
videoId: 7,
canonicalTitle: 'Episode 7',
animeId: null,
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalTokensSeen: 30,
totalLinesSeen: 120,
totalLookupCount: 30,
totalLookupHits: 21,
totalYomitanLookupCount: 0,
}}
initialKnownWordsSummary={{
knownWordCount: 17,
totalUniqueWords: 34,
}}
/>,
);
assert.match(markup, /word occurrences/);
assert.match(markup, /known unique words \(50%\)/);
assert.match(markup, /17 \/ 34/);
});
test('EpisodeList renders per-episode Yomitan lookup rate', () => {
const markup = renderToStaticMarkup(
<EpisodeList
episodes={[
{
videoId: 9,
episode: 9,
season: 1,
durationMs: 100,
endedMediaMs: 6,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 90,
totalCards: 1,
totalTokensSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
/>,
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.0 \/ 100 words/);
assert.match(markup, /6%/);
assert.doesNotMatch(markup, /90%/);
});
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
const markup = renderToStaticMarkup(
<AnimeOverviewStats
detail={{
animeId: 1,
canonicalTitle: 'Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
description: null,
totalSessions: 5,
totalActiveMs: 100_000,
totalCards: 8,
totalTokensSeen: 800,
totalLinesSeen: 100,
totalLookupCount: 50,
totalLookupHits: 30,
totalYomitanLookupCount: 16,
episodeCount: 3,
lastWatchedMs: 0,
}}
avgSessionMs={20_000}
knownWordsSummary={null}
/>,
);
assert.match(markup, /Lookups/);
assert.match(markup, /16/);
assert.match(markup, /2\.0 \/ 100 words/);
assert.match(markup, /Yomitan lookups per 100 words seen/);
});
test('SessionRow prefers word-based count when available', () => {
const markup = renderToStaticMarkup(
<SessionRow
session={{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 7,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 12,
tokensSeen: 42,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
}}
isExpanded={false}
detailsId="session-7"
onToggle={() => {}}
onDelete={() => {}}
/>,
);
assert.match(markup, />42</);
assert.doesNotMatch(markup, />12</);
});

View File

@@ -0,0 +1,25 @@
import type { SessionEvent } from '../types/stats';
import { EventType } from '../types/stats';
export interface LookupRateDisplay {
shortValue: string;
longValue: string;
}
export function buildLookupRateDisplay(
yomitanLookupCount: number,
tokensSeen: number,
): LookupRateDisplay | null {
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
return null;
}
const per100 = ((Math.max(0, yomitanLookupCount) / tokensSeen) * 100).toFixed(1);
return {
shortValue: `${per100} / 100 words`,
longValue: `${per100} lookups per 100 words`,
};
}
export function getYomitanLookupEvents(events: SessionEvent[]): SessionEvent[] {
return events.filter((event) => event.eventType === EventType.YOMITAN_LOOKUP);
}