Files
SubMiner/stats/src/lib/dashboard-data.test.ts
sudacode 0f44107beb feat(stats): build anime-centric stats dashboard frontend
5-tab React dashboard with Catppuccin Mocha theme:
- Overview: hero stats, streak calendar, watch time chart, recent sessions
- Anime: grid with cover art, episode list with completion %, detail view
- Trends: 15 charts across Activity, Efficiency, Anime, and Patterns
- Vocabulary: POS-filtered word/kanji lists with detail panels
- Sessions: expandable session history with event timeline

Features:
- Cross-tab navigation (anime <-> vocabulary)
- Global word detail panel overlay
- Expandable episode detail with Anki card links (Expression field)
- Per-anime multi-line trend charts
- Watch time by day-of-week and hour-of-day
- Collapsible sections with accessibility (aria-expanded)
- Card size selector for anime grid
- Cover art caching via AniList
- HTTP API client with file:// protocol fallback for Electron overlay
2026-03-17 20:05:07 -07:00

138 lines
3.6 KiB
TypeScript

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,
wordsSeen: 100,
tokensSeen: 80,
cardsMined: 2,
lookupCount: 10,
lookupHits: 8,
},
];
const rollups: DailyRollup[] = [
{
rollupDayOrMonth: today,
videoId: 1,
totalSessions: 1,
totalActiveMin: 50,
totalLinesSeen: 20,
totalWordsSeen: 100,
totalTokensSeen: 80,
totalCards: 2,
cardsPerHour: 2.4,
wordsPerMin: 2,
lookupHitRate: 0.8,
},
];
const overview: OverviewData = {
sessions,
rollups,
hints: { totalSessions: 1, activeSessions: 0, episodesToday: 2, activeAnimeCount: 3 },
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.todayCards, 2);
assert.equal(summary.totalTrackedCards, 2);
assert.equal(summary.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50);
});
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,
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,
totalWordsSeen: 120,
totalTokensSeen: 100,
totalCards: 3,
cardsPerHour: 3,
wordsPerMin: 2,
lookupHitRate: 0.5,
},
{
rollupDayOrMonth: today,
videoId: 1,
totalSessions: 1,
totalActiveMin: 30,
totalLinesSeen: 10,
totalWordsSeen: 40,
totalTokensSeen: 30,
totalCards: 1,
cardsPerHour: 2,
wordsPerMin: 1.33,
lookupHitRate: 0.75,
},
];
const dashboard = buildTrendDashboard(rollups);
assert.equal(dashboard.watchTime.length, 2);
assert.equal(dashboard.words[1]?.value, 40);
assert.equal(dashboard.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);
});