mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -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:
117
stats/src/lib/api-client.ts
Normal file
117
stats/src/lib/api-client.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
OverviewData,
|
||||
DailyRollup,
|
||||
MonthlyRollup,
|
||||
SessionSummary,
|
||||
SessionTimelinePoint,
|
||||
SessionEvent,
|
||||
VocabularyEntry,
|
||||
KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem,
|
||||
MediaDetailData,
|
||||
AnimeLibraryItem,
|
||||
AnimeDetailData,
|
||||
AnimeWord,
|
||||
StreakCalendarDay,
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
} from '../types/stats';
|
||||
|
||||
export const BASE_URL = window.location.protocol === 'file:'
|
||||
? 'http://127.0.0.1:5175'
|
||||
: window.location.origin;
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`);
|
||||
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.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 = 200) =>
|
||||
fetchJson<SessionTimelinePoint[]>(`/api/stats/sessions/${id}/timeline?limit=${limit}`),
|
||||
getSessionEvents: (id: number, limit = 500) =>
|
||||
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
|
||||
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}`),
|
||||
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 fetch(`${BASE_URL}/api/stats/media/${videoId}/watched`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ watched }),
|
||||
});
|
||||
},
|
||||
ankiBrowse: async (noteId: number): Promise<void> => {
|
||||
await fetch(`${BASE_URL}/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
|
||||
},
|
||||
ankiNotesInfo: async (noteIds: number[]): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
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;
|
||||
137
stats/src/lib/dashboard-data.test.ts
Normal file
137
stats/src/lib/dashboard-data.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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);
|
||||
});
|
||||
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 };
|
||||
});
|
||||
}
|
||||
45
stats/src/lib/formatters.test.ts
Normal file
45
stats/src/lib/formatters.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { formatRelativeDate } 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: 23 hours ago returns "23h ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 23 * 3_600_000), '23h ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 36 hours ago returns "Yesterday"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 36 * 3_600_000), 'Yesterday');
|
||||
});
|
||||
|
||||
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());
|
||||
});
|
||||
44
stats/src/lib/formatters.ts
Normal file
44
stats/src/lib/formatters.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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 < 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);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffDays < 2) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}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());
|
||||
}
|
||||
96
stats/src/lib/ipc-client.ts
Normal file
96
stats/src/lib/ipc-client.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
OverviewData, DailyRollup, MonthlyRollup,
|
||||
SessionSummary, SessionTimelinePoint, SessionEvent,
|
||||
VocabularyEntry, KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem, MediaDetailData,
|
||||
AnimeLibraryItem, AnimeDetailData, AnimeWord,
|
||||
StreakCalendarDay, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime,
|
||||
WordDetailData, KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
} 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<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
|
||||
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 = 200) => 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),
|
||||
};
|
||||
Reference in New Issue
Block a user