feat: overhaul stats dashboard with navigation, trends, and anime views

Add navigation state machine for tab/detail routing, anime overview
stats with Yomitan lookup rates, session word count accuracy fixes,
vocabulary tab hook order fix, simplified trends data fetching from
backend-aggregated endpoints, and improved session detail charts.
This commit is contained in:
2026-03-17 19:54:15 -07:00
parent 08a5401a7d
commit f8e2ae4887
39 changed files with 2578 additions and 871 deletions

View File

@@ -65,3 +65,55 @@ test('deleteSession throws when the stats API delete request fails', async () =>
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;
}
});

View File

@@ -17,6 +17,7 @@ import type {
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
TrendsDashboardData,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
@@ -73,6 +74,10 @@ export const apiClient = {
fetchJson<SessionTimelinePoint[]>(`/api/stats/sessions/${id}/timeline?limit=${limit}`),
getSessionEvents: (id: number, limit = 500) =>
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
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) =>
@@ -101,6 +106,10 @@ export const apiClient = {
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) =>
@@ -117,10 +126,27 @@ export const apiClient = {
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<{

View File

@@ -35,6 +35,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
cardsMined: 2,
lookupCount: 10,
lookupHits: 8,
yomitanLookupCount: 0,
},
];
const rollups: DailyRollup[] = [
@@ -56,7 +57,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
sessions,
rollups,
hints: {
totalSessions: 1,
totalSessions: 15,
activeSessions: 0,
episodesToday: 2,
activeAnimeCount: 3,
@@ -65,6 +66,10 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
totalActiveMin: 50,
activeDays: 2,
totalCards: 9,
totalLookupCount: 100,
totalLookupHits: 80,
newWordsToday: 5,
newWordsThisWeek: 20,
},
};
@@ -74,15 +79,17 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
assert.equal(summary.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50);
assert.equal(summary.allTimeHours, 1);
assert.equal(summary.allTimeMinutes, 50);
assert.equal(summary.activeDays, 2);
assert.equal(summary.totalSessions, 15);
assert.equal(summary.lookupRate, 80);
});
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: [
const overview: OverviewData = {
sessions: [
{
sessionId: 2,
canonicalTitle: 'B',
@@ -99,6 +106,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
cardsMined: 10,
lookupCount: 1,
lookupHits: 1,
yomitanLookupCount: 0,
},
],
rollups: [
@@ -117,7 +125,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
},
],
hints: {
totalSessions: 999,
totalSessions: 50,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
@@ -126,13 +134,16 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
totalActiveMin: 120,
activeDays: 40,
totalCards: 5,
totalLookupCount: 0,
totalLookupHits: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.totalTrackedCards, 5);
assert.equal(summary.totalSessions, 999);
assert.equal(summary.allTimeHours, 2);
assert.equal(summary.allTimeMinutes, 120);
assert.equal(summary.activeDays, 40);
});
@@ -150,6 +161,8 @@ test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
pos2: null,
pos3: null,
frequency: 4,
frequencyRank: null,
animeCount: 1,
firstSeen: nowSec - 2 * 86_400,
lastSeen: nowSec - 1,
},

View File

@@ -16,15 +16,19 @@ export interface OverviewSummary {
todayActiveMs: number;
todayCards: number;
streakDays: number;
allTimeHours: number;
allTimeMinutes: number;
totalTrackedCards: number;
episodesToday: number;
activeAnimeCount: number;
totalEpisodesWatched: number;
totalAnimeCompleted: number;
averageSessionMinutes: number;
totalSessions: number;
activeDays: number;
totalSessions: number;
lookupRate: number | null;
todayWords: number;
newWordsToday: number;
newWordsThisWeek: number;
recentWatchTime: ChartPoint[];
}
@@ -161,7 +165,7 @@ export function buildOverviewSummary(
sumBy(todaySessions, (session) => session.cardsMined),
),
streakDays,
allTimeHours: Math.max(0, Math.round(totalActiveMin / 60)),
allTimeMinutes: Math.max(0, Math.round(totalActiveMin)),
totalTrackedCards: lifetimeCards,
episodesToday: overview.hints.episodesToday ?? 0,
activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
@@ -175,8 +179,18 @@ export function buildOverviewSummary(
60_000,
)
: 0,
totalSessions: overview.hints.totalSessions,
activeDays: overview.hints.activeDays ?? daysWithActivity.size,
totalSessions: overview.hints.totalSessions ?? overview.sessions.length,
lookupRate:
overview.hints.totalLookupCount > 0
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
: null,
todayWords: Math.max(
todayRow?.words ?? 0,
sumBy(todaySessions, (session) => session.wordsSeen),
),
newWordsToday: overview.hints.newWordsToday ?? 0,
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,
recentWatchTime: aggregated
.slice(-14)
.map((row) => ({ label: row.label, value: row.activeMin })),

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
import { confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
test('confirmSessionDelete uses the shared session delete warning copy', () => {
const calls: string[] = [];
@@ -18,6 +18,38 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => {
}
});
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;

View File

@@ -2,6 +2,18 @@ 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,39 @@
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,
wordsSeen: 24,
tokensSeen: 24,
cardsMined: 2,
lookupCount: 3,
lookupHits: 2,
yomitanLookupCount: 1,
},
]}
onDeleteSession={() => {}}
initialExpandedSessionId={7}
/>,
);
assert.match(markup, /Session History/);
assert.match(markup, /aria-expanded="true"/);
assert.match(markup, /Delete session Episode 7/);
assert.match(markup, /Total words/);
assert.match(markup, /1 Yomitan lookup/);
});

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { SessionDetail } from '../components/sessions/SessionDetail';
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,
wordsSeen: 24,
tokensSeen: 24,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
}}
/>,
);
assert.match(markup, /Total words/);
assert.doesNotMatch(markup, /New words/);
});

View File

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

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,139 @@
import type { SessionSummary } from '../types/stats';
import type { TabId } from '../components/layout/TabBar';
export type MediaDetailOrigin = { type: 'anime'; animeId: number } | { type: 'overview' };
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 closeMediaDetail(state: StatsViewState): StatsViewState {
if (!state.mediaDetail) {
return state;
}
if (state.mediaDetail.origin.type === 'overview') {
return {
activeTab: 'overview',
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,40 @@
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,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalWordsSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
onOpenDetail={() => {}}
/>,
);
assert.match(markup, />Details</);
assert.match(markup, /Episode 9/);
});

View File

@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const VOCABULARY_TAB_PATH = path.resolve(
import.meta.dir,
'../components/vocabulary/VocabularyTab.tsx',
);
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 ?? [], []);
});

View File

@@ -0,0 +1,171 @@
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',
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalWordsSeen: 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',
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalWordsSeen: 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: 1,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalWordsSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
/>,
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.0 \/ 100 words/);
});
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,
totalWordsSeen: 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, /2\.0 lookups per 100 words/);
});
test('SessionRow prefers token-based word 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,
wordsSeen: 12,
tokensSeen: 42,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 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,
wordsSeen: number,
): LookupRateDisplay | null {
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(wordsSeen) || wordsSeen <= 0) {
return null;
}
const per100 = ((Math.max(0, yomitanLookupCount) / wordsSeen) * 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);
}