fix(stats): report complete vocabulary summary totals

This commit is contained in:
2026-08-15 21:50:23 -07:00
parent 297aa382c6
commit 9ae303af7d
13 changed files with 263 additions and 26 deletions
@@ -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"
/>
+16 -4
View File
@@ -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 };
}
+1
View File
@@ -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', {
+6 -3
View File
@@ -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\)\}/);
});