mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
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
This commit is contained in:
224
stats/src/lib/dashboard-data.ts
Normal file
224
stats/src/lib/dashboard-data.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { DailyRollup, KanjiEntry, OverviewData, StreakCalendarDay, VocabularyEntry } from '../types/stats';
|
||||
import { epochDayToDate, localDayFromMs } from './formatters';
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface OverviewSummary {
|
||||
todayActiveMs: number;
|
||||
todayCards: number;
|
||||
streakDays: number;
|
||||
allTimeHours: number;
|
||||
totalTrackedCards: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
averageSessionMinutes: number;
|
||||
totalSessions: number;
|
||||
activeDays: 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 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.totalWordsSeen;
|
||||
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);
|
||||
|
||||
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,
|
||||
allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60),
|
||||
totalTrackedCards: Math.max(sessionCards, rollupCards),
|
||||
episodesToday: overview.hints.episodesToday ?? 0,
|
||||
activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
|
||||
averageSessionMinutes:
|
||||
overview.sessions.length > 0
|
||||
? Math.round(sumBy(overview.sessions, (session) => session.activeWatchedMs) / overview.sessions.length / 60_000)
|
||||
: 0,
|
||||
totalSessions: overview.hints.totalSessions,
|
||||
activeDays: daysWithActivity.size,
|
||||
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 day = Math.floor(word.firstSeen / 86_400);
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
uniqueWords: words.length,
|
||||
uniqueKanji: kanji.length,
|
||||
newThisWeek: words.filter((word) => word.firstSeen >= 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) => right.firstSeen - left.firstSeen)
|
||||
.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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user