fix(stats): fix data loss and error handling in anime merge/move

- flush pending telemetry before merge/move so in-progress session time isn't dropped from lifetime totals
- repoint subtitle lines by video_id instead of anime_id so lines recorded before the async title parse assigns a link aren't stranded
- stop absorbing metadata into the target when moving an episode out of an emptied entry (a move isn't a same-show claim)
- return 404 only for missing episode/target, not storage failures; return 404 when a merge folds nothing
- keep AnimeMergeDialog open while a merge is in flight instead of letting dismiss race the request
- surface library load failures in LibraryEntryPicker instead of showing an empty list
This commit is contained in:
2026-08-10 23:06:00 -07:00
parent 5938095d92
commit d6e6e29b5e
9 changed files with 164 additions and 17 deletions
@@ -43,8 +43,18 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
}
};
// Dismissing mid-request would leave the caller unaware of a merge that is
// still going to land, so the backdrop and close button are inert until it
// resolves.
const handleDismiss = () => {
if (!merging) onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
onClick={handleDismiss}
>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
@@ -57,8 +67,9 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
</h3>
<button
type="button"
onClick={onClose}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
onClick={handleDismiss}
disabled={merging}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
>
{'✕'}
</button>
+2 -2
View File
@@ -205,7 +205,7 @@ export function EpisodeList({
setMoveError(null);
setMovingEpisode(ep);
}}
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue focus-visible:text-ctp-blue hover:bg-ctp-blue/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
title="Move to another library entry"
aria-label="Move to another library entry"
>
@@ -217,7 +217,7 @@ export function EpisodeList({
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red focus-visible:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
title="Delete episode"
aria-label="Delete episode"
>
@@ -25,6 +25,7 @@ export function LibraryEntryPicker({
onClose,
}: LibraryEntryPickerProps) {
const [entries, setEntries] = useState<AnimeLibraryItem[] | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
@@ -37,7 +38,11 @@ export function LibraryEntryPicker({
if (!cancelled) setEntries(data);
})
.catch(() => {
if (!cancelled) setEntries([]);
// Distinct from an empty library: telling the user "no other titles"
// when the request failed hides a retryable error.
if (cancelled) return;
setEntries([]);
setLoadFailed(true);
});
return () => {
cancelled = true;
@@ -84,7 +89,12 @@ export function LibraryEntryPicker({
<div className="flex-1 overflow-y-auto p-2">
{entries === null && <div className="text-xs text-ctp-overlay2 p-3">Loading...</div>}
{entries !== null && visible.length === 0 && (
{loadFailed && (
<div className="text-xs text-ctp-red p-3">
Could not load the library. Close this dialog and try again.
</div>
)}
{!loadFailed && entries !== null && visible.length === 0 && (
<div className="text-xs text-ctp-overlay2 p-3">No other titles</div>
)}
{visible.map((entry) => (