mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
feat: overhaul stats dashboard with navigation, trends, and anime views
Add navigation state machine for tab/detail routing, anime overview stats with Yomitan lookup rates, session word count accuracy fixes, vocabulary tab hook order fix, simplified trends data fetching from backend-aggregated endpoints, and improved session detail charts.
This commit is contained in:
@@ -65,3 +65,55 @@ test('deleteSession throws when the stats API delete request fails', async () =>
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard requests the chart-ready trends endpoint with range and grouping', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrl = String(input);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
activity: { watchTime: [], cards: [], words: [], sessions: [] },
|
||||
progress: {
|
||||
watchTime: [],
|
||||
sessions: [],
|
||||
words: [],
|
||||
newWords: [],
|
||||
cards: [],
|
||||
episodes: [],
|
||||
lookups: [],
|
||||
},
|
||||
ratios: { lookupsPerHundred: [] },
|
||||
animePerDay: {
|
||||
episodes: [],
|
||||
watchTime: [],
|
||||
cards: [],
|
||||
words: [],
|
||||
lookups: [],
|
||||
lookupsPerHundred: [],
|
||||
},
|
||||
animeCumulative: {
|
||||
watchTime: [],
|
||||
episodes: [],
|
||||
cards: [],
|
||||
words: [],
|
||||
},
|
||||
patterns: {
|
||||
watchTimeByDayOfWeek: [],
|
||||
watchTimeByHour: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.getTrendsDashboard('90d', 'month');
|
||||
assert.equal(
|
||||
seenUrl,
|
||||
`${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
TrendsDashboardData,
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
@@ -73,6 +74,10 @@ export const apiClient = {
|
||||
fetchJson<SessionTimelinePoint[]>(`/api/stats/sessions/${id}/timeline?limit=${limit}`),
|
||||
getSessionEvents: (id: number, limit = 500) =>
|
||||
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
|
||||
getSessionKnownWordsTimeline: (id: number) =>
|
||||
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number }>>(
|
||||
`/api/stats/sessions/${id}/known-words-timeline`,
|
||||
),
|
||||
getVocabulary: (limit = 100) =>
|
||||
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
|
||||
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
||||
@@ -101,6 +106,10 @@ export const apiClient = {
|
||||
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||
getWatchTimePerAnime: (limit = 90) =>
|
||||
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||
getTrendsDashboard: (range: '7d' | '30d' | '90d' | 'all', groupBy: 'day' | 'month') =>
|
||||
fetchJson<TrendsDashboardData>(
|
||||
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
|
||||
),
|
||||
getWordDetail: (wordId: number) =>
|
||||
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
|
||||
getKanjiDetail: (kanjiId: number) =>
|
||||
@@ -117,10 +126,27 @@ export const apiClient = {
|
||||
deleteSession: async (sessionId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
},
|
||||
deleteSessions: async (sessionIds: number[]): Promise<void> => {
|
||||
await fetchResponse('/api/stats/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionIds }),
|
||||
});
|
||||
},
|
||||
deleteVideo: async (videoId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
|
||||
},
|
||||
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
|
||||
getKnownWordsSummary: () =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>('/api/stats/known-words-summary'),
|
||||
getAnimeKnownWordsSummary: (animeId: number) =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||
`/api/stats/anime/${animeId}/known-words-summary`,
|
||||
),
|
||||
getMediaKnownWordsSummary: (videoId: number) =>
|
||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
||||
`/api/stats/media/${videoId}/known-words-summary`,
|
||||
),
|
||||
searchAnilist: (query: string) =>
|
||||
fetchJson<
|
||||
Array<{
|
||||
|
||||
@@ -35,6 +35,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
cardsMined: 2,
|
||||
lookupCount: 10,
|
||||
lookupHits: 8,
|
||||
yomitanLookupCount: 0,
|
||||
},
|
||||
];
|
||||
const rollups: DailyRollup[] = [
|
||||
@@ -56,7 +57,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
sessions,
|
||||
rollups,
|
||||
hints: {
|
||||
totalSessions: 1,
|
||||
totalSessions: 15,
|
||||
activeSessions: 0,
|
||||
episodesToday: 2,
|
||||
activeAnimeCount: 3,
|
||||
@@ -65,6 +66,10 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
totalActiveMin: 50,
|
||||
activeDays: 2,
|
||||
totalCards: 9,
|
||||
totalLookupCount: 100,
|
||||
totalLookupHits: 80,
|
||||
newWordsToday: 5,
|
||||
newWordsThisWeek: 20,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -74,15 +79,17 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
assert.equal(summary.episodesToday, 2);
|
||||
assert.equal(summary.activeAnimeCount, 3);
|
||||
assert.equal(summary.averageSessionMinutes, 50);
|
||||
assert.equal(summary.allTimeHours, 1);
|
||||
assert.equal(summary.allTimeMinutes, 50);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.totalSessions, 15);
|
||||
assert.equal(summary.lookupRate, 80);
|
||||
});
|
||||
|
||||
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
||||
const now = Date.UTC(2026, 2, 13, 12);
|
||||
const today = Math.floor(now / 86_400_000);
|
||||
const overview: OverviewData = {
|
||||
sessions: [
|
||||
const overview: OverviewData = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 2,
|
||||
canonicalTitle: 'B',
|
||||
@@ -99,6 +106,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
cardsMined: 10,
|
||||
lookupCount: 1,
|
||||
lookupHits: 1,
|
||||
yomitanLookupCount: 0,
|
||||
},
|
||||
],
|
||||
rollups: [
|
||||
@@ -117,7 +125,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
},
|
||||
],
|
||||
hints: {
|
||||
totalSessions: 999,
|
||||
totalSessions: 50,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
@@ -126,13 +134,16 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
totalActiveMin: 120,
|
||||
activeDays: 40,
|
||||
totalCards: 5,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildOverviewSummary(overview, now);
|
||||
assert.equal(summary.totalTrackedCards, 5);
|
||||
assert.equal(summary.totalSessions, 999);
|
||||
assert.equal(summary.allTimeHours, 2);
|
||||
assert.equal(summary.allTimeMinutes, 120);
|
||||
assert.equal(summary.activeDays, 40);
|
||||
});
|
||||
|
||||
@@ -150,6 +161,8 @@ test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
|
||||
pos2: null,
|
||||
pos3: null,
|
||||
frequency: 4,
|
||||
frequencyRank: null,
|
||||
animeCount: 1,
|
||||
firstSeen: nowSec - 2 * 86_400,
|
||||
lastSeen: nowSec - 1,
|
||||
},
|
||||
|
||||
@@ -16,15 +16,19 @@ export interface OverviewSummary {
|
||||
todayActiveMs: number;
|
||||
todayCards: number;
|
||||
streakDays: number;
|
||||
allTimeHours: number;
|
||||
allTimeMinutes: number;
|
||||
totalTrackedCards: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
totalEpisodesWatched: number;
|
||||
totalAnimeCompleted: number;
|
||||
averageSessionMinutes: number;
|
||||
totalSessions: number;
|
||||
activeDays: number;
|
||||
totalSessions: number;
|
||||
lookupRate: number | null;
|
||||
todayWords: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
recentWatchTime: ChartPoint[];
|
||||
}
|
||||
|
||||
@@ -161,7 +165,7 @@ export function buildOverviewSummary(
|
||||
sumBy(todaySessions, (session) => session.cardsMined),
|
||||
),
|
||||
streakDays,
|
||||
allTimeHours: Math.max(0, Math.round(totalActiveMin / 60)),
|
||||
allTimeMinutes: Math.max(0, Math.round(totalActiveMin)),
|
||||
totalTrackedCards: lifetimeCards,
|
||||
episodesToday: overview.hints.episodesToday ?? 0,
|
||||
activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
|
||||
@@ -175,8 +179,18 @@ export function buildOverviewSummary(
|
||||
60_000,
|
||||
)
|
||||
: 0,
|
||||
totalSessions: overview.hints.totalSessions,
|
||||
activeDays: overview.hints.activeDays ?? daysWithActivity.size,
|
||||
totalSessions: overview.hints.totalSessions ?? overview.sessions.length,
|
||||
lookupRate:
|
||||
overview.hints.totalLookupCount > 0
|
||||
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
|
||||
: null,
|
||||
todayWords: Math.max(
|
||||
todayRow?.words ?? 0,
|
||||
sumBy(todaySessions, (session) => session.wordsSeen),
|
||||
),
|
||||
newWordsToday: overview.hints.newWordsToday ?? 0,
|
||||
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,
|
||||
recentWatchTime: aggregated
|
||||
.slice(-14)
|
||||
.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
|
||||
import { confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
|
||||
|
||||
test('confirmSessionDelete uses the shared session delete warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
@@ -18,6 +18,38 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmDayGroupDelete includes the day label and count in the warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
calls.push(message ?? '');
|
||||
return true;
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(confirmDayGroupDelete('Today', 3), true);
|
||||
assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmDayGroupDelete uses singular for one session', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
globalThis.confirm = ((message?: string) => {
|
||||
calls.push(message ?? '');
|
||||
return true;
|
||||
}) as typeof globalThis.confirm;
|
||||
|
||||
try {
|
||||
assert.equal(confirmDayGroupDelete('Yesterday', 1), true);
|
||||
assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']);
|
||||
} finally {
|
||||
globalThis.confirm = originalConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
|
||||
const calls: string[] = [];
|
||||
const originalConfirm = globalThis.confirm;
|
||||
|
||||
@@ -2,6 +2,18 @@ export function confirmSessionDelete(): boolean {
|
||||
return globalThis.confirm('Delete this session and all associated data?');
|
||||
}
|
||||
|
||||
export function confirmDayGroupDelete(dayLabel: string, count: number): boolean {
|
||||
return globalThis.confirm(
|
||||
`Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmAnimeGroupDelete(title: string, count: number): boolean {
|
||||
return globalThis.confirm(
|
||||
`Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmEpisodeDelete(title: string): boolean {
|
||||
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
|
||||
}
|
||||
|
||||
39
stats/src/lib/media-session-list.test.tsx
Normal file
39
stats/src/lib/media-session-list.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MediaSessionList } from '../components/library/MediaSessionList';
|
||||
|
||||
test('MediaSessionList renders expandable session rows with delete affordance', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MediaSessionList
|
||||
sessions={[
|
||||
{
|
||||
sessionId: 7,
|
||||
canonicalTitle: 'Episode 7',
|
||||
videoId: 9,
|
||||
animeId: 3,
|
||||
animeTitle: 'Anime',
|
||||
startedAtMs: 0,
|
||||
endedAtMs: null,
|
||||
totalWatchedMs: 1_000,
|
||||
activeWatchedMs: 900,
|
||||
linesSeen: 12,
|
||||
wordsSeen: 24,
|
||||
tokensSeen: 24,
|
||||
cardsMined: 2,
|
||||
lookupCount: 3,
|
||||
lookupHits: 2,
|
||||
yomitanLookupCount: 1,
|
||||
},
|
||||
]}
|
||||
onDeleteSession={() => {}}
|
||||
initialExpandedSessionId={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Session History/);
|
||||
assert.match(markup, /aria-expanded="true"/);
|
||||
assert.match(markup, /Delete session Episode 7/);
|
||||
assert.match(markup, /Total words/);
|
||||
assert.match(markup, /1 Yomitan lookup/);
|
||||
});
|
||||
32
stats/src/lib/session-detail.test.tsx
Normal file
32
stats/src/lib/session-detail.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SessionDetail } from '../components/sessions/SessionDetail';
|
||||
|
||||
test('SessionDetail omits the misleading new words metric', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionDetail
|
||||
session={{
|
||||
sessionId: 7,
|
||||
canonicalTitle: 'Episode 7',
|
||||
videoId: 7,
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
startedAtMs: 0,
|
||||
endedAtMs: null,
|
||||
totalWatchedMs: 0,
|
||||
activeWatchedMs: 0,
|
||||
linesSeen: 12,
|
||||
wordsSeen: 24,
|
||||
tokensSeen: 24,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Total words/);
|
||||
assert.doesNotMatch(markup, /New words/);
|
||||
});
|
||||
8
stats/src/lib/session-word-count.ts
Normal file
8
stats/src/lib/session-word-count.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
type SessionWordCountLike = {
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
};
|
||||
|
||||
export function getSessionDisplayWordCount(value: SessionWordCountLike): number {
|
||||
return value.tokensSeen > 0 ? value.tokensSeen : value.wordsSeen;
|
||||
}
|
||||
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);
|
||||
});
|
||||
139
stats/src/lib/stats-navigation.ts
Normal file
139
stats/src/lib/stats-navigation.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { SessionSummary } from '../types/stats';
|
||||
import type { TabId } from '../components/layout/TabBar';
|
||||
|
||||
export type MediaDetailOrigin = { type: 'anime'; animeId: number } | { type: 'overview' };
|
||||
|
||||
export interface MediaDetailState {
|
||||
videoId: number;
|
||||
initialSessionId: number | null;
|
||||
origin: MediaDetailOrigin;
|
||||
}
|
||||
|
||||
export interface StatsViewState {
|
||||
activeTab: TabId;
|
||||
selectedAnimeId: number | null;
|
||||
focusedSessionId: number | null;
|
||||
mediaDetail: MediaDetailState | null;
|
||||
}
|
||||
|
||||
export function createInitialStatsView(): StatsViewState {
|
||||
return {
|
||||
activeTab: 'overview',
|
||||
selectedAnimeId: null,
|
||||
focusedSessionId: null,
|
||||
mediaDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function switchTab(state: StatsViewState, tabId: TabId): StatsViewState {
|
||||
return {
|
||||
activeTab: tabId,
|
||||
selectedAnimeId: null,
|
||||
focusedSessionId: tabId === 'sessions' ? state.focusedSessionId : null,
|
||||
mediaDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function navigateToAnime(state: StatsViewState, animeId: number): StatsViewState {
|
||||
return {
|
||||
...state,
|
||||
activeTab: 'anime',
|
||||
selectedAnimeId: animeId,
|
||||
mediaDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function navigateToSession(state: StatsViewState, sessionId: number): StatsViewState {
|
||||
return {
|
||||
...state,
|
||||
activeTab: 'sessions',
|
||||
focusedSessionId: sessionId,
|
||||
mediaDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function openAnimeEpisodeDetail(
|
||||
state: StatsViewState,
|
||||
animeId: number,
|
||||
videoId: number,
|
||||
sessionId: number | null = null,
|
||||
): StatsViewState {
|
||||
return {
|
||||
activeTab: 'anime',
|
||||
selectedAnimeId: animeId,
|
||||
focusedSessionId: null,
|
||||
mediaDetail: {
|
||||
videoId,
|
||||
initialSessionId: sessionId,
|
||||
origin: {
|
||||
type: 'anime',
|
||||
animeId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function openOverviewMediaDetail(
|
||||
state: StatsViewState,
|
||||
videoId: number,
|
||||
sessionId: number | null = null,
|
||||
): StatsViewState {
|
||||
return {
|
||||
activeTab: 'overview',
|
||||
selectedAnimeId: null,
|
||||
focusedSessionId: null,
|
||||
mediaDetail: {
|
||||
videoId,
|
||||
initialSessionId: sessionId,
|
||||
origin: {
|
||||
type: 'overview',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function closeMediaDetail(state: StatsViewState): StatsViewState {
|
||||
if (!state.mediaDetail) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.mediaDetail.origin.type === 'overview') {
|
||||
return {
|
||||
activeTab: 'overview',
|
||||
selectedAnimeId: null,
|
||||
focusedSessionId: null,
|
||||
mediaDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab: 'anime',
|
||||
selectedAnimeId: state.mediaDetail.origin.animeId,
|
||||
focusedSessionId: null,
|
||||
mediaDetail: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSessionNavigationTarget(session: Pick<SessionSummary, 'sessionId' | 'videoId'>):
|
||||
| {
|
||||
type: 'media-detail';
|
||||
videoId: number;
|
||||
sessionId: number;
|
||||
}
|
||||
| {
|
||||
type: 'session';
|
||||
sessionId: number;
|
||||
} {
|
||||
if (session.videoId != null) {
|
||||
return {
|
||||
type: 'media-detail',
|
||||
videoId: session.videoId,
|
||||
sessionId: session.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'session',
|
||||
sessionId: session.sessionId,
|
||||
};
|
||||
}
|
||||
40
stats/src/lib/stats-ui-navigation.test.tsx
Normal file
40
stats/src/lib/stats-ui-navigation.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 { TabBar } from '../components/layout/TabBar';
|
||||
import { EpisodeList } from '../components/anime/EpisodeList';
|
||||
|
||||
test('TabBar renders Library instead of Anime for the media library tab', () => {
|
||||
const markup = renderToStaticMarkup(<TabBar activeTab="overview" onTabChange={() => {}} />);
|
||||
|
||||
assert.doesNotMatch(markup, />Anime</);
|
||||
assert.match(markup, />Overview</);
|
||||
assert.match(markup, />Library</);
|
||||
});
|
||||
|
||||
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EpisodeList
|
||||
episodes={[
|
||||
{
|
||||
videoId: 9,
|
||||
episode: 9,
|
||||
season: 1,
|
||||
durationMs: 1,
|
||||
watched: 0,
|
||||
canonicalTitle: 'Episode 9',
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1,
|
||||
totalCards: 1,
|
||||
totalWordsSeen: 350,
|
||||
totalYomitanLookupCount: 7,
|
||||
lastWatchedMs: 0,
|
||||
},
|
||||
]}
|
||||
onOpenDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, />Details</);
|
||||
assert.match(markup, /Episode 9/);
|
||||
});
|
||||
22
stats/src/lib/vocabulary-tab.test.ts
Normal file
22
stats/src/lib/vocabulary-tab.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const VOCABULARY_TAB_PATH = path.resolve(
|
||||
import.meta.dir,
|
||||
'../components/vocabulary/VocabularyTab.tsx',
|
||||
);
|
||||
|
||||
test('VocabularyTab declares all hooks before loading and error early returns', () => {
|
||||
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
|
||||
const loadingGuardIndex = source.indexOf('if (loading) {');
|
||||
|
||||
assert.notEqual(loadingGuardIndex, -1, 'expected loading early return');
|
||||
|
||||
const hooksAfterLoadingGuard = source
|
||||
.slice(loadingGuardIndex)
|
||||
.match(/\buse(?:State|Effect|Memo|Callback|Ref|Reducer)\s*\(/g);
|
||||
|
||||
assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
|
||||
});
|
||||
171
stats/src/lib/yomitan-lookup.test.tsx
Normal file
171
stats/src/lib/yomitan-lookup.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MediaHeader } from '../components/library/MediaHeader';
|
||||
import { EpisodeList } from '../components/anime/EpisodeList';
|
||||
import { AnimeOverviewStats } from '../components/anime/AnimeOverviewStats';
|
||||
import { SessionRow } from '../components/sessions/SessionRow';
|
||||
import { EventType, type SessionEvent } from '../types/stats';
|
||||
import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup';
|
||||
|
||||
test('buildLookupRateDisplay formats lookups per 100 words in short and long forms', () => {
|
||||
assert.deepEqual(buildLookupRateDisplay(23, 1000), {
|
||||
shortValue: '2.3 / 100 words',
|
||||
longValue: '2.3 lookups per 100 words',
|
||||
});
|
||||
assert.equal(buildLookupRateDisplay(0, 0), null);
|
||||
});
|
||||
|
||||
test('getYomitanLookupEvents keeps only Yomitan lookup events', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{ eventType: EventType.LOOKUP, tsMs: 1, payload: null },
|
||||
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 2, payload: null },
|
||||
{ eventType: EventType.CARD_MINED, tsMs: 3, payload: null },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
getYomitanLookupEvents(events).map((event) => event.tsMs),
|
||||
[2],
|
||||
);
|
||||
});
|
||||
|
||||
test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MediaHeader
|
||||
detail={{
|
||||
videoId: 7,
|
||||
canonicalTitle: 'Episode 7',
|
||||
totalSessions: 4,
|
||||
totalActiveMs: 90_000,
|
||||
totalCards: 12,
|
||||
totalWordsSeen: 1000,
|
||||
totalLinesSeen: 120,
|
||||
totalLookupCount: 30,
|
||||
totalLookupHits: 21,
|
||||
totalYomitanLookupCount: 23,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /23/);
|
||||
assert.match(markup, /2\.3 \/ 100 words/);
|
||||
assert.match(markup, /2\.3 lookups per 100 words/);
|
||||
});
|
||||
|
||||
test('MediaHeader distinguishes word occurrences from known unique words', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MediaHeader
|
||||
detail={{
|
||||
videoId: 7,
|
||||
canonicalTitle: 'Episode 7',
|
||||
totalSessions: 4,
|
||||
totalActiveMs: 90_000,
|
||||
totalCards: 12,
|
||||
totalWordsSeen: 30,
|
||||
totalLinesSeen: 120,
|
||||
totalLookupCount: 30,
|
||||
totalLookupHits: 21,
|
||||
totalYomitanLookupCount: 0,
|
||||
}}
|
||||
initialKnownWordsSummary={{
|
||||
knownWordCount: 17,
|
||||
totalUniqueWords: 34,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /word occurrences/);
|
||||
assert.match(markup, /known unique words \(50%\)/);
|
||||
assert.match(markup, /17 \/ 34/);
|
||||
});
|
||||
|
||||
test('EpisodeList renders per-episode Yomitan lookup rate', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EpisodeList
|
||||
episodes={[
|
||||
{
|
||||
videoId: 9,
|
||||
episode: 9,
|
||||
season: 1,
|
||||
durationMs: 1,
|
||||
watched: 0,
|
||||
canonicalTitle: 'Episode 9',
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1,
|
||||
totalCards: 1,
|
||||
totalWordsSeen: 350,
|
||||
totalYomitanLookupCount: 7,
|
||||
lastWatchedMs: 0,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Lookup Rate/);
|
||||
assert.match(markup, /2\.0 \/ 100 words/);
|
||||
});
|
||||
|
||||
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeOverviewStats
|
||||
detail={{
|
||||
animeId: 1,
|
||||
canonicalTitle: 'Anime',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
description: null,
|
||||
totalSessions: 5,
|
||||
totalActiveMs: 100_000,
|
||||
totalCards: 8,
|
||||
totalWordsSeen: 800,
|
||||
totalLinesSeen: 100,
|
||||
totalLookupCount: 50,
|
||||
totalLookupHits: 30,
|
||||
totalYomitanLookupCount: 16,
|
||||
episodeCount: 3,
|
||||
lastWatchedMs: 0,
|
||||
}}
|
||||
avgSessionMs={20_000}
|
||||
knownWordsSummary={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Lookups/);
|
||||
assert.match(markup, /16/);
|
||||
assert.match(markup, /2\.0 \/ 100 words/);
|
||||
assert.match(markup, /2\.0 lookups per 100 words/);
|
||||
});
|
||||
|
||||
test('SessionRow prefers token-based word count when available', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionRow
|
||||
session={{
|
||||
sessionId: 7,
|
||||
canonicalTitle: 'Episode 7',
|
||||
videoId: 7,
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
startedAtMs: 0,
|
||||
endedAtMs: null,
|
||||
totalWatchedMs: 0,
|
||||
activeWatchedMs: 0,
|
||||
linesSeen: 12,
|
||||
wordsSeen: 12,
|
||||
tokensSeen: 42,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
}}
|
||||
isExpanded={false}
|
||||
detailsId="session-7"
|
||||
onToggle={() => {}}
|
||||
onDelete={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, />42</);
|
||||
assert.doesNotMatch(markup, />12</);
|
||||
});
|
||||
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,
|
||||
wordsSeen: number,
|
||||
): LookupRateDisplay | null {
|
||||
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(wordsSeen) || wordsSeen <= 0) {
|
||||
return null;
|
||||
}
|
||||
const per100 = ((Math.max(0, yomitanLookupCount) / wordsSeen) * 100).toFixed(1);
|
||||
return {
|
||||
shortValue: `${per100} / 100 words`,
|
||||
longValue: `${per100} lookups per 100 words`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getYomitanLookupEvents(events: SessionEvent[]): SessionEvent[] {
|
||||
return events.filter((event) => event.eventType === EventType.YOMITAN_LOOKUP);
|
||||
}
|
||||
Reference in New Issue
Block a user