mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-03 18:12:07 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
45
stats/src/hooks/useAnimeDetail.ts
Normal file
45
stats/src/hooks/useAnimeDetail.ts
Normal 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 };
|
||||
}
|
||||
29
stats/src/hooks/useAnimeLibrary.ts
Normal file
29
stats/src/hooks/useAnimeLibrary.ts
Normal 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 };
|
||||
}
|
||||
77
stats/src/hooks/useExcludedWords.ts
Normal file
77
stats/src/hooks/useExcludedWords.ts
Normal 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 };
|
||||
}
|
||||
42
stats/src/hooks/useKanjiDetail.ts
Normal file
42
stats/src/hooks/useKanjiDetail.ts
Normal 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 };
|
||||
}
|
||||
42
stats/src/hooks/useMediaDetail.ts
Normal file
42
stats/src/hooks/useMediaDetail.ts
Normal 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 };
|
||||
}
|
||||
34
stats/src/hooks/useMediaLibrary.ts
Normal file
34
stats/src/hooks/useMediaLibrary.ts
Normal 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 };
|
||||
}
|
||||
36
stats/src/hooks/useOverview.ts
Normal file
36
stats/src/hooks/useOverview.ts
Normal 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 };
|
||||
}
|
||||
20
stats/src/hooks/useSessions.test.ts
Normal file
20
stats/src/hooks/useSessions.test.ts
Normal 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);
|
||||
});
|
||||
96
stats/src/hooks/useSessions.ts
Normal file
96
stats/src/hooks/useSessions.ts
Normal 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 };
|
||||
}
|
||||
7
stats/src/hooks/useStatsApi.ts
Normal file
7
stats/src/hooks/useStatsApi.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { apiClient } from '../lib/api-client';
|
||||
|
||||
export type StatsClient = typeof apiClient;
|
||||
|
||||
export function getStatsClient(): StatsClient {
|
||||
return apiClient;
|
||||
}
|
||||
29
stats/src/hooks/useStreakCalendar.ts
Normal file
29
stats/src/hooks/useStreakCalendar.ts
Normal 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 };
|
||||
}
|
||||
37
stats/src/hooks/useTrends.ts
Normal file
37
stats/src/hooks/useTrends.ts
Normal 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 };
|
||||
}
|
||||
52
stats/src/hooks/useVocabulary.ts
Normal file
52
stats/src/hooks/useVocabulary.ts
Normal 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 };
|
||||
}
|
||||
42
stats/src/hooks/useWordDetail.ts
Normal file
42
stats/src/hooks/useWordDetail.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user