mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
fix(stats): report complete vocabulary summary totals
This commit is contained in:
@@ -35,7 +35,7 @@ export function VocabularyTab({
|
||||
onRemoveExclusion,
|
||||
onClearExclusions,
|
||||
}: VocabularyTabProps) {
|
||||
const { words, kanji, knownWords, loading, error, reload } = useVocabulary();
|
||||
const { words, kanji, knownWords, summary, loading, error, reload } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [hideNames, setHideNames] = useState(false);
|
||||
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
||||
@@ -48,19 +48,10 @@ export function VocabularyTab({
|
||||
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
|
||||
return result;
|
||||
}, [words, hideNames, excluded, isExcluded]);
|
||||
const summary = useMemo(
|
||||
const chartSummary = useMemo(
|
||||
() => buildVocabularySummary(filteredWords, kanji),
|
||||
[filteredWords, kanji],
|
||||
);
|
||||
const knownWordCount = useMemo(() => {
|
||||
if (knownWords.size === 0) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (const w of filteredWords) {
|
||||
if (knownWords.has(w.headword)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}, [filteredWords, knownWords]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -90,29 +81,41 @@ export function VocabularyTab({
|
||||
setSelectedKanjiId(entry.kanjiId);
|
||||
};
|
||||
|
||||
const displayedSummary = hideNames
|
||||
? {
|
||||
uniqueWords: summary?.uniqueWordsWithoutNames ?? 0,
|
||||
newThisWeek: summary?.newThisWeekWithoutNames ?? 0,
|
||||
knownWordCount: summary?.knownWordCountWithoutNames ?? null,
|
||||
}
|
||||
: {
|
||||
uniqueWords: summary?.uniqueWords ?? 0,
|
||||
newThisWeek: summary?.newThisWeek ?? 0,
|
||||
knownWordCount: summary?.knownWordCount ?? null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
label="Unique Words"
|
||||
value={formatNumber(summary.uniqueWords)}
|
||||
value={formatNumber(displayedSummary.uniqueWords)}
|
||||
color="text-ctp-blue"
|
||||
/>
|
||||
{knownWords.size > 0 && (
|
||||
{displayedSummary.knownWordCount !== null && (
|
||||
<StatCard
|
||||
label="Known Words"
|
||||
value={`${formatNumber(knownWordCount)} (${summary.uniqueWords > 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`}
|
||||
value={`${formatNumber(displayedSummary.knownWordCount)} (${displayedSummary.uniqueWords > 0 ? Math.round((displayedSummary.knownWordCount / displayedSummary.uniqueWords) * 100) : 0}%)`}
|
||||
color="text-ctp-green"
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
label="Unique Kanji"
|
||||
value={formatNumber(summary.uniqueKanji)}
|
||||
value={formatNumber(summary?.uniqueKanji ?? 0)}
|
||||
color="text-ctp-teal"
|
||||
/>
|
||||
<StatCard
|
||||
label="New This Week"
|
||||
value={`+${formatNumber(summary.newThisWeek)}`}
|
||||
value={`+${formatNumber(displayedSummary.newThisWeek)}`}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
</div>
|
||||
@@ -154,14 +157,14 @@ export function VocabularyTab({
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<TrendChart
|
||||
title="Top Repeated Words"
|
||||
data={summary.topWords}
|
||||
data={chartSummary.topWords}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
onBarClick={handleBarClick}
|
||||
/>
|
||||
<TrendChart
|
||||
title="New Words by Day"
|
||||
data={summary.newWordsTimeline}
|
||||
data={chartSummary.newWordsTimeline}
|
||||
color="#c6a0f6"
|
||||
type="line"
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
|
||||
import type { VocabularyEntry, KanjiEntry, 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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
|
||||
@@ -17,8 +18,13 @@ export function useVocabulary() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const client = getStatsClient();
|
||||
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
|
||||
.then(([wordsResult, kanjiResult, knownResult]) => {
|
||||
Promise.allSettled([
|
||||
client.getVocabulary(500),
|
||||
client.getKanji(200),
|
||||
client.getKnownWords(),
|
||||
client.getVocabularySummary(),
|
||||
])
|
||||
.then(([wordsResult, kanjiResult, knownResult, summaryResult]) => {
|
||||
if (cancelled) return;
|
||||
const errors: string[] = [];
|
||||
|
||||
@@ -38,6 +44,12 @@ export function useVocabulary() {
|
||||
setKnownWords(new Set(knownResult.value));
|
||||
}
|
||||
|
||||
if (summaryResult.status === 'fulfilled') {
|
||||
setSummary(summaryResult.value);
|
||||
} else {
|
||||
errors.push(summaryResult.reason.message);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('; '));
|
||||
}
|
||||
@@ -51,5 +63,5 @@ export function useVocabulary() {
|
||||
};
|
||||
}, [reloadToken]);
|
||||
|
||||
return { words, kanji, knownWords, loading, error, reload };
|
||||
return { words, kanji, knownWords, summary, loading, error, reload };
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export const apiClient = {
|
||||
getSessionKnownWordsTimeline: (id: number) =>
|
||||
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'),
|
||||
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
|
||||
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
|
||||
await fetchResponse('/api/stats/excluded-words', {
|
||||
|
||||
@@ -20,15 +20,18 @@ test('VocabularyTab declares all hooks before loading and error early returns',
|
||||
assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
|
||||
});
|
||||
|
||||
test('VocabularyTab memoizes summary and known-word aggregate calculations', () => {
|
||||
test('VocabularyTab uses database-wide summary totals for its stat cards', () => {
|
||||
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const summary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/,
|
||||
/const chartSummary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const knownWordCount = useMemo\(\(\) => \{[\s\S]*for \(const w of filteredWords\) \{[\s\S]*knownWords\.has\(w\.headword\)[\s\S]*\}\s*return count;\s*\}, \[filteredWords, knownWords\]\);/,
|
||||
/const \{ words, kanji, knownWords, summary, loading, error, reload \} = useVocabulary\(\);/,
|
||||
);
|
||||
assert.match(source, /uniqueWords: summary\?\.uniqueWordsWithoutNames \?\? 0/);
|
||||
assert.match(source, /uniqueWords: summary\?\.uniqueWords \?\? 0/);
|
||||
assert.match(source, /value=\{formatNumber\(summary\?\.uniqueKanji \?\? 0\)\}/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user