import { useCallback, useState } from 'react'; import { getStatsClient } from '../../hooks/useStatsApi'; import { formatNumber } from '../../lib/formatters'; import type { StatsDuplicateLineCleanupResult } from '../../types/stats'; interface DuplicateLineCleanupProps { onClose: () => void; /** Called after rows are actually removed, so the charts can reload. */ onCleaned: () => void; } const LOOKBACK_CHOICES: Array<{ label: string; days: number | null }> = [ { label: '7 days', days: 7 }, { label: '30 days', days: 30 }, { label: '90 days', days: 90 }, { label: '1 year', days: 365 }, { label: 'All time', days: null }, ]; function formatTimecode(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${String(seconds).padStart(2, '0')}`; } export function DuplicateLineCleanup({ onClose, onCleaned }: DuplicateLineCleanupProps) { const [lookbackDays, setLookbackDays] = useState(30); const [preview, setPreview] = useState(null); const [applied, setApplied] = useState(null); const [busy, setBusy] = useState<'scan' | 'apply' | null>(null); const [error, setError] = useState(null); // Survives everything the displayed result does not: another scan, a different window. // Rows are gone from the moment an apply succeeds, so the reload is owed until it runs. const [needsReload, setNeedsReload] = useState(false); const run = useCallback( async (dryRun: boolean) => { setBusy(dryRun ? 'scan' : 'apply'); setError(null); try { const result = await getStatsClient().cleanupDuplicateLines({ dryRun, lookbackDays }); if (dryRun) { setPreview(result); setApplied(null); } else { setApplied(result); setPreview(null); if (result.removedLines > 0) { setNeedsReload(true); } } } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { setBusy(null); } }, [lookbackDays], ); // Reloading the vocabulary tables unmounts this modal along with the rest of the tab, // so it waits for the user to close: they get to read what was removed first. Closing // is refused mid-apply, which would drop the reload on the floor along with the report. const close = useCallback(() => { if (busy === 'apply') { return; } if (needsReload) { onCleaned(); } onClose(); }, [busy, needsReload, onCleaned, onClose]); const result = applied ?? preview; const nothingToDo = preview !== null && preview.removedLines === 0; return (

Typeset subtitles — karaoke openings, animated signs — are authored as one event per animation frame, and older versions counted every frame as its own line. This finds those runs and collapses each one back to a single line, giving back the word and kanji counts they inflated. Ordinary repeated dialogue is left alone.

Look back over
{LOOKBACK_CHOICES.map((choice) => ( ))}
{error && (
{error}
)} {result && (
{applied ? `Removed ${formatNumber(applied.removedLines)} repeated lines` : nothingToDo ? 'No animation bursts found in this window' : `Found ${formatNumber(preview!.burstGroups)} bursts covering ${formatNumber(preview!.removedLines)} extra lines`}
{formatNumber(result.scannedLines)} lines scanned ·{' '} {formatNumber(result.removedWordOccurrences)} word counts ·{' '} {formatNumber(result.removedKanjiOccurrences)} kanji counts {applied ? ' removed' : ' would be removed'}
{result.samples.length > 0 && (
{result.samples.map((sample) => (
{sample.text}
{sample.videoTitle ?? `Video ${sample.videoId}`} ·{' '} {formatTimecode(sample.startMs)}
×{sample.frames}
))}
)}
)}

Scan first: cleanup removes rows and cannot be undone. Session watch time and lines-seen totals are left untouched.

); }