fix(stats): use yomitan tokens for subtitle counts

This commit is contained in:
2026-03-17 22:33:08 -07:00
parent ecb41a490b
commit 8f39416ff5
35 changed files with 991 additions and 507 deletions

View File

@@ -30,7 +30,6 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
totalWatchedMs: 3_600_000,
activeWatchedMs: 3_000_000,
linesSeen: 20,
wordsSeen: 100,
tokensSeen: 80,
cardsMined: 2,
lookupCount: 10,
@@ -45,33 +44,32 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
totalSessions: 1,
totalActiveMin: 50,
totalLinesSeen: 20,
totalWordsSeen: 100,
totalTokensSeen: 80,
totalCards: 2,
cardsPerHour: 2.4,
wordsPerMin: 2,
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,
newWordsToday: 5,
newWordsThisWeek: 20,
},
};
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,
newWordsToday: 5,
newWordsThisWeek: 20,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.todayCards, 2);
@@ -88,8 +86,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
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',
@@ -101,7 +99,6 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
totalWatchedMs: 60_000,
activeWatchedMs: 60_000,
linesSeen: 10,
wordsSeen: 10,
tokensSeen: 10,
cardsMined: 10,
lookupCount: 1,
@@ -116,11 +113,10 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
totalSessions: 1,
totalActiveMin: 1,
totalLinesSeen: 10,
totalWordsSeen: 10,
totalTokensSeen: 10,
totalCards: 10,
cardsPerHour: 600,
wordsPerMin: 10,
tokensPerMin: 10,
lookupHitRate: 1,
},
],
@@ -182,11 +178,10 @@ test('buildTrendDashboard derives dense chart series', () => {
totalSessions: 2,
totalActiveMin: 60,
totalLinesSeen: 30,
totalWordsSeen: 120,
totalTokensSeen: 100,
totalCards: 3,
cardsPerHour: 3,
wordsPerMin: 2,
tokensPerMin: 2,
lookupHitRate: 0.5,
},
{
@@ -195,18 +190,17 @@ test('buildTrendDashboard derives dense chart series', () => {
totalSessions: 1,
totalActiveMin: 30,
totalLinesSeen: 10,
totalWordsSeen: 40,
totalTokensSeen: 30,
totalCards: 1,
cardsPerHour: 2,
wordsPerMin: 1.33,
tokensPerMin: 1.33,
lookupHitRate: 0.75,
},
];
const dashboard = buildTrendDashboard(rollups);
assert.equal(dashboard.watchTime.length, 2);
assert.equal(dashboard.words[1]?.value, 40);
assert.equal(dashboard.words[1]?.value, 30);
assert.equal(dashboard.sessions[0]?.value, 2);
});

View File

@@ -26,7 +26,7 @@ export interface OverviewSummary {
activeDays: number;
totalSessions: number;
lookupRate: number | null;
todayWords: number;
todayTokens: number;
newWordsToday: number;
newWordsThisWeek: number;
recentWatchTime: ChartPoint[];
@@ -100,7 +100,7 @@ function buildAggregatedDailyRows(rollups: DailyRollup[]) {
existing.activeMin += rollup.totalActiveMin;
existing.cards += rollup.totalCards;
existing.words += rollup.totalWordsSeen;
existing.words += rollup.totalTokensSeen;
existing.sessions += rollup.totalSessions;
if (rollup.lookupHitRate != null) {
const weight = Math.max(rollup.totalSessions, 1);
@@ -185,9 +185,9 @@ export function buildOverviewSummary(
overview.hints.totalLookupCount > 0
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
: null,
todayWords: Math.max(
todayTokens: Math.max(
todayRow?.words ?? 0,
sumBy(todaySessions, (session) => session.wordsSeen),
sumBy(todaySessions, (session) => session.tokensSeen),
),
newWordsToday: overview.hints.newWordsToday ?? 0,
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,

View File

@@ -18,7 +18,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
totalWatchedMs: 1_000,
activeWatchedMs: 900,
linesSeen: 12,
wordsSeen: 24,
tokensSeen: 24,
cardsMined: 2,
lookupCount: 3,
@@ -34,6 +33,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
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/);
assert.match(markup, /tokens/);
assert.match(markup, /No token data for this session/);
});

View File

@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { SessionDetail } 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(
@@ -17,7 +19,6 @@ test('SessionDetail omits the misleading new words metric', () => {
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 12,
wordsSeen: 24,
tokensSeen: 24,
cardsMined: 0,
lookupCount: 0,
@@ -27,6 +28,33 @@ test('SessionDetail omits the misleading new words metric', () => {
/>,
);
assert.match(markup, /Total words/);
assert.match(markup, /No token 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 }]);
});

View File

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

View File

@@ -26,7 +26,7 @@ test('EpisodeList renders explicit episode detail button alongside quick peek ro
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalWordsSeen: 350,
totalTokensSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},

View File

@@ -8,10 +8,10 @@ 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', () => {
test('buildLookupRateDisplay formats lookups per 100 tokens in short and long forms', () => {
assert.deepEqual(buildLookupRateDisplay(23, 1000), {
shortValue: '2.3 / 100 words',
longValue: '2.3 lookups per 100 words',
shortValue: '2.3 / 100 tokens',
longValue: '2.3 lookups per 100 tokens',
});
assert.equal(buildLookupRateDisplay(0, 0), null);
});
@@ -38,7 +38,7 @@ test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalWordsSeen: 1000,
totalTokensSeen: 1000,
totalLinesSeen: 120,
totalLookupCount: 30,
totalLookupHits: 21,
@@ -48,11 +48,11 @@ test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
);
assert.match(markup, /23/);
assert.match(markup, /2\.3 \/ 100 words/);
assert.match(markup, /2\.3 lookups per 100 words/);
assert.match(markup, /2\.3 \/ 100 tokens/);
assert.match(markup, /2\.3 lookups per 100 tokens/);
});
test('MediaHeader distinguishes word occurrences from known unique words', () => {
test('MediaHeader distinguishes token occurrences from known unique words', () => {
const markup = renderToStaticMarkup(
<MediaHeader
detail={{
@@ -61,7 +61,7 @@ test('MediaHeader distinguishes word occurrences from known unique words', () =>
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalWordsSeen: 30,
totalTokensSeen: 30,
totalLinesSeen: 120,
totalLookupCount: 30,
totalLookupHits: 21,
@@ -74,7 +74,7 @@ test('MediaHeader distinguishes word occurrences from known unique words', () =>
/>,
);
assert.match(markup, /word occurrences/);
assert.match(markup, /token occurrences/);
assert.match(markup, /known unique words \(50%\)/);
assert.match(markup, /17 \/ 34/);
});
@@ -93,7 +93,7 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalWordsSeen: 350,
totalTokensSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
@@ -102,7 +102,7 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.0 \/ 100 words/);
assert.match(markup, /2\.0 \/ 100 tokens/);
});
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
@@ -119,7 +119,7 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
totalSessions: 5,
totalActiveMs: 100_000,
totalCards: 8,
totalWordsSeen: 800,
totalTokensSeen: 800,
totalLinesSeen: 100,
totalLookupCount: 50,
totalLookupHits: 30,
@@ -134,8 +134,8 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
assert.match(markup, /Lookups/);
assert.match(markup, /16/);
assert.match(markup, /2\.0 \/ 100 words/);
assert.match(markup, /2\.0 lookups per 100 words/);
assert.match(markup, /2\.0 \/ 100 tokens/);
assert.match(markup, /Yomitan lookups per 100 tokens seen/);
});
test('SessionRow prefers token-based word count when available', () => {
@@ -152,7 +152,6 @@ test('SessionRow prefers token-based word count when available', () => {
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 12,
wordsSeen: 12,
tokensSeen: 42,
cardsMined: 0,
lookupCount: 0,

View File

@@ -8,15 +8,15 @@ export interface LookupRateDisplay {
export function buildLookupRateDisplay(
yomitanLookupCount: number,
wordsSeen: number,
tokensSeen: number,
): LookupRateDisplay | null {
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(wordsSeen) || wordsSeen <= 0) {
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
return null;
}
const per100 = ((Math.max(0, yomitanLookupCount) / wordsSeen) * 100).toFixed(1);
const per100 = ((Math.max(0, yomitanLookupCount) / tokensSeen) * 100).toFixed(1);
return {
shortValue: `${per100} / 100 words`,
longValue: `${per100} lookups per 100 words`,
shortValue: `${per100} / 100 tokens`,
longValue: `${per100} lookups per 100 tokens`,
};
}