mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-24 12:11:29 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
157
stats/src/lib/api-client.test.ts
Normal file
157
stats/src/lib/api-client.test.ts
Normal 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
220
stats/src/lib/api-client.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
38
stats/src/lib/app-lazy-loading.test.ts
Normal file
38
stats/src/lib/app-lazy-loading.test.ts
Normal 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';/,
|
||||
);
|
||||
});
|
||||
8
stats/src/lib/chart-theme.ts
Normal file
8
stats/src/lib/chart-theme.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const CHART_THEME = {
|
||||
tick: '#a5adcb',
|
||||
tooltipBg: '#363a4f',
|
||||
tooltipBorder: '#494d64',
|
||||
tooltipText: '#cad3f5',
|
||||
tooltipLabel: '#b8c0e0',
|
||||
barFill: '#8aadf4',
|
||||
} as const;
|
||||
232
stats/src/lib/dashboard-data.test.ts
Normal file
232
stats/src/lib/dashboard-data.test.ts
Normal 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);
|
||||
});
|
||||
272
stats/src/lib/dashboard-data.ts
Normal file
272
stats/src/lib/dashboard-data.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
71
stats/src/lib/delete-confirm.test.ts
Normal file
71
stats/src/lib/delete-confirm.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
19
stats/src/lib/delete-confirm.ts
Normal file
19
stats/src/lib/delete-confirm.ts
Normal 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?`);
|
||||
}
|
||||
101
stats/src/lib/formatters.test.ts
Normal file
101
stats/src/lib/formatters.test.ts
Normal 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);
|
||||
});
|
||||
75
stats/src/lib/formatters.ts
Normal file
75
stats/src/lib/formatters.ts
Normal 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
109
stats/src/lib/ipc-client.ts
Normal 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),
|
||||
};
|
||||
40
stats/src/lib/media-session-list.test.tsx
Normal file
40
stats/src/lib/media-session-list.test.tsx
Normal 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/);
|
||||
});
|
||||
51
stats/src/lib/reading-utils.test.ts
Normal file
51
stats/src/lib/reading-utils.test.ts
Normal 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('かずまさま');
|
||||
});
|
||||
});
|
||||
73
stats/src/lib/reading-utils.ts
Normal file
73
stats/src/lib/reading-utils.ts
Normal 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;
|
||||
}
|
||||
70
stats/src/lib/session-detail.test.tsx
Normal file
70
stats/src/lib/session-detail.test.tsx
Normal 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);
|
||||
});
|
||||
226
stats/src/lib/session-events.test.ts
Normal file
226
stats/src/lib/session-events.test.ts
Normal 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');
|
||||
});
|
||||
384
stats/src/lib/session-events.ts
Normal file
384
stats/src/lib/session-events.ts
Normal 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(/ /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,
|
||||
};
|
||||
}
|
||||
7
stats/src/lib/session-word-count.ts
Normal file
7
stats/src/lib/session-word-count.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
type SessionWordCountLike = {
|
||||
tokensSeen: number;
|
||||
};
|
||||
|
||||
export function getSessionDisplayWordCount(value: SessionWordCountLike): number {
|
||||
return value.tokensSeen;
|
||||
}
|
||||
103
stats/src/lib/stats-navigation.test.ts
Normal file
103
stats/src/lib/stats-navigation.test.ts
Normal 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);
|
||||
});
|
||||
166
stats/src/lib/stats-navigation.ts
Normal file
166
stats/src/lib/stats-navigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
41
stats/src/lib/stats-ui-navigation.test.tsx
Normal file
41
stats/src/lib/stats-ui-navigation.test.tsx
Normal 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/);
|
||||
});
|
||||
34
stats/src/lib/vocabulary-tab.test.ts
Normal file
34
stats/src/lib/vocabulary-tab.test.ts
Normal 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\]\);/,
|
||||
);
|
||||
});
|
||||
177
stats/src/lib/yomitan-lookup.test.tsx
Normal file
177
stats/src/lib/yomitan-lookup.test.tsx
Normal 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</);
|
||||
});
|
||||
25
stats/src/lib/yomitan-lookup.ts
Normal file
25
stats/src/lib/yomitan-lookup.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user