import { useMemo, useState } from 'react'; import { PosBadge } from './pos-helpers'; import { fullReading } from '../../lib/reading-utils'; import type { VocabularyEntry } from '../../types/stats'; interface FrequencyRankTableProps { words: VocabularyEntry[]; knownWords: Set; onSelectWord?: (word: VocabularyEntry) => void; } const PAGE_SIZE = 25; interface FrequencyRankOptions { hideKnown: boolean; hideKanaOnly: boolean; } const KANA_ONLY_TEXT = /^[\p{Script=Hiragana}\p{Script=Katakana}\u30fc\u309d\u309e\u30fd\u30fe]+$/u; export function isKanaOnlyTokenText(text: string): boolean { const trimmed = text.trim(); return trimmed.length > 0 && KANA_ONLY_TEXT.test(trimmed); } function isWordKnown(w: VocabularyEntry, knownWords: Set): boolean { return knownWords.has(w.headword) || knownWords.has(w.word); } function isKanaOnlyWord(w: VocabularyEntry): boolean { return isKanaOnlyTokenText(w.headword || w.word); } export function buildFrequencyRankRows( words: VocabularyEntry[], knownWords: Set, options: FrequencyRankOptions, ): VocabularyEntry[] { const hasKnownData = knownWords.size > 0; let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0); if (options.hideKnown && hasKnownData) { filtered = filtered.filter((w) => !isWordKnown(w, knownWords)); } if (options.hideKanaOnly) { filtered = filtered.filter((w) => !isKanaOnlyWord(w)); } const byHeadword = new Map(); for (const w of filtered) { const existing = byHeadword.get(w.headword); if (!existing) { byHeadword.set(w.headword, { ...w }); } else { existing.frequency += w.frequency; existing.animeCount = Math.max(existing.animeCount, w.animeCount); if (w.frequencyRank! < existing.frequencyRank!) { existing.frequencyRank = w.frequencyRank; } if (!existing.reading && w.reading) { existing.reading = w.reading; } if (!existing.partOfSpeech && w.partOfSpeech) { existing.partOfSpeech = w.partOfSpeech; } } } return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!); } export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) { const [page, setPage] = useState(0); const [hideKnown, setHideKnown] = useState(true); const [hideKanaOnly, setHideKanaOnly] = useState(false); const [collapsed, setCollapsed] = useState(false); const hasKnownData = knownWords.size > 0; const ranked = useMemo(() => { return buildFrequencyRankRows(words, knownWords, { hideKnown, hideKanaOnly }); }, [words, knownWords, hideKnown, hideKanaOnly]); if (words.every((w) => w.frequencyRank == null)) { return (

Most Common Words Seen

No frequency rank data available. Run the frequency backfill script or install a frequency dictionary.
); } const totalPages = Math.ceil(ranked.length / PAGE_SIZE); const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); return (
{hasKnownData && ( )} {ranked.length} words
{collapsed ? null : ranked.length === 0 ? (
{hideKnown && hasKnownData && !hideKanaOnly ? 'All ranked words are already in Anki!' : hideKnown || hideKanaOnly ? 'No ranked words match the active filters.' : 'No words with frequency data.'}
) : ( <>
{paged.map((w) => ( onSelectWord?.(w)} className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors" > ))}
Rank Word POS Seen
#{w.frequencyRank!.toLocaleString()} {w.headword} {(() => { const reading = fullReading(w.headword, w.reading); // `fullReading` normalizes katakana to hiragana, so we normalize the // headword the same way before comparing — otherwise katakana-only // entries like `カレー` would render `【かれー】`. const normalizedHeadword = fullReading(w.headword, w.headword); if (!reading || reading === normalizedHeadword) return null; return ( 【{reading}】 ); })()} {w.partOfSpeech && } {w.frequency}x
{totalPages > 1 && (
{page + 1} / {totalPages}
)} )}
); }