mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 00:18:41 -07:00
fix(stats): refresh vocabulary aggregates after exclusion edits
- Refetch vocabulary summary/charts once the exclusion list write is server-acknowledged, and retry failed aggregate loads with backoff before showing an inline error with a Retry control - Coalesce concurrent getVocabularySummary calls into a single worker task - Scan imm_words in id-keyed batches to bound memory for large vocabularies
This commit is contained in:
@@ -4,3 +4,4 @@ area: stats
|
|||||||
- Fixed Vocabulary totals and charts counting only the first browsing page instead of all tracked vocabulary, without delaying the rest of the page.
|
- Fixed Vocabulary totals and charts counting only the first browsing page instead of all tracked vocabulary, without delaying the rest of the page.
|
||||||
- New-word history now uses permanent daily lexical rollups, backfilled in the background and repaired when tracked material is removed or reprocessed; playback writes queue safely during the one-time rebuild and resume afterward.
|
- New-word history now uses permanent daily lexical rollups, backfilled in the background and repaired when tracked material is removed or reprocessed; playback writes queue safely during the one-time rebuild and resume afterward.
|
||||||
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
|
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
|
||||||
|
- Vocabulary summary cards and charts refresh automatically after the word exclusion list changes, and failed loads retry with backoff before showing an inline error with a Retry control.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Expandable session history with new-word activity, cumulative totals, and pause/
|
|||||||
|
|
||||||
#### Vocabulary
|
#### Vocabulary
|
||||||
|
|
||||||
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 word and kanji tables load first while those complete totals calculate separately. Top Repeated Words and New Words by Day use complete tracking history rather than the table's browsing page; new-word history is maintained as a permanent daily lexical rollup, including retroactive corrections when tracked material is removed or reprocessed. On the first launch after upgrading, that history is built in the background and the chart refreshes when it is ready. The rest of the tab includes 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 word and kanji tables load first while those complete totals calculate separately. Top Repeated Words and New Words by Day use complete tracking history rather than the table's browsing page; new-word history is maintained as a permanent daily lexical rollup, including retroactive corrections when tracked material is removed or reprocessed. On the first launch after upgrading, that history is built in the background and the chart refreshes when it is ready. The cards and charts also refresh automatically after the word exclusion list changes. The rest of the tab includes 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -5052,3 +5052,58 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getVocabularySummary coalesces concurrent requests into one worker task', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
let taskRuns = 0;
|
||||||
|
let releaseTask: (() => void) | null = null;
|
||||||
|
const summary = {
|
||||||
|
uniqueWords: 1,
|
||||||
|
uniqueWordsWithoutNames: 1,
|
||||||
|
uniqueKanji: 0,
|
||||||
|
newThisWeek: 0,
|
||||||
|
newThisWeekWithoutNames: 0,
|
||||||
|
knownWordCount: null,
|
||||||
|
knownWordCountWithoutNames: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath },
|
||||||
|
{
|
||||||
|
runVocabularySummaryTask: async () => {
|
||||||
|
taskRuns += 1;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseTask = resolve;
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
destroyVocabularySummaryRunner: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = tracker.getVocabularySummary(null);
|
||||||
|
const second = tracker.getVocabularySummary(new Set(['猫']));
|
||||||
|
await waitForCondition(() => releaseTask !== null);
|
||||||
|
let release = releaseTask as (() => void) | null;
|
||||||
|
assert.ok(release);
|
||||||
|
release();
|
||||||
|
assert.deepEqual(await first, summary);
|
||||||
|
assert.equal(await second, await first);
|
||||||
|
assert.equal(taskRuns, 1);
|
||||||
|
|
||||||
|
releaseTask = null;
|
||||||
|
const third = tracker.getVocabularySummary(null);
|
||||||
|
await waitForCondition(() => releaseTask !== null);
|
||||||
|
release = releaseTask as (() => void) | null;
|
||||||
|
assert.ok(release);
|
||||||
|
release();
|
||||||
|
assert.deepEqual(await third, summary);
|
||||||
|
assert.equal(taskRuns, 2);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ export class ImmersionTrackerService {
|
|||||||
private readonly runVocabularySummaryTask: (
|
private readonly runVocabularySummaryTask: (
|
||||||
knownWords: ReadonlySet<string> | null,
|
knownWords: ReadonlySet<string> | null,
|
||||||
) => Promise<VocabularyStatsSummary>;
|
) => Promise<VocabularyStatsSummary>;
|
||||||
|
private vocabularySummaryInFlight: Promise<VocabularyStatsSummary> | null = null;
|
||||||
private readonly destroyVocabularySummaryRunner: () => void;
|
private readonly destroyVocabularySummaryRunner: () => void;
|
||||||
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
|
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
|
||||||
private readonly destroyLexicalRollupBackfillRunner: () => void;
|
private readonly destroyLexicalRollupBackfillRunner: () => void;
|
||||||
@@ -680,7 +681,17 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getVocabularySummary(knownWords: ReadonlySet<string> | null) {
|
async getVocabularySummary(knownWords: ReadonlySet<string> | null) {
|
||||||
return this.runVocabularySummaryTask(knownWords);
|
// Concurrent dashboard refreshes share one worker scan; the coalesced
|
||||||
|
// callers accept the first caller's known-words snapshot.
|
||||||
|
const inFlight = this.vocabularySummaryInFlight;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
const task = this.runVocabularySummaryTask(knownWords);
|
||||||
|
this.vocabularySummaryInFlight = task;
|
||||||
|
try {
|
||||||
|
return await task;
|
||||||
|
} finally {
|
||||||
|
if (this.vocabularySummaryInFlight === task) this.vocabularySummaryInFlight = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVocabularyChartData() {
|
async getVocabularyChartData() {
|
||||||
|
|||||||
@@ -1958,6 +1958,33 @@ test('getVocabularySummary applies vocabulary exclusions and Hide Names totals',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getVocabularySummary counts identically across id-keyed scan batches', () => {
|
||||||
|
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)
|
||||||
|
`);
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
insertWord.run(`単語${index}`, `単語${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000);
|
||||||
|
const batchedScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000, 2);
|
||||||
|
|
||||||
|
assert.equal(fullScan.uniqueWords, 5);
|
||||||
|
assert.deepEqual(batchedScan, fullScan);
|
||||||
|
} 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);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
|||||||
const VOCABULARY_CHART_LIMIT = 12;
|
const VOCABULARY_CHART_LIMIT = 12;
|
||||||
const VOCABULARY_CHART_PAGE_SIZE = 100;
|
const VOCABULARY_CHART_PAGE_SIZE = 100;
|
||||||
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
|
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
|
||||||
|
const VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000;
|
||||||
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
||||||
const SENTENCE_SEARCH_MAX_LIMIT = 100;
|
const SENTENCE_SEARCH_MAX_LIMIT = 100;
|
||||||
const KANJI_PATTERN = /\p{Script=Han}/gu;
|
const KANJI_PATTERN = /\p{Script=Han}/gu;
|
||||||
@@ -293,19 +294,21 @@ export function getVocabularySummary(
|
|||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
knownWords: ReadonlySet<string> | null,
|
knownWords: ReadonlySet<string> | null,
|
||||||
nowMs: number = Date.now(),
|
nowMs: number = Date.now(),
|
||||||
|
scanBatchSize: number = VOCABULARY_SUMMARY_SCAN_BATCH_SIZE,
|
||||||
): VocabularyStatsSummary {
|
): VocabularyStatsSummary {
|
||||||
const words = db
|
// Visibility and exclusion rules live in JS, so rows are scanned in id-keyed
|
||||||
.prepare(
|
// batches to keep memory bounded on large vocabularies.
|
||||||
`
|
const scanStmt = db.prepare(`
|
||||||
SELECT id AS wordId, headword, word, reading,
|
SELECT id AS wordId, headword, word, reading,
|
||||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||||
frequency, frequency_rank AS frequencyRank,
|
frequency, frequency_rank AS frequencyRank,
|
||||||
first_seen AS firstSeen, last_seen AS lastSeen,
|
first_seen AS firstSeen, last_seen AS lastSeen,
|
||||||
0 AS animeCount
|
0 AS animeCount
|
||||||
FROM imm_words
|
FROM imm_words
|
||||||
`,
|
WHERE id > ?
|
||||||
)
|
ORDER BY id
|
||||||
.all() as VocabularyStatsRow[];
|
LIMIT ?
|
||||||
|
`);
|
||||||
const excludedAliases = new Set(
|
const excludedAliases = new Set(
|
||||||
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
||||||
);
|
);
|
||||||
@@ -321,26 +324,33 @@ export function getVocabularySummary(
|
|||||||
knownWordCountWithoutNames: knownWords ? 0 : null,
|
knownWordCountWithoutNames: knownWords ? 0 : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const word of words) {
|
let lastId = Number.MIN_SAFE_INTEGER;
|
||||||
if (
|
for (;;) {
|
||||||
!isVocabularyStatsRowVisible(word) ||
|
const words = scanStmt.all(lastId, scanBatchSize) as VocabularyStatsRow[];
|
||||||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
|
if (words.length === 0) break;
|
||||||
) {
|
lastId = words[words.length - 1]!.wordId;
|
||||||
continue;
|
for (const word of words) {
|
||||||
}
|
if (
|
||||||
const isName = word.pos2 === '固有名詞';
|
!isVocabularyStatsRowVisible(word) ||
|
||||||
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
|
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
|
||||||
const isKnown = knownWords?.has(word.headword) ?? false;
|
) {
|
||||||
summary.uniqueWords += 1;
|
continue;
|
||||||
if (!isName) summary.uniqueWordsWithoutNames += 1;
|
}
|
||||||
if (isNewThisWeek) {
|
const isName = word.pos2 === '固有名詞';
|
||||||
summary.newThisWeek += 1;
|
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
|
||||||
if (!isName) summary.newThisWeekWithoutNames += 1;
|
const isKnown = knownWords?.has(word.headword) ?? false;
|
||||||
}
|
summary.uniqueWords += 1;
|
||||||
if (isKnown) {
|
if (!isName) summary.uniqueWordsWithoutNames += 1;
|
||||||
summary.knownWordCount! += 1;
|
if (isNewThisWeek) {
|
||||||
if (!isName) summary.knownWordCountWithoutNames! += 1;
|
summary.newThisWeek += 1;
|
||||||
|
if (!isName) summary.newThisWeekWithoutNames += 1;
|
||||||
|
}
|
||||||
|
if (isKnown) {
|
||||||
|
summary.knownWordCount! += 1;
|
||||||
|
if (!isName) summary.knownWordCountWithoutNames! += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (words.length < scanBatchSize) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
|
|||||||
@@ -34,7 +34,18 @@ export function VocabularyTab({
|
|||||||
onRemoveExclusion,
|
onRemoveExclusion,
|
||||||
onClearExclusions,
|
onClearExclusions,
|
||||||
}: VocabularyTabProps) {
|
}: VocabularyTabProps) {
|
||||||
const { words, kanji, knownWords, summary, charts, loading, error, reload } = useVocabulary();
|
const {
|
||||||
|
words,
|
||||||
|
kanji,
|
||||||
|
knownWords,
|
||||||
|
summary,
|
||||||
|
charts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
aggregatesError,
|
||||||
|
refreshAggregates,
|
||||||
|
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);
|
||||||
@@ -49,21 +60,21 @@ export function VocabularyTab({
|
|||||||
}, [words, hideNames, excluded, isExcluded]);
|
}, [words, hideNames, excluded, isExcluded]);
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
topWords:
|
topWords: ((hideNames ? charts?.topWordsWithoutNames : charts?.topWords) ?? []).map(
|
||||||
((hideNames ? charts?.topWordsWithoutNames : charts?.topWords) ?? []).map((word) => ({
|
(word) => ({
|
||||||
label: word.headword,
|
label: word.headword,
|
||||||
value: word.frequency,
|
value: word.frequency,
|
||||||
})) ?? [],
|
}),
|
||||||
newWordsTimeline:
|
),
|
||||||
((hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? []).map(
|
newWordsTimeline: (
|
||||||
(point) => ({
|
(hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? []
|
||||||
label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, {
|
).map((point) => ({
|
||||||
month: 'short',
|
label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, {
|
||||||
day: 'numeric',
|
month: 'short',
|
||||||
}),
|
day: 'numeric',
|
||||||
value: point.wordCount,
|
}),
|
||||||
}),
|
value: point.wordCount,
|
||||||
) ?? [],
|
})),
|
||||||
}),
|
}),
|
||||||
[charts, hideNames],
|
[charts, hideNames],
|
||||||
);
|
);
|
||||||
@@ -139,6 +150,19 @@ export function VocabularyTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{aggregatesError && (
|
||||||
|
<p className="text-xs text-ctp-red" role="alert">
|
||||||
|
{aggregatesError}{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refreshAggregates}
|
||||||
|
className="underline hover:text-ctp-text"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
{hasNames && (
|
{hasNames && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ let cachedKeys: Set<string> | null = null;
|
|||||||
let initialized: Promise<void> | null = null;
|
let initialized: Promise<void> | null = null;
|
||||||
let revision = 0;
|
let revision = 0;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
// Fires only after the stats server acknowledged an exclusion write, so
|
||||||
|
// subscribers can refetch server-computed aggregates without racing the POST.
|
||||||
|
const serverSyncListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function subscribeExcludedWordsServerSync(fn: () => void): () => void {
|
||||||
|
serverSyncListeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
serverSyncListeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readLocalStorage(): ExcludedWord[] {
|
function readLocalStorage(): ExcludedWord[] {
|
||||||
if (typeof localStorage === 'undefined') return [];
|
if (typeof localStorage === 'undefined') return [];
|
||||||
@@ -104,6 +114,7 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise<void> {
|
|||||||
applyWords(normalized);
|
applyWords(normalized);
|
||||||
try {
|
try {
|
||||||
await apiClient.setExcludedWords(normalized);
|
await apiClient.setExcludedWords(normalized);
|
||||||
|
for (const fn of serverSyncListeners) fn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (revision === writeRevision) {
|
if (revision === writeRevision) {
|
||||||
revision = previousRevision;
|
revision = previousRevision;
|
||||||
@@ -155,6 +166,7 @@ export function resetExcludedWordsStoreForTests(): void {
|
|||||||
initialized = null;
|
initialized = null;
|
||||||
revision = 0;
|
revision = 0;
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
|
serverSyncListeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(fn: () => void): () => void {
|
function subscribe(fn: () => void): () => void {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
|
import { subscribeExcludedWordsServerSync } from './useExcludedWords';
|
||||||
import type {
|
import type {
|
||||||
VocabularyEntry,
|
VocabularyEntry,
|
||||||
KanjiEntry,
|
KanjiEntry,
|
||||||
@@ -7,6 +8,17 @@ import type {
|
|||||||
StatsVocabularySummary,
|
StatsVocabularySummary,
|
||||||
} from '../types/stats';
|
} from '../types/stats';
|
||||||
|
|
||||||
|
const AGGREGATE_RETRY_BASE_MS = 1_000;
|
||||||
|
const AGGREGATE_RETRY_MAX_MS = 30_000;
|
||||||
|
const AGGREGATE_RETRY_LIMIT = 5;
|
||||||
|
const CHART_BACKFILL_POLL_MS = 1_000;
|
||||||
|
const CHART_BACKFILL_SLOW_POLL_MS = 5_000;
|
||||||
|
const CHART_BACKFILL_FAST_POLLS = 30;
|
||||||
|
|
||||||
|
function aggregateRetryDelayMs(attempt: number): number {
|
||||||
|
return Math.min(AGGREGATE_RETRY_BASE_MS * 2 ** attempt, AGGREGATE_RETRY_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
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[]>([]);
|
||||||
@@ -15,16 +27,24 @@ export function useVocabulary() {
|
|||||||
const [charts, setCharts] = useState<StatsVocabularyCharts | null>(null);
|
const [charts, setCharts] = useState<StatsVocabularyCharts | 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);
|
||||||
|
const [aggregatesError, setAggregatesError] = useState<string | null>(null);
|
||||||
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
|
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
|
||||||
const [reloadToken, setReloadToken] = useState(0);
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
const reload = useCallback(() => setReloadToken((token) => token + 1), []);
|
// Bumped independently when only the server-computed summary/charts are
|
||||||
|
// stale, e.g. after the exclusion list changes on the server.
|
||||||
|
const [aggregatesToken, setAggregatesToken] = useState(0);
|
||||||
|
const refreshAggregates = useCallback(() => setAggregatesToken((token) => token + 1), []);
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setReloadToken((token) => token + 1);
|
||||||
|
setAggregatesToken((token) => token + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => subscribeExcludedWordsServerSync(refreshAggregates), [refreshAggregates]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSummary(null);
|
|
||||||
setCharts(null);
|
|
||||||
const client = getStatsClient();
|
const client = getStatsClient();
|
||||||
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
|
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
|
||||||
.then(([wordsResult, kanjiResult, knownResult]) => {
|
.then(([wordsResult, kanjiResult, knownResult]) => {
|
||||||
@@ -55,36 +75,85 @@ export function useVocabulary() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
void client
|
return () => {
|
||||||
.getVocabularySummary()
|
cancelled = true;
|
||||||
.then((nextSummary) => {
|
};
|
||||||
if (!cancelled) setSummary(nextSummary);
|
}, [reloadToken]);
|
||||||
})
|
|
||||||
.catch((summaryError: unknown) => {
|
useEffect(() => {
|
||||||
console.error('Failed to load vocabulary summary', summaryError);
|
let cancelled = false;
|
||||||
});
|
setAggregatesError(null);
|
||||||
let chartRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
const client = getStatsClient();
|
||||||
const loadCharts = (): void => {
|
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
const schedule = (fn: () => void, delayMs: number): void => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timers.delete(timer);
|
||||||
|
fn();
|
||||||
|
}, delayMs);
|
||||||
|
timers.add(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSummary = (attempt: number): void => {
|
||||||
|
void client
|
||||||
|
.getVocabularySummary()
|
||||||
|
.then((nextSummary) => {
|
||||||
|
if (!cancelled) setSummary(nextSummary);
|
||||||
|
})
|
||||||
|
.catch((summaryError: unknown) => {
|
||||||
|
console.error('Failed to load vocabulary summary', summaryError);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (attempt + 1 < AGGREGATE_RETRY_LIMIT) {
|
||||||
|
schedule(() => loadSummary(attempt + 1), aggregateRetryDelayMs(attempt));
|
||||||
|
} else {
|
||||||
|
setAggregatesError((previous) => previous ?? 'Vocabulary totals failed to load.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCharts = (attempt: number, readyPolls: number): void => {
|
||||||
void client
|
void client
|
||||||
.getVocabularyCharts()
|
.getVocabularyCharts()
|
||||||
.then((nextCharts) => {
|
.then((nextCharts) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCharts(nextCharts);
|
setCharts(nextCharts);
|
||||||
if (!nextCharts.ready) {
|
if (!nextCharts.ready) {
|
||||||
chartRetryTimer = setTimeout(loadCharts, 1_000);
|
schedule(
|
||||||
|
() => loadCharts(0, readyPolls + 1),
|
||||||
|
readyPolls < CHART_BACKFILL_FAST_POLLS
|
||||||
|
? CHART_BACKFILL_POLL_MS
|
||||||
|
: CHART_BACKFILL_SLOW_POLL_MS,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((chartError: unknown) => {
|
.catch((chartError: unknown) => {
|
||||||
console.error('Failed to load vocabulary charts', chartError);
|
console.error('Failed to load vocabulary charts', chartError);
|
||||||
if (!cancelled) chartRetryTimer = setTimeout(loadCharts, 1_000);
|
if (cancelled) return;
|
||||||
|
if (attempt + 1 < AGGREGATE_RETRY_LIMIT) {
|
||||||
|
schedule(() => loadCharts(attempt + 1, readyPolls), aggregateRetryDelayMs(attempt));
|
||||||
|
} else {
|
||||||
|
setAggregatesError((previous) => previous ?? 'Vocabulary charts failed to load.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
loadCharts();
|
|
||||||
|
loadSummary(0);
|
||||||
|
loadCharts(0, 0);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (chartRetryTimer) clearTimeout(chartRetryTimer);
|
for (const timer of timers) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [reloadToken]);
|
}, [aggregatesToken]);
|
||||||
|
|
||||||
return { words, kanji, knownWords, summary, charts, loading, error, reload };
|
return {
|
||||||
|
words,
|
||||||
|
kanji,
|
||||||
|
knownWords,
|
||||||
|
summary,
|
||||||
|
charts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
aggregatesError,
|
||||||
|
refreshAggregates,
|
||||||
|
reload,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ test('VocabularyTab declares all hooks before loading and error early returns',
|
|||||||
test('VocabularyTab uses uncapped server-side data for its charts and card totals', () => {
|
test('VocabularyTab uses uncapped server-side data for its charts and card totals', () => {
|
||||||
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
|
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
|
||||||
|
|
||||||
assert.match(
|
assert.match(source, /\} = useVocabulary\(\);/);
|
||||||
source,
|
|
||||||
/const \{ words, kanji, knownWords, summary, charts, loading, error, reload \} = useVocabulary\(\);/,
|
|
||||||
);
|
|
||||||
assert.match(source, /charts\?\.topWordsWithoutNames/);
|
assert.match(source, /charts\?\.topWordsWithoutNames/);
|
||||||
assert.match(source, /charts\?\.newWordsTimelineWithoutNames/);
|
assert.match(source, /charts\?\.newWordsTimelineWithoutNames/);
|
||||||
assert.doesNotMatch(source, /buildVocabularySummary\(/);
|
assert.doesNotMatch(source, /buildVocabularySummary\(/);
|
||||||
@@ -36,6 +33,13 @@ test('VocabularyTab uses uncapped server-side data for its charts and card total
|
|||||||
assert.match(source, /value=\{summary \? formatNumber\(summary\.uniqueKanji\) : '…'\}/);
|
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', () => {
|
test('useVocabulary loads exact card totals without holding up the vocabulary tables', () => {
|
||||||
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
|
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
|
||||||
|
|
||||||
@@ -43,6 +47,20 @@ test('useVocabulary loads exact card totals without holding up the vocabulary ta
|
|||||||
source,
|
source,
|
||||||
/Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
|
/Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
|
||||||
);
|
);
|
||||||
assert.match(source, /void client\s*\.getVocabularySummary\(\)\s*\.then\(/);
|
assert.match(source, /client\s*\.getVocabularySummary\(\)\s*\.then\(/);
|
||||||
assert.match(source, /client\s*\.getVocabularyCharts\(\)/);
|
assert.match(source, /client\s*\.getVocabularyCharts\(\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('useVocabulary refetches aggregates after server-acknowledged exclusion edits', () => {
|
||||||
|
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /subscribeExcludedWordsServerSync\(refreshAggregates\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('useVocabulary bounds aggregate retries instead of polling failures forever', () => {
|
||||||
|
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /AGGREGATE_RETRY_LIMIT/);
|
||||||
|
assert.match(source, /aggregateRetryDelayMs\(attempt\)/);
|
||||||
|
assert.match(source, /CHART_BACKFILL_SLOW_POLL_MS/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user