perf(stats): calculate vocabulary totals off main thread

This commit is contained in:
2026-08-15 22:01:56 -07:00
parent 9ae303af7d
commit 48fdac62a6
10 changed files with 271 additions and 23 deletions
@@ -98,24 +98,26 @@ export function VocabularyTab({
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard
label="Unique Words"
value={formatNumber(displayedSummary.uniqueWords)}
value={summary ? formatNumber(displayedSummary.uniqueWords) : '…'}
color="text-ctp-blue"
/>
{displayedSummary.knownWordCount !== null && (
{displayedSummary.knownWordCount !== null ? (
<StatCard
label="Known Words"
value={`${formatNumber(displayedSummary.knownWordCount)} (${displayedSummary.uniqueWords > 0 ? Math.round((displayedSummary.knownWordCount / displayedSummary.uniqueWords) * 100) : 0}%)`}
color="text-ctp-green"
/>
)}
) : knownWords.size > 0 ? (
<StatCard label="Known Words" value="…" color="text-ctp-green" />
) : null}
<StatCard
label="Unique Kanji"
value={formatNumber(summary?.uniqueKanji ?? 0)}
value={summary ? formatNumber(summary.uniqueKanji) : '…'}
color="text-ctp-teal"
/>
<StatCard
label="New This Week"
value={`+${formatNumber(displayedSummary.newThisWeek)}`}
value={summary ? `+${formatNumber(displayedSummary.newThisWeek)}` : '…'}
color="text-ctp-mauve"
/>
</div>
+11 -13
View File
@@ -17,14 +17,10 @@ export function useVocabulary() {
let cancelled = false;
setLoading(true);
setError(null);
setSummary(null);
const client = getStatsClient();
Promise.allSettled([
client.getVocabulary(500),
client.getKanji(200),
client.getKnownWords(),
client.getVocabularySummary(),
])
.then(([wordsResult, kanjiResult, knownResult, summaryResult]) => {
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult, knownResult]) => {
if (cancelled) return;
const errors: string[] = [];
@@ -44,12 +40,6 @@ 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('; '));
}
@@ -58,6 +48,14 @@ export function useVocabulary() {
if (cancelled) return;
setLoading(false);
});
void client
.getVocabularySummary()
.then((nextSummary) => {
if (!cancelled) setSummary(nextSummary);
})
.catch((summaryError: unknown) => {
console.error('Failed to load vocabulary summary', summaryError);
});
return () => {
cancelled = true;
};
+12 -1
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
const VOCABULARY_TAB_PATH = fileURLToPath(
new URL('../components/vocabulary/VocabularyTab.tsx', import.meta.url),
);
const VOCABULARY_HOOK_PATH = fileURLToPath(new URL('../hooks/useVocabulary.ts', import.meta.url));
test('VocabularyTab declares all hooks before loading and error early returns', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
@@ -33,5 +34,15 @@ test('VocabularyTab uses database-wide summary totals for its stat cards', () =>
);
assert.match(source, /uniqueWords: summary\?\.uniqueWordsWithoutNames \?\? 0/);
assert.match(source, /uniqueWords: summary\?\.uniqueWords \?\? 0/);
assert.match(source, /value=\{formatNumber\(summary\?\.uniqueKanji \?\? 0\)\}/);
assert.match(source, /value=\{summary \? formatNumber\(summary\.uniqueKanji\) : '…'\}/);
});
test('useVocabulary loads exact card totals without holding up the vocabulary tables', () => {
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
assert.match(
source,
/Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
);
assert.match(source, /void client\s*\.getVocabularySummary\(\)\s*\.then\(/);
});