mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
perf(stats): roll up lexical chart history
This commit is contained in:
@@ -6,11 +6,10 @@ import { KanjiBreakdown } from './KanjiBreakdown';
|
||||
import { KanjiDetailPanel } from './KanjiDetailPanel';
|
||||
import { ExclusionManager } from './ExclusionManager';
|
||||
import { DuplicateLineCleanup } from './DuplicateLineCleanup';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { epochDayToDate, formatNumber } from '../../lib/formatters';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
||||
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
|
||||
import { buildVocabularySummary } from '../../lib/dashboard-data';
|
||||
import type { ExcludedWord } from '../../hooks/useExcludedWords';
|
||||
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
|
||||
|
||||
@@ -35,7 +34,7 @@ export function VocabularyTab({
|
||||
onRemoveExclusion,
|
||||
onClearExclusions,
|
||||
}: VocabularyTabProps) {
|
||||
const { words, kanji, knownWords, summary, loading, error, reload } = useVocabulary();
|
||||
const { words, kanji, knownWords, summary, charts, loading, error, reload } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [hideNames, setHideNames] = useState(false);
|
||||
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
||||
@@ -48,9 +47,23 @@ export function VocabularyTab({
|
||||
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
|
||||
return result;
|
||||
}, [words, hideNames, excluded, isExcluded]);
|
||||
const chartSummary = useMemo(
|
||||
() => buildVocabularySummary(filteredWords, kanji),
|
||||
[filteredWords, kanji],
|
||||
const chartData = useMemo(
|
||||
() => ({
|
||||
topWords:
|
||||
((hideNames ? charts?.topWordsWithoutNames : charts?.topWords) ?? []).map((word) => ({
|
||||
label: word.headword,
|
||||
value: word.frequency,
|
||||
})) ?? [],
|
||||
newWordsTimeline:
|
||||
((hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? []).map((point) => ({
|
||||
label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
value: point.wordCount,
|
||||
})) ?? [],
|
||||
}),
|
||||
[charts, hideNames],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -73,7 +86,9 @@ export function VocabularyTab({
|
||||
};
|
||||
|
||||
const handleBarClick = (headword: string): void => {
|
||||
const match = filteredWords.find((w) => w.headword === headword);
|
||||
const match = (hideNames ? charts?.topWordsWithoutNames : charts?.topWords)?.find(
|
||||
(word) => word.headword === headword,
|
||||
);
|
||||
if (match) onOpenWordDetail?.(match.wordId);
|
||||
};
|
||||
|
||||
@@ -159,19 +174,25 @@ export function VocabularyTab({
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<TrendChart
|
||||
title="Top Repeated Words"
|
||||
data={chartSummary.topWords}
|
||||
data={chartData.topWords}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
onBarClick={handleBarClick}
|
||||
/>
|
||||
<TrendChart
|
||||
title="New Words by Day"
|
||||
data={chartSummary.newWordsTimeline}
|
||||
data={chartData.newWordsTimeline}
|
||||
color="#c6a0f6"
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{charts && !charts.ready && (
|
||||
<p className="text-xs text-ctp-overlay1" role="status">
|
||||
Building vocabulary history in the background…
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FrequencyRankTable
|
||||
words={filteredWords}
|
||||
knownWords={knownWords}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { VocabularyEntry, KanjiEntry, StatsVocabularySummary } from '../types/stats';
|
||||
import type {
|
||||
VocabularyEntry,
|
||||
KanjiEntry,
|
||||
StatsVocabularyCharts,
|
||||
StatsVocabularySummary,
|
||||
} 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 [summary, setSummary] = useState<StatsVocabularySummary | null>(null);
|
||||
const [charts, setCharts] = useState<StatsVocabularyCharts | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
|
||||
@@ -18,6 +24,7 @@ export function useVocabulary() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSummary(null);
|
||||
setCharts(null);
|
||||
const client = getStatsClient();
|
||||
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
|
||||
.then(([wordsResult, kanjiResult, knownResult]) => {
|
||||
@@ -56,10 +63,27 @@ export function useVocabulary() {
|
||||
.catch((summaryError: unknown) => {
|
||||
console.error('Failed to load vocabulary summary', summaryError);
|
||||
});
|
||||
let chartRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const loadCharts = (): void => {
|
||||
void client
|
||||
.getVocabularyCharts()
|
||||
.then((nextCharts) => {
|
||||
if (cancelled) return;
|
||||
setCharts(nextCharts);
|
||||
if (!nextCharts.ready) {
|
||||
chartRetryTimer = setTimeout(loadCharts, 1_000);
|
||||
}
|
||||
})
|
||||
.catch((chartError: unknown) => {
|
||||
console.error('Failed to load vocabulary charts', chartError);
|
||||
});
|
||||
};
|
||||
loadCharts();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (chartRetryTimer) clearTimeout(chartRetryTimer);
|
||||
};
|
||||
}, [reloadToken]);
|
||||
|
||||
return { words, kanji, knownWords, summary, loading, error, reload };
|
||||
return { words, kanji, knownWords, summary, charts, loading, error, reload };
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export const apiClient = {
|
||||
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
|
||||
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
|
||||
getVocabularySummary: () => fetchJson('vocabularySummary', '/api/stats/vocabulary/summary'),
|
||||
getVocabularyCharts: () => fetchJson('vocabularyCharts', '/api/stats/vocabulary/charts'),
|
||||
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
|
||||
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
|
||||
await fetchResponse('/api/stats/excluded-words', {
|
||||
|
||||
@@ -21,17 +21,16 @@ test('VocabularyTab declares all hooks before loading and error early returns',
|
||||
assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
|
||||
});
|
||||
|
||||
test('VocabularyTab uses database-wide summary totals for its stat cards', () => {
|
||||
test('VocabularyTab uses uncapped server-side data for its charts and card totals', () => {
|
||||
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const chartSummary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const \{ words, kanji, knownWords, summary, loading, error, reload \} = useVocabulary\(\);/,
|
||||
/const \{ words, kanji, knownWords, summary, charts, loading, error, reload \} = useVocabulary\(\);/,
|
||||
);
|
||||
assert.match(source, /charts\?\.topWordsWithoutNames/);
|
||||
assert.match(source, /charts\?\.newWordsTimelineWithoutNames/);
|
||||
assert.doesNotMatch(source, /buildVocabularySummary\(/);
|
||||
assert.match(source, /uniqueWords: summary\?\.uniqueWordsWithoutNames \?\? 0/);
|
||||
assert.match(source, /uniqueWords: summary\?\.uniqueWords \?\? 0/);
|
||||
assert.match(source, /value=\{summary \? formatNumber\(summary\.uniqueKanji\) : '…'\}/);
|
||||
@@ -45,4 +44,5 @@ test('useVocabulary loads exact card totals without holding up the vocabulary ta
|
||||
/Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
|
||||
);
|
||||
assert.match(source, /void client\s*\.getVocabularySummary\(\)\s*\.then\(/);
|
||||
assert.match(source, /client\s*\.getVocabularyCharts\(\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user