feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -0,0 +1,45 @@
import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeDetailData } from '../types/stats';
export function useAnimeDetail(animeId: number | null) {
const [data, setData] = useState<AnimeDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
if (animeId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getAnimeDetail(animeId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [animeId, reloadKey]);
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
return { data, loading, error, reload };
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setAnime(data);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { anime, loading, error };
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useSyncExternalStore } from 'react';
export interface ExcludedWord {
headword: string;
word: string;
reading: string;
}
const STORAGE_KEY = 'subminer-excluded-words';
function toKey(w: ExcludedWord): string {
return `${w.headword}\0${w.word}\0${w.reading}`;
}
let cached: ExcludedWord[] | null = null;
let cachedKeys: Set<string> | null = null;
const listeners = new Set<() => void>();
function load(): ExcludedWord[] {
if (cached) return cached;
try {
const raw = localStorage.getItem(STORAGE_KEY);
cached = raw ? JSON.parse(raw) : [];
} catch {
cached = [];
}
return cached!;
}
function getKeySet(): Set<string> {
if (cachedKeys) return cachedKeys;
cachedKeys = new Set(load().map(toKey));
return cachedKeys;
}
function persist(words: ExcludedWord[]) {
cached = words;
cachedKeys = new Set(words.map(toKey));
localStorage.setItem(STORAGE_KEY, JSON.stringify(words));
for (const fn of listeners) fn();
}
function getSnapshot(): ExcludedWord[] {
return load();
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
export function useExcludedWords() {
const excluded = useSyncExternalStore(subscribe, getSnapshot);
const isExcluded = useCallback(
(w: { headword: string; word: string; reading: string }) => getKeySet().has(toKey(w)),
[excluded],
);
const toggleExclusion = useCallback((w: ExcludedWord) => {
const key = toKey(w);
const current = load();
if (getKeySet().has(key)) {
persist(current.filter((e) => toKey(e) !== key));
} else {
persist([...current, w]);
}
}, []);
const removeExclusion = useCallback((w: ExcludedWord) => {
persist(load().filter((e) => toKey(e) !== toKey(w)));
}, []);
const clearAll = useCallback(() => persist([]), []);
return { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll };
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { KanjiDetailData } from '../types/stats';
export function useKanjiDetail(kanjiId: number | null) {
const [data, setData] = useState<KanjiDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (kanjiId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getKanjiDetail(kanjiId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [kanjiId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { MediaDetailData } from '../types/stats';
export function useMediaDetail(videoId: number | null) {
const [data, setData] = useState<MediaDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (videoId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getMediaDetail(videoId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [videoId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { MediaLibraryItem } from '../types/stats';
export function useMediaLibrary() {
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStatsClient()
.getMediaLibrary()
.then((rows) => {
if (cancelled) return;
setMedia(rows);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { media, loading, error };
}

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { OverviewData, SessionSummary } from '../types/stats';
export function useOverview() {
const [data, setData] = useState<OverviewData | null>(null);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
Promise.all([client.getOverview(), client.getSessions(50)])
.then(([overview, allSessions]) => {
if (cancelled) return;
setData(overview);
setSessions(allSessions);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { data, sessions, setSessions, loading, error };
}

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { toErrorMessage } from './useSessions';
const USE_SESSIONS_PATH = fileURLToPath(new URL('./useSessions.ts', import.meta.url));
test('toErrorMessage normalizes Error and non-Error rejections', () => {
assert.equal(toErrorMessage(new Error('network down')), 'network down');
assert.equal(toErrorMessage('bad gateway'), 'bad gateway');
assert.equal(toErrorMessage(503), '503');
});
test('useSessions and useSessionDetail route catch handlers through toErrorMessage', () => {
const source = fs.readFileSync(USE_SESSIONS_PATH, 'utf8');
const matches = source.match(/setError\(toErrorMessage\(err\)\)/g);
assert.equal(matches?.length, 2);
});

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import { SESSION_CHART_EVENT_TYPES } from '../lib/session-events';
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
export function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function useSessions(limit = 50) {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
client
.getSessions(limit)
.then((nextSessions) => {
if (cancelled) return;
setSessions(nextSessions);
})
.catch((err) => {
if (cancelled) return;
setError(toErrorMessage(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [limit]);
return { sessions, loading, error };
}
export interface KnownWordsTimelinePoint {
linesSeen: number;
knownWordsSeen: number;
}
export function useSessionDetail(sessionId: number | null) {
const [timeline, setTimeline] = useState<SessionTimelinePoint[]>([]);
const [events, setEvents] = useState<SessionEvent[]>([]);
const [knownWordsTimeline, setKnownWordsTimeline] = useState<KnownWordsTimelinePoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
if (sessionId == null) {
setTimeline([]);
setEvents([]);
setKnownWordsTimeline([]);
setLoading(false);
return () => {
cancelled = true;
};
}
setLoading(true);
setTimeline([]);
setEvents([]);
setKnownWordsTimeline([]);
const client = getStatsClient();
Promise.all([
client.getSessionTimeline(sessionId),
client.getSessionEvents(sessionId, 500, [...SESSION_CHART_EVENT_TYPES]),
client.getSessionKnownWordsTimeline(sessionId),
])
.then(([nextTimeline, nextEvents, nextKnownWords]) => {
if (cancelled) return;
setTimeline(nextTimeline);
setEvents(nextEvents);
setKnownWordsTimeline(nextKnownWords);
})
.catch((err) => {
if (cancelled) return;
setError(toErrorMessage(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [sessionId]);
return { timeline, events, knownWordsTimeline, loading, error };
}

View File

@@ -0,0 +1,7 @@
import { apiClient } from '../lib/api-client';
export type StatsClient = typeof apiClient;
export function getStatsClient(): StatsClient {
return apiClient;
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { StreakCalendarDay } from '../types/stats';
export function useStreakCalendar(days = 90) {
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getStreakCalendar(days)
.then((data) => {
if (!cancelled) setCalendar(data);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [days]);
return { calendar, loading, error };
}

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { TrendsDashboardData } from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | 'all';
export type GroupBy = 'day' | 'month';
export function useTrends(range: TimeRange, groupBy: GroupBy) {
const [data, setData] = useState<TrendsDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStatsClient()
.getTrendsDashboard(range, groupBy)
.then((nextData) => {
if (cancelled) return;
setData(nextData);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [range, groupBy]);
return { data, loading, error };
}

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult, knownResult]) => {
if (cancelled) return;
const errors: string[] = [];
if (wordsResult.status === 'fulfilled') {
setWords(wordsResult.value);
} else {
errors.push(wordsResult.reason.message);
}
if (kanjiResult.status === 'fulfilled') {
setKanji(kanjiResult.value);
} else {
errors.push(kanjiResult.reason.message);
}
if (knownResult.status === 'fulfilled') {
setKnownWords(new Set(knownResult.value));
}
if (errors.length > 0) {
setError(errors.join('; '));
}
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { words, kanji, knownWords, loading, error };
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { WordDetailData } from '../types/stats';
export function useWordDetail(wordId: number | null) {
const [data, setData] = useState<WordDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (wordId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getWordDetail(wordId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [wordId]);
return { data, loading, error };
}