From a3ed8dcf3db2d6ef5ed8e0cbd721a5233506fd25 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 16 Mar 2026 01:42:40 -0700 Subject: [PATCH] feat(stats): add word exclusion list for vocabulary tab - useExcludedWords hook manages excluded words in localStorage - ExclusionManager modal for viewing/removing excluded words - Exclusion button on WordDetailPanel header - Filtered words affect stat cards, charts, frequency table, and word list --- stats/src/App.tsx | 8 ++ .../vocabulary/ExclusionManager.tsx | 77 +++++++++++++++++ .../components/vocabulary/VocabularyTab.tsx | 48 +++++++++-- stats/src/hooks/useExcludedWords.ts | 83 +++++++++++++++++++ 4 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 stats/src/components/vocabulary/ExclusionManager.tsx create mode 100644 stats/src/hooks/useExcludedWords.ts diff --git a/stats/src/App.tsx b/stats/src/App.tsx index 57b82bf..9070ff1 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -6,12 +6,14 @@ import { AnimeTab } from './components/anime/AnimeTab'; import { VocabularyTab } from './components/vocabulary/VocabularyTab'; import { SessionsTab } from './components/sessions/SessionsTab'; import { WordDetailPanel } from './components/vocabulary/WordDetailPanel'; +import { useExcludedWords } from './hooks/useExcludedWords'; import type { TabId } from './components/layout/TabBar'; export function App() { const [activeTab, setActiveTab] = useState('overview'); const [selectedAnimeId, setSelectedAnimeId] = useState(null); const [globalWordId, setGlobalWordId] = useState(null); + const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords(); const navigateToAnime = useCallback((animeId: number) => { setActiveTab('anime'); @@ -65,6 +67,10 @@ export function App() { ) : null} @@ -79,6 +85,8 @@ export function App() { onClose={() => setGlobalWordId(null)} onSelectWord={openWordDetail} onNavigateToAnime={navigateToAnime} + isExcluded={isExcluded} + onToggleExclusion={toggleExclusion} /> ); diff --git a/stats/src/components/vocabulary/ExclusionManager.tsx b/stats/src/components/vocabulary/ExclusionManager.tsx new file mode 100644 index 0000000..ed590e4 --- /dev/null +++ b/stats/src/components/vocabulary/ExclusionManager.tsx @@ -0,0 +1,77 @@ +import type { ExcludedWord } from '../../hooks/useExcludedWords'; + +interface ExclusionManagerProps { + excluded: ExcludedWord[]; + onRemove: (w: ExcludedWord) => void; + onClearAll: () => void; + onClose: () => void; +} + +export function ExclusionManager({ excluded, onRemove, onClearAll, onClose }: ExclusionManagerProps) { + return ( +
+ + )} + +
+ +
+ {excluded.length === 0 ? ( +
+ No excluded words yet. Use the Exclude button on a word's detail panel to hide it from stats. +
+ ) : ( +
+ {excluded.map(w => ( +
+
+ {w.headword} + {w.reading && w.reading !== w.headword && ( + {w.reading} + )} +
+ +
+ ))} +
+ )} +
+ + + ); +} diff --git a/stats/src/components/vocabulary/VocabularyTab.tsx b/stats/src/components/vocabulary/VocabularyTab.tsx index cd22a8c..6200cec 100644 --- a/stats/src/components/vocabulary/VocabularyTab.tsx +++ b/stats/src/components/vocabulary/VocabularyTab.tsx @@ -4,32 +4,42 @@ import { StatCard } from '../layout/StatCard'; import { WordList } from './WordList'; import { KanjiBreakdown } from './KanjiBreakdown'; import { KanjiDetailPanel } from './KanjiDetailPanel'; +import { ExclusionManager } from './ExclusionManager'; import { 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'; interface VocabularyTabProps { onNavigateToAnime?: (animeId: number) => void; onOpenWordDetail?: (wordId: number) => void; + excluded: ExcludedWord[]; + isExcluded: (w: { headword: string; word: string; reading: string }) => boolean; + onRemoveExclusion: (w: ExcludedWord) => void; + onClearExclusions: () => void; } function isProperNoun(w: VocabularyEntry): boolean { return w.pos2 === 'ε›Ίζœ‰εθ©ž'; } -export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) { +export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, isExcluded, onRemoveExclusion, onClearExclusions }: VocabularyTabProps) { const { words, kanji, knownWords, loading, error } = useVocabulary(); const [selectedKanjiId, setSelectedKanjiId] = useState(null); const [search, setSearch] = useState(''); const [hideNames, setHideNames] = useState(false); + const [showExclusionManager, setShowExclusionManager] = useState(false); const hasNames = useMemo(() => words.some(isProperNoun), [words]); - const filteredWords = useMemo( - () => hideNames ? words.filter((w) => !isProperNoun(w)) : words, - [words, hideNames], - ); + const filteredWords = useMemo(() => { + let result = words; + if (hideNames) result = result.filter((w) => !isProperNoun(w)); + if (excluded.length > 0) result = result.filter((w) => !isExcluded(w)); + return result; + }, [words, hideNames, excluded, isExcluded]); if (loading) { return ( @@ -52,6 +62,11 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular onOpenWordDetail?.(entry.wordId); }; + const handleBarClick = (headword: string): void => { + const match = filteredWords.find(w => w.headword === headword); + if (match) onOpenWordDetail?.(match.wordId); + }; + const openKanjiDetail = (entry: KanjiEntry): void => { setSelectedKanjiId(entry.kanjiId); }; @@ -89,6 +104,17 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular Hide Names )} +
@@ -97,6 +123,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular data={summary.topWords} color="#8aadf4" type="bar" + onBarClick={handleBarClick} /> + + + + {showExclusionManager && ( + setShowExclusionManager(false)} + /> + )}
); } diff --git a/stats/src/hooks/useExcludedWords.ts b/stats/src/hooks/useExcludedWords.ts new file mode 100644 index 0000000..2a60cc7 --- /dev/null +++ b/stats/src/hooks/useExcludedWords.ts @@ -0,0 +1,83 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +export interface ExcludedWord { + headword: string; + word: string; + reading: string; +} + +const STORAGE_KEY = 'subminer-excluded-words'; + +function toKey(w: ExcludedWord): string { + return `${w.headword}\0${w.word}\0${w.reading}`; +} + +let cached: ExcludedWord[] | null = null; +let cachedKeys: Set | null = null; +const listeners = new Set<() => void>(); + +function load(): ExcludedWord[] { + if (cached) return cached; + try { + const raw = localStorage.getItem(STORAGE_KEY); + cached = raw ? JSON.parse(raw) : []; + } catch { + cached = []; + } + return cached!; +} + +function getKeySet(): Set { + if (cachedKeys) return cachedKeys; + cachedKeys = new Set(load().map(toKey)); + return cachedKeys; +} + +function persist(words: ExcludedWord[]) { + cached = words; + cachedKeys = new Set(words.map(toKey)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(words)); + for (const fn of listeners) fn(); +} + +function getSnapshot(): ExcludedWord[] { + return load(); +} + +function subscribe(fn: () => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function useExcludedWords() { + const excluded = useSyncExternalStore(subscribe, getSnapshot); + + const isExcluded = useCallback( + (w: { headword: string; word: string; reading: string }) => getKeySet().has(toKey(w)), + [excluded], + ); + + const toggleExclusion = useCallback( + (w: ExcludedWord) => { + const key = toKey(w); + const current = load(); + if (getKeySet().has(key)) { + persist(current.filter(e => toKey(e) !== key)); + } else { + persist([...current, w]); + } + }, + [], + ); + + const removeExclusion = useCallback( + (w: ExcludedWord) => { + persist(load().filter(e => toKey(e) !== toKey(w))); + }, + [], + ); + + const clearAll = useCallback(() => persist([]), []); + + return { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll }; +}