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:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Fixed Vocabulary summary cards counting only the first page of frequency-ranked words and kanji instead of all tracked vocabulary.
|
||||||
@@ -82,7 +82,7 @@ Expandable session history with new-word activity, cumulative totals, and pause/
|
|||||||
|
|
||||||
#### Vocabulary
|
#### Vocabulary
|
||||||
|
|
||||||
Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
|
The summary cards show all unique vocabulary and kanji recorded in the local tracking database; **New This Week** is the only weekly figure and uses a rolling seven-day window. The rest of the tab includes top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,15 @@ function createMockTracker(
|
|||||||
getSessionTimeline: async () => [],
|
getSessionTimeline: async () => [],
|
||||||
getSessionEvents: async () => [],
|
getSessionEvents: async () => [],
|
||||||
getVocabularyStats: async () => VOCABULARY_STATS,
|
getVocabularyStats: async () => VOCABULARY_STATS,
|
||||||
|
getVocabularySummary: async () => ({
|
||||||
|
uniqueWords: 501,
|
||||||
|
uniqueWordsWithoutNames: 500,
|
||||||
|
uniqueKanji: 201,
|
||||||
|
newThisWeek: 7,
|
||||||
|
newThisWeekWithoutNames: 6,
|
||||||
|
knownWordCount: 250,
|
||||||
|
knownWordCountWithoutNames: 249,
|
||||||
|
}),
|
||||||
getStatsExcludedWords: async () => [],
|
getStatsExcludedWords: async () => [],
|
||||||
replaceStatsExcludedWords: async () => {},
|
replaceStatsExcludedWords: async () => {},
|
||||||
getKanjiStats: async () => KANJI_STATS,
|
getKanjiStats: async () => KANJI_STATS,
|
||||||
@@ -711,6 +720,23 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(body[0].headword, 'する');
|
assert.equal(body[0].headword, 'する');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/vocabulary/summary returns database-wide card totals', async () => {
|
||||||
|
const app = createStatsApp(createMockTracker());
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/vocabulary/summary');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), {
|
||||||
|
uniqueWords: 501,
|
||||||
|
uniqueWordsWithoutNames: 500,
|
||||||
|
uniqueKanji: 201,
|
||||||
|
newThisWeek: 7,
|
||||||
|
newThisWeekWithoutNames: 6,
|
||||||
|
knownWordCount: 250,
|
||||||
|
knownWordCountWithoutNames: 249,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/kanji returns kanji frequency data', async () => {
|
it('GET /api/stats/kanji returns kanji frequency data', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/kanji');
|
const res = await app.request('/api/stats/kanji');
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
getSimilarWords,
|
getSimilarWords,
|
||||||
getStatsExcludedWords,
|
getStatsExcludedWords,
|
||||||
getVocabularyStats,
|
getVocabularyStats,
|
||||||
|
getVocabularySummary,
|
||||||
replaceStatsExcludedWords,
|
replaceStatsExcludedWords,
|
||||||
searchSubtitleSentences,
|
searchSubtitleSentences,
|
||||||
getWordAnimeAppearances,
|
getWordAnimeAppearances,
|
||||||
@@ -634,6 +635,10 @@ export class ImmersionTrackerService {
|
|||||||
return getVocabularyStats(this.db, limit, excludePos);
|
return getVocabularyStats(this.db, limit, excludePos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVocabularySummary(knownWords: ReadonlySet<string> | null) {
|
||||||
|
return getVocabularySummary(this.db, knownWords);
|
||||||
|
}
|
||||||
|
|
||||||
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
|
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
|
||||||
return getStatsExcludedWords(this.db);
|
return getStatsExcludedWords(this.db);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
getKanjiOccurrences,
|
getKanjiOccurrences,
|
||||||
getSessionSummaries,
|
getSessionSummaries,
|
||||||
getVocabularyStats,
|
getVocabularyStats,
|
||||||
|
getVocabularySummary,
|
||||||
getKanjiStats,
|
getKanjiStats,
|
||||||
getSessionEvents,
|
getSessionEvents,
|
||||||
getSessionTimeline,
|
getSessionTimeline,
|
||||||
@@ -1875,6 +1876,88 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getVocabularySummary counts every tracked vocabulary row instead of a display page', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const insertWord = db.prepare(`
|
||||||
|
INSERT INTO imm_words (
|
||||||
|
headword, word, reading, part_of_speech, pos1, pos2, pos3,
|
||||||
|
first_seen, last_seen, frequency
|
||||||
|
) VALUES (?, ?, '', 'noun', '名詞', '一般', '', ?, ?, 1)
|
||||||
|
`);
|
||||||
|
const insertKanji = db.prepare(`
|
||||||
|
INSERT INTO imm_kanji (kanji, first_seen, last_seen, frequency)
|
||||||
|
VALUES (?, ?, ?, 1)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (let index = 0; index < 501; index += 1) {
|
||||||
|
insertWord.run(`単語${index}`, `単語${index}`, nowSec - 8 * 86_400, nowSec - 8 * 86_400);
|
||||||
|
}
|
||||||
|
for (let index = 0; index < 201; index += 1) {
|
||||||
|
insertKanji.run(
|
||||||
|
String.fromCodePoint(0x4e00 + index),
|
||||||
|
nowSec - 8 * 86_400,
|
||||||
|
nowSec - 8 * 86_400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
insertWord.run('今週', '今週', nowSec - 86_400, nowSec - 86_400);
|
||||||
|
|
||||||
|
assert.deepEqual(getVocabularySummary(db, new Set(['単語0', '今週']), nowSec * 1000), {
|
||||||
|
uniqueWords: 502,
|
||||||
|
uniqueWordsWithoutNames: 502,
|
||||||
|
uniqueKanji: 201,
|
||||||
|
newThisWeek: 1,
|
||||||
|
newThisWeekWithoutNames: 1,
|
||||||
|
knownWordCount: 2,
|
||||||
|
knownWordCountWithoutNames: 2,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVocabularySummary applies vocabulary exclusions and Hide Names totals', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = openTestDb(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const insertWord = db.prepare(`
|
||||||
|
INSERT INTO imm_words (
|
||||||
|
headword, word, reading, part_of_speech, pos1, pos2, pos3,
|
||||||
|
first_seen, last_seen, frequency
|
||||||
|
) VALUES (?, ?, '', 'noun', '名詞', ?, '', 1, 1, 1)
|
||||||
|
`);
|
||||||
|
insertWord.run('猫', '猫', '一般');
|
||||||
|
insertWord.run('太郎', '太郎', '固有名詞');
|
||||||
|
insertWord.run('東京', '東京都', '一般');
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_stats_excluded_words (headword, word, reading)
|
||||||
|
VALUES ('東京', '東京', '')
|
||||||
|
`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
assert.deepEqual(getVocabularySummary(db, new Set(['猫', '太郎', '東京']), 9 * 86_400_000), {
|
||||||
|
uniqueWords: 2,
|
||||||
|
uniqueWordsWithoutNames: 1,
|
||||||
|
uniqueKanji: 0,
|
||||||
|
newThisWeek: 0,
|
||||||
|
newThisWeekWithoutNames: 0,
|
||||||
|
knownWordCount: 2,
|
||||||
|
knownWordCountWithoutNames: 1,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
|
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
SimilarWordRow,
|
SimilarWordRow,
|
||||||
StatsExcludedWordRow,
|
StatsExcludedWordRow,
|
||||||
VocabularyStatsRow,
|
VocabularyStatsRow,
|
||||||
|
VocabularyStatsSummary,
|
||||||
WordAnimeAppearanceRow,
|
WordAnimeAppearanceRow,
|
||||||
WordDetailRow,
|
WordDetailRow,
|
||||||
WordOccurrenceRow,
|
WordOccurrenceRow,
|
||||||
@@ -153,6 +154,75 @@ export function getVocabularyStats(
|
|||||||
return visibleRows.slice(0, limit);
|
return visibleRows.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function excludedVocabularyAliases(
|
||||||
|
word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>,
|
||||||
|
): string[] {
|
||||||
|
const aliases = [word.headword.trim(), word.word.trim()].filter(Boolean);
|
||||||
|
if (aliases.length === 0) aliases.push(word.reading.trim());
|
||||||
|
return [...new Set(aliases)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampSeconds(timestamp: number): number {
|
||||||
|
return timestamp < 10_000_000_000 ? timestamp : Math.floor(timestamp / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVocabularySummary(
|
||||||
|
db: DatabaseSync,
|
||||||
|
knownWords: ReadonlySet<string> | null,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): VocabularyStatsSummary {
|
||||||
|
const words = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id AS wordId, headword, word, reading,
|
||||||
|
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||||
|
frequency, frequency_rank AS frequencyRank,
|
||||||
|
first_seen AS firstSeen, last_seen AS lastSeen,
|
||||||
|
0 AS animeCount
|
||||||
|
FROM imm_words
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all() as VocabularyStatsRow[];
|
||||||
|
const excludedAliases = new Set(
|
||||||
|
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
||||||
|
);
|
||||||
|
const weekAgoSec = nowMs / 1000 - 7 * 86_400;
|
||||||
|
const summary: VocabularyStatsSummary = {
|
||||||
|
uniqueWords: 0,
|
||||||
|
uniqueWordsWithoutNames: 0,
|
||||||
|
uniqueKanji: (db.prepare('SELECT COUNT(*) AS count FROM imm_kanji').get() as { count: number })
|
||||||
|
.count,
|
||||||
|
newThisWeek: 0,
|
||||||
|
newThisWeekWithoutNames: 0,
|
||||||
|
knownWordCount: knownWords ? 0 : null,
|
||||||
|
knownWordCountWithoutNames: knownWords ? 0 : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
if (
|
||||||
|
!isVocabularyStatsRowVisible(word) ||
|
||||||
|
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isName = word.pos2 === '固有名詞';
|
||||||
|
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
|
||||||
|
const isKnown = knownWords?.has(word.headword) ?? false;
|
||||||
|
summary.uniqueWords += 1;
|
||||||
|
if (!isName) summary.uniqueWordsWithoutNames += 1;
|
||||||
|
if (isNewThisWeek) {
|
||||||
|
summary.newThisWeek += 1;
|
||||||
|
if (!isName) summary.newThisWeekWithoutNames += 1;
|
||||||
|
}
|
||||||
|
if (isKnown) {
|
||||||
|
summary.knownWordCount! += 1;
|
||||||
|
if (!isName) summary.knownWordCountWithoutNames! += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
export function getStatsExcludedWords(db: DatabaseSync): StatsExcludedWordRow[] {
|
export function getStatsExcludedWords(db: DatabaseSync): StatsExcludedWordRow[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -306,6 +306,16 @@ export interface VocabularyStatsRow {
|
|||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VocabularyStatsSummary {
|
||||||
|
uniqueWords: number;
|
||||||
|
uniqueWordsWithoutNames: number;
|
||||||
|
uniqueKanji: number;
|
||||||
|
newThisWeek: number;
|
||||||
|
newThisWeekWithoutNames: number;
|
||||||
|
knownWordCount: number | null;
|
||||||
|
knownWordCountWithoutNames: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatsExcludedWordRow {
|
export interface StatsExcludedWordRow {
|
||||||
headword: string;
|
headword: string;
|
||||||
word: string;
|
word: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseExcludedWordsBody,
|
parseExcludedWordsBody,
|
||||||
parseIntQuery,
|
parseIntQuery,
|
||||||
parsePositiveIdList,
|
parsePositiveIdList,
|
||||||
|
loadKnownWordsSet,
|
||||||
} from './route-support.js';
|
} from './route-support.js';
|
||||||
|
|
||||||
export function registerStatsLibraryRoutes(
|
export function registerStatsLibraryRoutes(
|
||||||
@@ -31,6 +32,13 @@ export function registerStatsLibraryRoutes(
|
|||||||
return c.json(statsJson('vocabulary', vocab));
|
return c.json(statsJson('vocabulary', vocab));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/vocabulary/summary', async (c) => {
|
||||||
|
const summary = await tracker.getVocabularySummary(
|
||||||
|
loadKnownWordsSet(options?.knownWordCachePath),
|
||||||
|
);
|
||||||
|
return c.json(statsJson('vocabularySummary', summary));
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/stats/excluded-words', async (c) => {
|
app.get('/api/stats/excluded-words', async (c) => {
|
||||||
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
|
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ export interface StatsKnownWordsSummary {
|
|||||||
knownWordCount: number;
|
knownWordCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatsVocabularySummary {
|
||||||
|
uniqueWords: number;
|
||||||
|
uniqueWordsWithoutNames: number;
|
||||||
|
uniqueKanji: number;
|
||||||
|
newThisWeek: number;
|
||||||
|
newThisWeekWithoutNames: number;
|
||||||
|
knownWordCount: number | null;
|
||||||
|
knownWordCountWithoutNames: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatsAnilistSearchResult {
|
export interface StatsAnilistSearchResult {
|
||||||
id: number;
|
id: number;
|
||||||
episodes: number | null;
|
episodes: number | null;
|
||||||
@@ -164,6 +174,7 @@ export interface StatsJsonResponseMap {
|
|||||||
sessionEvents: SessionEvent[];
|
sessionEvents: SessionEvent[];
|
||||||
sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[];
|
sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[];
|
||||||
vocabulary: VocabularyEntry[];
|
vocabulary: VocabularyEntry[];
|
||||||
|
vocabularySummary: StatsVocabularySummary;
|
||||||
excludedWords: StatsExcludedWord[];
|
excludedWords: StatsExcludedWord[];
|
||||||
setExcludedWords: StatsOkResponse;
|
setExcludedWords: StatsOkResponse;
|
||||||
duplicateLineCleanup: StatsDuplicateLineCleanupResult;
|
duplicateLineCleanup: StatsDuplicateLineCleanupResult;
|
||||||
@@ -222,6 +233,7 @@ export interface StatsHttpClient {
|
|||||||
getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise<SessionEvent[]>;
|
getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise<SessionEvent[]>;
|
||||||
getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>;
|
getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>;
|
||||||
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
|
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
|
||||||
|
getVocabularySummary: () => Promise<StatsVocabularySummary>;
|
||||||
getExcludedWords: () => Promise<StatsExcludedWord[]>;
|
getExcludedWords: () => Promise<StatsExcludedWord[]>;
|
||||||
setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>;
|
setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>;
|
||||||
cleanupDuplicateLines: (
|
cleanupDuplicateLines: (
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function VocabularyTab({
|
|||||||
onRemoveExclusion,
|
onRemoveExclusion,
|
||||||
onClearExclusions,
|
onClearExclusions,
|
||||||
}: VocabularyTabProps) {
|
}: 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 [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||||
const [hideNames, setHideNames] = useState(false);
|
const [hideNames, setHideNames] = useState(false);
|
||||||
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
||||||
@@ -48,19 +48,10 @@ export function VocabularyTab({
|
|||||||
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
|
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
|
||||||
return result;
|
return result;
|
||||||
}, [words, hideNames, excluded, isExcluded]);
|
}, [words, hideNames, excluded, isExcluded]);
|
||||||
const summary = useMemo(
|
const chartSummary = useMemo(
|
||||||
() => buildVocabularySummary(filteredWords, kanji),
|
() => buildVocabularySummary(filteredWords, kanji),
|
||||||
[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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -90,29 +81,41 @@ export function VocabularyTab({
|
|||||||
setSelectedKanjiId(entry.kanjiId);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Unique Words"
|
label="Unique Words"
|
||||||
value={formatNumber(summary.uniqueWords)}
|
value={formatNumber(displayedSummary.uniqueWords)}
|
||||||
color="text-ctp-blue"
|
color="text-ctp-blue"
|
||||||
/>
|
/>
|
||||||
{knownWords.size > 0 && (
|
{displayedSummary.knownWordCount !== null && (
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Known Words"
|
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"
|
color="text-ctp-green"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Unique Kanji"
|
label="Unique Kanji"
|
||||||
value={formatNumber(summary.uniqueKanji)}
|
value={formatNumber(summary?.uniqueKanji ?? 0)}
|
||||||
color="text-ctp-teal"
|
color="text-ctp-teal"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="New This Week"
|
label="New This Week"
|
||||||
value={`+${formatNumber(summary.newThisWeek)}`}
|
value={`+${formatNumber(displayedSummary.newThisWeek)}`}
|
||||||
color="text-ctp-mauve"
|
color="text-ctp-mauve"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,14 +157,14 @@ export function VocabularyTab({
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<TrendChart
|
<TrendChart
|
||||||
title="Top Repeated Words"
|
title="Top Repeated Words"
|
||||||
data={summary.topWords}
|
data={chartSummary.topWords}
|
||||||
color="#8aadf4"
|
color="#8aadf4"
|
||||||
type="bar"
|
type="bar"
|
||||||
onBarClick={handleBarClick}
|
onBarClick={handleBarClick}
|
||||||
/>
|
/>
|
||||||
<TrendChart
|
<TrendChart
|
||||||
title="New Words by Day"
|
title="New Words by Day"
|
||||||
data={summary.newWordsTimeline}
|
data={chartSummary.newWordsTimeline}
|
||||||
color="#c6a0f6"
|
color="#c6a0f6"
|
||||||
type="line"
|
type="line"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
|
import type { VocabularyEntry, KanjiEntry, StatsVocabularySummary } from '../types/stats';
|
||||||
|
|
||||||
export function useVocabulary() {
|
export function useVocabulary() {
|
||||||
const [words, setWords] = useState<VocabularyEntry[]>([]);
|
const [words, setWords] = useState<VocabularyEntry[]>([]);
|
||||||
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
|
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
|
||||||
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
|
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
|
||||||
|
const [summary, setSummary] = useState<StatsVocabularySummary | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
|
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
|
||||||
@@ -17,8 +18,13 @@ export function useVocabulary() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const client = getStatsClient();
|
const client = getStatsClient();
|
||||||
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
|
Promise.allSettled([
|
||||||
.then(([wordsResult, kanjiResult, knownResult]) => {
|
client.getVocabulary(500),
|
||||||
|
client.getKanji(200),
|
||||||
|
client.getKnownWords(),
|
||||||
|
client.getVocabularySummary(),
|
||||||
|
])
|
||||||
|
.then(([wordsResult, kanjiResult, knownResult, summaryResult]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
@@ -38,6 +44,12 @@ export function useVocabulary() {
|
|||||||
setKnownWords(new Set(knownResult.value));
|
setKnownWords(new Set(knownResult.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (summaryResult.status === 'fulfilled') {
|
||||||
|
setSummary(summaryResult.value);
|
||||||
|
} else {
|
||||||
|
errors.push(summaryResult.reason.message);
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setError(errors.join('; '));
|
setError(errors.join('; '));
|
||||||
}
|
}
|
||||||
@@ -51,5 +63,5 @@ export function useVocabulary() {
|
|||||||
};
|
};
|
||||||
}, [reloadToken]);
|
}, [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) =>
|
getSessionKnownWordsTimeline: (id: number) =>
|
||||||
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
|
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
|
||||||
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
|
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
|
||||||
|
getVocabularySummary: () => fetchJson('vocabularySummary', '/api/stats/vocabulary/summary'),
|
||||||
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
|
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
|
||||||
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
|
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
|
||||||
await fetchResponse('/api/stats/excluded-words', {
|
await fetchResponse('/api/stats/excluded-words', {
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ test('VocabularyTab declares all hooks before loading and error early returns',
|
|||||||
assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
|
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');
|
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
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(
|
assert.match(
|
||||||
source,
|
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