mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
fix(stats): report complete vocabulary totals and new-word history (#202)
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user