mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
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
138 lines
3.6 KiB
TypeScript
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);
|
|
});
|