fix(stats): report complete vocabulary totals and new-word history (#202)

This commit is contained in:
2026-08-18 00:42:46 -07:00
committed by GitHub
parent 273652f781
commit 7de73e16a1
38 changed files with 2995 additions and 85 deletions
+2
View File
@@ -100,6 +100,8 @@ 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'),
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', {
+19 -1
View File
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
import {
epochDayToDate,
epochMsFromDbTimestamp,
formatRelativeDate,
formatSessionDayLabel,
} from './formatters';
const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime();
@@ -108,6 +113,19 @@ test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
});
test('epochDayToDate preserves the calendar day west of UTC', () => {
const previousTimezone = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
const epochDay = Math.floor(Date.UTC(2026, 2, 16) / 86_400_000);
const date = epochDayToDate(epochDay);
assert.deepEqual([date.getFullYear(), date.getMonth(), date.getDate()], [2026, 2, 16]);
} finally {
if (previousTimezone === undefined) delete process.env.TZ;
else process.env.TZ = previousTimezone;
}
});
test('formatSessionDayLabel formats today and yesterday', () => {
withFixedNow((now) => {
const oneDayMs = 24 * 60 * 60_000;
+2 -1
View File
@@ -38,7 +38,8 @@ export function formatRelativeDate(ms: number): string {
}
export function epochDayToDate(epochDay: number): Date {
return new Date(epochDay * 86_400_000);
const utcDate = new Date(epochDay * 86_400_000);
return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate());
}
export function localDayFromMs(ms: number): number {
+24 -6
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');
@@ -20,15 +21,32 @@ 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 uncapped server-side data for its charts and card totals', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(source, /\} = 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\) : '…'\}/);
});
test('VocabularyTab surfaces aggregate failures with a retry control', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(source, /aggregatesError/);
assert.match(source, /onClick=\{refreshAggregates\}/);
});
test('useVocabulary loads exact card totals without holding up the vocabulary tables', () => {
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
assert.match(
source,
/const summary = 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\]\);/,
/Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
);
assert.match(source, /client\s*\.getVocabularySummary\(\)\s*\.then\(/);
assert.match(source, /client\s*\.getVocabularyCharts\(\)/);
});