fix(stats): stop counting duplicate typeset subtitle lines (#191)

This commit is contained in:
2026-08-14 00:12:56 -07:00
committed by GitHub
parent 046e74ea91
commit b98d4d65c7
34 changed files with 2644 additions and 35 deletions
@@ -0,0 +1,202 @@
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<number | null>(30);
const [preview, setPreview] = useState<StatsDuplicateLineCleanupResult | null>(null);
const [applied, setApplied] = useState<StatsDuplicateLineCleanupResult | null>(null);
const [busy, setBusy] = useState<'scan' | 'apply' | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close duplicate line cleanup"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={close}
/>
<div className="absolute inset-x-0 top-1/2 mx-auto max-w-xl -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">Duplicate Lines</h2>
<button
type="button"
disabled={busy === 'apply'}
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 disabled:opacity-50"
onClick={close}
>
Close
</button>
</div>
<div className="space-y-4 px-5 py-4">
<p className="text-xs leading-relaxed text-ctp-subtext0">
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.
</p>
<div>
<div className="mb-2 text-xs font-medium text-ctp-subtext1">Look back over</div>
<div className="flex flex-wrap gap-2">
{LOOKBACK_CHOICES.map((choice) => (
<button
key={choice.label}
type="button"
disabled={busy !== null}
onClick={() => {
setLookbackDays(choice.days);
setPreview(null);
setApplied(null);
}}
className={`rounded-lg border px-3 py-1.5 text-xs transition disabled:opacity-50 ${
lookbackDays === choice.days
? 'border-ctp-blue/50 bg-ctp-surface2 text-ctp-text'
: 'border-ctp-surface1 bg-ctp-surface0 text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{choice.label}
</button>
))}
</div>
</div>
{error && (
<div className="rounded-lg border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
{error}
</div>
)}
{result && (
<div className="rounded-lg bg-ctp-surface0 px-4 py-3">
<div className="text-sm text-ctp-text">
{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`}
</div>
<div className="mt-1 text-xs text-ctp-overlay2">
{formatNumber(result.scannedLines)} lines scanned ·{' '}
{formatNumber(result.removedWordOccurrences)} word counts ·{' '}
{formatNumber(result.removedKanjiOccurrences)} kanji counts
{applied ? ' removed' : ' would be removed'}
</div>
{result.samples.length > 0 && (
<div className="mt-3 max-h-52 space-y-1.5 overflow-y-auto">
{result.samples.map((sample) => (
<div
key={`${sample.videoId}:${sample.startMs}:${sample.text}`}
className="flex items-center justify-between gap-3 rounded-md bg-ctp-mantle px-3 py-1.5"
>
<div className="min-w-0">
<div className="truncate text-xs text-ctp-text">{sample.text}</div>
<div className="truncate text-[11px] text-ctp-overlay1">
{sample.videoTitle ?? `Video ${sample.videoId}`} ·{' '}
{formatTimecode(sample.startMs)}
</div>
</div>
<span className="shrink-0 text-xs text-ctp-peach">×{sample.frames}</span>
</div>
))}
</div>
)}
</div>
)}
<div className="flex items-center justify-end gap-2">
<button
type="button"
disabled={busy !== null}
onClick={() => void run(true)}
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 disabled:opacity-50"
>
{busy === 'scan' ? 'Scanning…' : 'Scan'}
</button>
<button
type="button"
disabled={busy !== null || preview === null || nothingToDo}
onClick={() => void run(false)}
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 disabled:opacity-40"
>
{busy === 'apply' ? 'Cleaning…' : 'Clean Up'}
</button>
</div>
<p className="text-[11px] text-ctp-overlay1">
Scan first: cleanup removes rows and cannot be undone. Session watch time and lines-seen
totals are left untouched.
</p>
</div>
</div>
</div>
);
}