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
This commit is contained in:
2026-03-16 01:42:40 -07:00
parent 92c1557e46
commit a3ed8dcf3d
4 changed files with 211 additions and 5 deletions

View File

@@ -6,12 +6,14 @@ import { AnimeTab } from './components/anime/AnimeTab';
import { VocabularyTab } from './components/vocabulary/VocabularyTab'; import { VocabularyTab } from './components/vocabulary/VocabularyTab';
import { SessionsTab } from './components/sessions/SessionsTab'; import { SessionsTab } from './components/sessions/SessionsTab';
import { WordDetailPanel } from './components/vocabulary/WordDetailPanel'; import { WordDetailPanel } from './components/vocabulary/WordDetailPanel';
import { useExcludedWords } from './hooks/useExcludedWords';
import type { TabId } from './components/layout/TabBar'; import type { TabId } from './components/layout/TabBar';
export function App() { export function App() {
const [activeTab, setActiveTab] = useState<TabId>('overview'); const [activeTab, setActiveTab] = useState<TabId>('overview');
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null); const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
const [globalWordId, setGlobalWordId] = useState<number | null>(null); const [globalWordId, setGlobalWordId] = useState<number | null>(null);
const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords();
const navigateToAnime = useCallback((animeId: number) => { const navigateToAnime = useCallback((animeId: number) => {
setActiveTab('anime'); setActiveTab('anime');
@@ -65,6 +67,10 @@ export function App() {
<VocabularyTab <VocabularyTab
onNavigateToAnime={navigateToAnime} onNavigateToAnime={navigateToAnime}
onOpenWordDetail={openWordDetail} onOpenWordDetail={openWordDetail}
excluded={excluded}
isExcluded={isExcluded}
onRemoveExclusion={removeExclusion}
onClearExclusions={clearAll}
/> />
</section> </section>
) : null} ) : null}
@@ -79,6 +85,8 @@ export function App() {
onClose={() => setGlobalWordId(null)} onClose={() => setGlobalWordId(null)}
onSelectWord={openWordDetail} onSelectWord={openWordDetail}
onNavigateToAnime={navigateToAnime} onNavigateToAnime={navigateToAnime}
isExcluded={isExcluded}
onToggleExclusion={toggleExclusion}
/> />
</div> </div>
); );

View File

@@ -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 (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close exclusion manager"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<div className="absolute inset-x-0 top-1/2 mx-auto max-w-lg -translate-y-1/2 rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex items-center justify-between border-b border-ctp-surface1 px-5 py-4">
<h2 className="text-sm font-semibold text-ctp-text">
Excluded Words
<span className="ml-2 text-ctp-overlay1 font-normal">({excluded.length})</span>
</h2>
<div className="flex items-center gap-2">
{excluded.length > 0 && (
<button
type="button"
className="rounded-md border border-ctp-red/30 px-3 py-1.5 text-xs font-medium text-ctp-red transition hover:bg-ctp-red/10"
onClick={onClearAll}
>
Clear All
</button>
)}
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
</div>
<div className="max-h-80 overflow-y-auto px-5 py-3">
{excluded.length === 0 ? (
<div className="py-6 text-center text-sm text-ctp-overlay2">
No excluded words yet. Use the Exclude button on a word's detail panel to hide it from stats.
</div>
) : (
<div className="space-y-1.5">
{excluded.map(w => (
<div
key={`${w.headword}\0${w.word}\0${w.reading}`}
className="flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2"
>
<div className="min-w-0">
<span className="text-sm font-medium text-ctp-text">{w.headword}</span>
{w.reading && w.reading !== w.headword && (
<span className="ml-2 text-xs text-ctp-subtext0">{w.reading}</span>
)}
</div>
<button
type="button"
className="shrink-0 rounded-md border border-ctp-surface2 px-2 py-1 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={() => onRemove(w)}
>
Restore
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -4,32 +4,42 @@ import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList'; import { WordList } from './WordList';
import { KanjiBreakdown } from './KanjiBreakdown'; import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel'; import { KanjiDetailPanel } from './KanjiDetailPanel';
import { ExclusionManager } from './ExclusionManager';
import { formatNumber } from '../../lib/formatters'; import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart'; import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable'; import { FrequencyRankTable } from './FrequencyRankTable';
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import { buildVocabularySummary } from '../../lib/dashboard-data'; import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { ExcludedWord } from '../../hooks/useExcludedWords';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats'; import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
interface VocabularyTabProps { interface VocabularyTabProps {
onNavigateToAnime?: (animeId: number) => void; onNavigateToAnime?: (animeId: number) => void;
onOpenWordDetail?: (wordId: 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 { function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞'; 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 { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null); const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false); const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false);
const hasNames = useMemo(() => words.some(isProperNoun), [words]); const hasNames = useMemo(() => words.some(isProperNoun), [words]);
const filteredWords = useMemo( const filteredWords = useMemo(() => {
() => hideNames ? words.filter((w) => !isProperNoun(w)) : words, let result = words;
[words, hideNames], 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) { if (loading) {
return ( return (
@@ -52,6 +62,11 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
onOpenWordDetail?.(entry.wordId); 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 => { const openKanjiDetail = (entry: KanjiEntry): void => {
setSelectedKanjiId(entry.kanjiId); setSelectedKanjiId(entry.kanjiId);
}; };
@@ -89,6 +104,17 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
Hide Names Hide Names
</button> </button>
)} )}
<button
type="button"
onClick={() => setShowExclusionManager(true)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
excluded.length > 0
? 'bg-ctp-surface2 text-ctp-text border-ctp-red/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Exclusions{excluded.length > 0 && ` (${excluded.length})`}
</button>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
@@ -97,6 +123,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
data={summary.topWords} data={summary.topWords}
color="#8aadf4" color="#8aadf4"
type="bar" type="bar"
onBarClick={handleBarClick}
/> />
<TrendChart <TrendChart
title="New Words by Day" title="New Words by Day"
@@ -108,6 +135,8 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} /> <FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
<CrossAnimeWordsTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
<WordList <WordList
words={filteredWords} words={filteredWords}
selectedKey={null} selectedKey={null}
@@ -127,6 +156,15 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
onSelectWord={onOpenWordDetail} onSelectWord={onOpenWordDetail}
onNavigateToAnime={onNavigateToAnime} onNavigateToAnime={onNavigateToAnime}
/> />
{showExclusionManager && (
<ExclusionManager
excluded={excluded}
onRemove={onRemoveExclusion}
onClearAll={onClearExclusions}
onClose={() => setShowExclusionManager(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -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<string> | 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<string> {
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 };
}