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
@@ -6,11 +6,10 @@ import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel';
import { ExclusionManager } from './ExclusionManager';
import { DuplicateLineCleanup } from './DuplicateLineCleanup';
import { formatNumber } from '../../lib/formatters';
import { epochDayToDate, formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable';
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { ExcludedWord } from '../../hooks/useExcludedWords';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
@@ -35,7 +34,18 @@ export function VocabularyTab({
onRemoveExclusion,
onClearExclusions,
}: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error, reload } = useVocabulary();
const {
words,
kanji,
knownWords,
summary,
charts,
loading,
error,
aggregatesError,
refreshAggregates,
reload,
} = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false);
@@ -48,19 +58,26 @@ export function VocabularyTab({
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
return result;
}, [words, hideNames, excluded, isExcluded]);
const summary = useMemo(
() => buildVocabularySummary(filteredWords, kanji),
[filteredWords, kanji],
const chartData = useMemo(
() => ({
topWords: ((hideNames ? charts?.topWordsWithoutNames : charts?.topWords) ?? []).map(
(word) => ({
label: word.headword,
value: word.frequency,
}),
),
newWordsTimeline: (
(hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? []
).map((point) => ({
label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
value: point.wordCount,
})),
}),
[charts, hideNames],
);
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) {
return (
@@ -82,7 +99,9 @@ export function VocabularyTab({
};
const handleBarClick = (headword: string): void => {
const match = filteredWords.find((w) => w.headword === headword);
const match = (hideNames ? charts?.topWordsWithoutNames : charts?.topWords)?.find(
(word) => word.headword === headword,
);
if (match) onOpenWordDetail?.(match.wordId);
};
@@ -90,33 +109,60 @@ export function VocabularyTab({
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard
label="Unique Words"
value={formatNumber(summary.uniqueWords)}
value={summary ? formatNumber(displayedSummary.uniqueWords) : '…'}
color="text-ctp-blue"
/>
{knownWords.size > 0 && (
{displayedSummary.knownWordCount !== null ? (
<StatCard
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"
/>
)}
) : knownWords.size > 0 ? (
<StatCard label="Known Words" value="…" color="text-ctp-green" />
) : null}
<StatCard
label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)}
value={summary ? formatNumber(summary.uniqueKanji) : '…'}
color="text-ctp-teal"
/>
<StatCard
label="New This Week"
value={`+${formatNumber(summary.newThisWeek)}`}
value={summary ? `+${formatNumber(displayedSummary.newThisWeek)}` : '…'}
color="text-ctp-mauve"
/>
</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">
{hasNames && (
<button
@@ -154,19 +200,25 @@ export function VocabularyTab({
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TrendChart
title="Top Repeated Words"
data={summary.topWords}
data={chartData.topWords}
color="#8aadf4"
type="bar"
onBarClick={handleBarClick}
/>
<TrendChart
title="New Words by Day"
data={summary.newWordsTimeline}
data={chartData.newWordsTimeline}
color="#c6a0f6"
type="line"
/>
</div>
{charts && !charts.ready && (
<p className="text-xs text-ctp-overlay1" role="status">
Building vocabulary history in the background
</p>
)}
<FrequencyRankTable
words={filteredWords}
knownWords={knownWords}
+89
View File
@@ -6,6 +6,7 @@ import {
initializeExcludedWordsStore,
resetExcludedWordsStoreForTests,
setExcludedWords,
subscribeExcludedWordsServerSync,
} from './useExcludedWords';
import { BASE_URL } from '../lib/api-client';
@@ -199,3 +200,91 @@ test('initializeExcludedWordsStore retries after transient database load failure
resetExcludedWordsStoreForTests();
}
});
test('a failing server-sync listener neither rolls back the write nor blocks other listeners', async () => {
resetExcludedWordsStoreForTests();
const { values: storage, restore } = installLocalStorage();
const originalFetch = globalThis.fetch;
const originalConsoleError = console.error;
console.error = () => {};
globalThis.fetch = (async () =>
new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch;
const notified: string[] = [];
const unsubscribeFirst = subscribeExcludedWordsServerSync(() => {
notified.push('first');
throw new Error('listener exploded');
});
const unsubscribeSecond = subscribeExcludedWordsServerSync(() => {
notified.push('second');
});
try {
const rows = [{ headword: 'する', word: 'する', reading: 'する' }];
await assert.doesNotReject(() => setExcludedWords(rows));
assert.deepEqual(notified, ['first', 'second']);
assert.deepEqual(getExcludedWordsSnapshot(), rows);
assert.equal(storage.get(STORAGE_KEY), JSON.stringify(rows));
} finally {
unsubscribeFirst();
unsubscribeSecond();
globalThis.fetch = originalFetch;
console.error = originalConsoleError;
restore();
resetExcludedWordsStoreForTests();
}
});
test('overlapping writes serialize so an older list cannot overwrite a newer edit', async () => {
resetExcludedWordsStoreForTests();
const { restore } = installLocalStorage();
const originalFetch = globalThis.fetch;
const sentBodies: string[] = [];
let releaseFirst: (() => void) | null = null;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
sentBodies.push(String(init?.body ?? ''));
if (sentBodies.length === 1) {
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof globalThis.fetch;
const syncs: string[] = [];
const unsubscribe = subscribeExcludedWordsServerSync(() => {
syncs.push(JSON.stringify(getExcludedWordsSnapshot()));
});
try {
const first = [{ headword: '猫', word: '猫', reading: 'ねこ' }];
const second = [...first, { headword: '犬', word: '犬', reading: 'いぬ' }];
const third = [...second, { headword: '鳥', word: '鳥', reading: 'とり' }];
// The first write reaches the network before the later edits are made.
const firstWrite = setExcludedWords(first);
await new Promise((resolve) => setTimeout(resolve, 0));
const secondWrite = setExcludedWords(second);
const thirdWrite = setExcludedWords(third);
const release = releaseFirst as (() => void) | null;
assert.ok(release, 'expected the first write to be in flight');
release();
await Promise.all([firstWrite, secondWrite, thirdWrite]);
// The in-flight write finishes first, the superseded middle write is
// dropped, and the newest list is the last thing the server is told.
assert.deepEqual(sentBodies, [
JSON.stringify({ words: first }),
JSON.stringify({ words: third }),
]);
assert.deepEqual(getExcludedWordsSnapshot(), third);
// Only the final revision notifies: the first write's acknowledgement was
// already obsolete, so it must not trigger an aggregate recomputation.
assert.deepEqual(syncs, [JSON.stringify(third)]);
} finally {
unsubscribe();
globalThis.fetch = originalFetch;
restore();
resetExcludedWordsStoreForTests();
}
});
+48 -9
View File
@@ -44,6 +44,32 @@ let cachedKeys: Set<string> | null = null;
let initialized: Promise<void> | null = null;
let revision = 0;
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 notifyServerSync(): void {
// Listener failures are their own concern: one must not roll back a write
// that already succeeded, nor stop the remaining listeners from running.
for (const fn of serverSyncListeners) {
try {
fn();
} catch (error) {
console.error('Excluded words server-sync listener failed', error);
}
}
}
// Full-list writes are serialized so a slow earlier request cannot land after a
// newer one and overwrite it with a stale list.
let writeChain: Promise<void> = Promise.resolve();
function readLocalStorage(): ExcludedWord[] {
if (typeof localStorage === 'undefined') return [];
@@ -102,16 +128,27 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise<void> {
const normalized = dedupeExcludedWords(words);
revision = writeRevision;
applyWords(normalized);
try {
await apiClient.setExcludedWords(normalized);
} catch (error) {
if (revision === writeRevision) {
revision = previousRevision;
applyWords(previousWords);
const write = writeChain.then(async () => {
// A newer edit already superseded this list and carries the newest state,
// so sending this one would push a stale list to the server.
if (revision !== writeRevision) return;
try {
await apiClient.setExcludedWords(normalized);
} catch (error) {
if (revision === writeRevision) {
revision = previousRevision;
applyWords(previousWords);
}
console.error('Failed to persist excluded words to stats database', error);
throw error;
}
console.error('Failed to persist excluded words to stats database', error);
throw error;
}
// A newer edit arrived while this write was in flight, so the server state
// this acknowledges is already obsolete. Its own acknowledgement notifies
// with the newest list; skipping here avoids a wasted aggregate scan.
if (revision === writeRevision) notifyServerSync();
});
writeChain = write.catch(() => {});
return write;
}
export function initializeExcludedWordsStore(): Promise<void> {
@@ -155,6 +192,8 @@ export function resetExcludedWordsStoreForTests(): void {
initialized = null;
revision = 0;
listeners.clear();
serverSyncListeners.clear();
writeChain = Promise.resolve();
}
function subscribe(fn: () => void): () => void {
+447
View File
@@ -0,0 +1,447 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { apiClient } from '../lib/api-client';
import { resetExcludedWordsStoreForTests, setExcludedWords } from './useExcludedWords';
import { useVocabulary } from './useVocabulary';
import type { StatsVocabularyCharts, StatsVocabularySummary } from '../types/stats';
type VocabularyState = ReturnType<typeof useVocabulary>;
function installDom(): () => void {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
const previousIsReactActEnvironment = Object.getOwnPropertyDescriptor(
globalThis,
'IS_REACT_ACT_ENVIRONMENT',
);
const window = new Window();
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
value: true,
configurable: true,
writable: true,
});
return () => {
const restoreProperty = (name: string, descriptor: PropertyDescriptor | undefined) => {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
};
restoreProperty('window', previousWindow);
restoreProperty('document', previousDocument);
restoreProperty('HTMLElement', previousHTMLElement);
restoreProperty('IS_REACT_ACT_ENVIRONMENT', previousIsReactActEnvironment);
};
}
test('DOM harness restores the original global property descriptors', () => {
const propertyNames = ['window', 'document', 'HTMLElement', 'IS_REACT_ACT_ENVIRONMENT'] as const;
const before = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
const restore = installDom();
restore();
const after = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
assert.deepEqual(after, before);
});
function installLocalStorage(): () => void {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
return () => {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else delete (globalThis as { localStorage?: unknown }).localStorage;
};
}
interface FakeClock {
tick: (ms: number) => void;
restore: () => void;
}
/** Bun's `node:test` shim has no `mock.timers`, so the retry clock is faked here. */
function installFakeTimers(): FakeClock {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const timers = new Map<number, { at: number; fn: () => void }>();
let now = 0;
let nextId = 1;
globalThis.setTimeout = ((fn: () => void, delay = 0) => {
const id = nextId;
nextId += 1;
timers.set(id, { at: now + delay, fn });
return id;
}) as unknown as typeof globalThis.setTimeout;
globalThis.clearTimeout = ((id: number) => {
timers.delete(id);
}) as unknown as typeof globalThis.clearTimeout;
return {
tick: (ms: number) => {
now += ms;
const due = [...timers.entries()]
.filter(([, timer]) => timer.at <= now)
.sort(([, a], [, b]) => a.at - b.at);
for (const [id, timer] of due) {
timers.delete(id);
timer.fn();
}
},
restore: () => {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
},
};
}
function summaryFixture(): StatsVocabularySummary {
return {
uniqueWords: 42,
uniqueWordsWithoutNames: 40,
uniqueKanji: 7,
newThisWeek: 3,
newThisWeekWithoutNames: 2,
knownWordCount: 10,
knownWordCountWithoutNames: 9,
};
}
function chartsFixture(overrides: Partial<StatsVocabularyCharts> = {}): StatsVocabularyCharts {
return {
ready: true,
topWords: [{ wordId: 1, headword: '猫', frequency: 5 }],
topWordsWithoutNames: [{ wordId: 1, headword: '猫', frequency: 5 }],
newWordsTimeline: [{ epochDay: 20_000, wordCount: 4 }],
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 4 }],
...overrides,
};
}
interface Harness {
state: () => VocabularyState;
flush: () => Promise<void>;
tick: (ms: number) => Promise<void>;
unmount: () => Promise<void>;
teardown: () => Promise<void>;
}
async function mountHook(): Promise<Harness> {
const uninstallDom = installDom();
const uninstallLocalStorage = installLocalStorage();
const clock = installFakeTimers();
let latest: VocabularyState | null = null;
function Probe() {
latest = useVocabulary();
return null;
}
const container = document.createElement('div');
document.body.append(container);
let root: Root | null = createRoot(container);
await act(async () => {
root!.render(<Probe />);
});
const flush = async (): Promise<void> => {
// Drain promise callbacks without advancing the mocked clock.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
return {
state: () => {
assert.ok(latest, 'expected the hook to have rendered');
return latest;
},
flush,
tick: async (ms: number) => {
await act(async () => {
clock.tick(ms);
});
await flush();
},
unmount: async () => {
await act(async () => {
root?.unmount();
root = null;
});
},
teardown: async () => {
await act(async () => {
root?.unmount();
root = null;
});
clock.restore();
// React's scheduler can still have deferred work queued; let it drain on
// a real timer while the DOM globals it reads are still installed.
await new Promise((resolve) => setTimeout(resolve, 0));
uninstallLocalStorage();
uninstallDom();
resetExcludedWordsStoreForTests();
},
};
}
function stubVocabularyClient(overrides: {
getVocabularySummary: () => Promise<StatsVocabularySummary>;
getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
}): () => void {
const original = {
getVocabulary: apiClient.getVocabulary,
getKanji: apiClient.getKanji,
getKnownWords: apiClient.getKnownWords,
getVocabularySummary: apiClient.getVocabularySummary,
getVocabularyCharts: apiClient.getVocabularyCharts,
setExcludedWords: apiClient.setExcludedWords,
};
apiClient.getVocabulary = async () => [];
apiClient.getKanji = async () => [];
apiClient.getKnownWords = async () => [];
apiClient.setExcludedWords = async () => {};
apiClient.getVocabularySummary = overrides.getVocabularySummary;
apiClient.getVocabularyCharts = overrides.getVocabularyCharts;
return () => Object.assign(apiClient, original);
}
test('aggregate failures retry with backoff, then surface an error that Retry clears', async () => {
const originalConsoleError = console.error;
console.error = () => {};
let summaryCalls = 0;
let failSummary = true;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
if (failSummary) throw new Error('summary unavailable');
return summaryFixture();
},
getVocabularyCharts: async () => chartsFixture(),
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(summaryCalls, 1);
assert.equal(harness.state().aggregatesError, null, 'no error until retries are exhausted');
// Backoff is 1s, 2s, 4s, 8s across the remaining four attempts.
for (const delayMs of [1_000, 2_000, 4_000, 8_000]) {
await harness.tick(delayMs);
}
assert.equal(summaryCalls, 5, 'retries are bounded at the attempt limit');
assert.match(harness.state().aggregatesError ?? '', /totals failed to load/i);
// Nothing further is scheduled once the limit is reached.
await harness.tick(60_000);
assert.equal(summaryCalls, 5);
failSummary = false;
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
assert.equal(summaryCalls, 6);
assert.equal(harness.state().aggregatesError, null);
assert.deepEqual(harness.state().summary, summaryFixture());
} finally {
await harness.teardown();
restoreClient();
console.error = originalConsoleError;
}
});
test('charts poll while the backfill is pending and stop once it is ready', async () => {
let chartCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => summaryFixture(),
getVocabularyCharts: async () => {
chartCalls += 1;
return chartsFixture({ ready: chartCalls >= 3 });
},
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(chartCalls, 1);
assert.equal(harness.state().charts?.ready, false);
await harness.tick(1_000);
assert.equal(chartCalls, 2);
await harness.tick(1_000);
assert.equal(chartCalls, 3);
assert.equal(harness.state().charts?.ready, true);
// A ready result ends the poll.
await harness.tick(60_000);
assert.equal(chartCalls, 3);
} finally {
await harness.teardown();
restoreClient();
}
});
test('chart backfill polling stops and surfaces Retry when readiness never arrives', async () => {
let chartCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => summaryFixture(),
getVocabularyCharts: async () => {
chartCalls += 1;
return chartsFixture({ ready: false });
},
});
const harness = await mountHook();
try {
await harness.flush();
for (let poll = 0; poll < 65; poll += 1) await harness.tick(5_000);
assert.equal(chartCalls, 60, 'a failed backfill must not poll for the lifetime of the tab');
assert.match(harness.state().aggregatesError ?? '', /still building/i);
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
assert.equal(chartCalls, 61, 'Retry starts one fresh bounded polling cycle');
assert.equal(harness.state().aggregatesError, null);
} finally {
await harness.teardown();
restoreClient();
}
});
test('aggregates refetch after an exclusion edit is acknowledged by the server', async () => {
let summaryCalls = 0;
let chartCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
return summaryFixture();
},
getVocabularyCharts: async () => {
chartCalls += 1;
return chartsFixture();
},
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(summaryCalls, 1);
assert.equal(chartCalls, 1);
await act(async () => {
await setExcludedWords([{ headword: '猫', word: '猫', reading: 'ねこ' }]);
});
await harness.flush();
assert.equal(summaryCalls, 2, 'totals must not keep counting the excluded word');
assert.equal(chartCalls, 2);
} finally {
await harness.teardown();
restoreClient();
}
});
test('pending retries are cancelled when the tab unmounts', async () => {
const originalConsoleError = console.error;
console.error = () => {};
let summaryCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
throw new Error('summary unavailable');
},
getVocabularyCharts: async () => chartsFixture(),
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(summaryCalls, 1);
await harness.unmount();
await harness.tick(60_000);
assert.equal(summaryCalls, 1, 'no retry may run after unmount');
} finally {
await harness.teardown();
restoreClient();
console.error = originalConsoleError;
}
});
test('a slow response from a superseded refresh cannot replace the newest aggregates', async () => {
let summaryCalls = 0;
let releaseSuperseded: (() => void) | null = null;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => {
summaryCalls += 1;
const call = summaryCalls;
// The second call is the one that gets superseded while still in flight.
if (call === 2) {
await new Promise<void>((resolve) => {
releaseSuperseded = resolve;
});
}
return { ...summaryFixture(), uniqueWords: call };
},
getVocabularyCharts: async () => chartsFixture(),
});
const harness = await mountHook();
try {
await harness.flush();
assert.equal(harness.state().summary?.uniqueWords, 1);
// First refresh stalls, then a second refresh supersedes it and resolves.
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
assert.equal(summaryCalls, 3);
assert.equal(harness.state().summary?.uniqueWords, 3);
const release = releaseSuperseded as (() => void) | null;
assert.ok(release, 'expected the superseded request to still be in flight');
release();
await harness.flush();
assert.equal(
harness.state().summary?.uniqueWords,
3,
'the superseded response must not overwrite the newest totals',
);
} finally {
await harness.teardown();
restoreClient();
}
});
+116 -3
View File
@@ -1,16 +1,46 @@
import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi';
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
import { subscribeExcludedWordsServerSync } from './useExcludedWords';
import type {
VocabularyEntry,
KanjiEntry,
StatsVocabularyCharts,
StatsVocabularySummary,
} 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;
const CHART_BACKFILL_POLL_LIMIT = 60;
function aggregateRetryDelayMs(attempt: number): number {
return Math.min(AGGREGATE_RETRY_BASE_MS * 2 ** attempt, AGGREGATE_RETRY_MAX_MS);
}
export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
const [summary, setSummary] = useState<StatsVocabularySummary | null>(null);
const [charts, setCharts] = useState<StatsVocabularyCharts | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [aggregatesError, setAggregatesError] = useState<string | null>(null);
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
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(() => {
let cancelled = false;
@@ -51,5 +81,88 @@ export function useVocabulary() {
};
}, [reloadToken]);
return { words, kanji, knownWords, loading, error, reload };
useEffect(() => {
let cancelled = false;
setAggregatesError(null);
const client = getStatsClient();
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
.getVocabularyCharts()
.then((nextCharts) => {
if (cancelled) return;
setCharts(nextCharts);
if (!nextCharts.ready) {
const completedPolls = readyPolls + 1;
if (completedPolls < CHART_BACKFILL_POLL_LIMIT) {
schedule(
() => loadCharts(0, completedPolls),
completedPolls < CHART_BACKFILL_FAST_POLLS
? CHART_BACKFILL_POLL_MS
: CHART_BACKFILL_SLOW_POLL_MS,
);
} else {
setAggregatesError(
(previous) =>
previous ?? 'Vocabulary charts are still building. Retry to check again.',
);
}
}
})
.catch((chartError: unknown) => {
console.error('Failed to load vocabulary charts', chartError);
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.');
}
});
};
loadSummary(0);
loadCharts(0, 0);
return () => {
cancelled = true;
for (const timer of timers) clearTimeout(timer);
};
}, [aggregatesToken]);
return {
words,
kanji,
knownWords,
summary,
charts,
loading,
error,
aggregatesError,
refreshAggregates,
reload,
};
}
+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\(\)/);
});