fix(stats): review fuzzy AniList duplicates before merging

- Preserve merged title aliases for future episodes
- Fail closed when queued writes cannot drain
This commit is contained in:
2026-08-11 23:11:39 -07:00
parent d82a4fd8c6
commit d8c5fc321a
28 changed files with 1625 additions and 44 deletions
+71 -4
View File
@@ -1,11 +1,16 @@
import { useCallback, useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
import type { AnimeLibraryItem, StatsAnimeMergeRecommendation } from '../types/stats';
const BACKGROUND_REFRESH_MS = 30_000;
export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [recommendations, setRecommendations] = useState<StatsAnimeMergeRecommendation[]>([]);
const [dismissingRecommendationId, setDismissingRecommendationId] = useState<number | null>(null);
const [recommendationActionError, setRecommendationActionError] = useState<string | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const reload = useCallback(() => {
@@ -14,10 +19,14 @@ export function useAnimeLibrary() {
useEffect(() => {
let cancelled = false;
getStatsClient()
const client = getStatsClient();
client
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setAnime(data);
if (!cancelled) {
setAnime(data);
setError(null);
}
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
@@ -25,10 +34,68 @@ export function useAnimeLibrary() {
.finally(() => {
if (!cancelled) setLoading(false);
});
// Recommendation support is deliberately non-blocking. An older backend
// should still be able to display its library even when this endpoint is
// unavailable.
client
.getAnimeMergeRecommendations()
.then((data) => {
if (!cancelled) {
setRecommendations(data.recommendations);
setRecommendationActionError(null);
}
})
.catch(() => {
// Preserve the last confirmed set. A transient polling failure should
// not make a pending review silently disappear.
});
return () => {
cancelled = true;
};
}, [reloadToken]);
return { anime, loading, error, reload };
useEffect(() => {
const refreshOnFocus = () => reload();
const interval = window.setInterval(reload, BACKGROUND_REFRESH_MS);
window.addEventListener('focus', refreshOnFocus);
return () => {
window.clearInterval(interval);
window.removeEventListener('focus', refreshOnFocus);
};
}, [reload]);
const dismissRecommendation = useCallback(async (recommendationId: number) => {
setDismissingRecommendationId(recommendationId);
setRecommendationActionError(null);
try {
await getStatsClient().dismissAnimeMergeRecommendation(recommendationId);
setRecommendations((current) =>
current.filter((item) => item.recommendationId !== recommendationId),
);
} catch {
setRecommendationActionError('Could not dismiss this suggestion. Try again.');
} finally {
setDismissingRecommendationId(null);
}
}, []);
const clearRecommendation = useCallback((recommendationId: number) => {
setRecommendations((current) =>
current.filter((item) => item.recommendationId !== recommendationId),
);
setRecommendationActionError(null);
}, []);
return {
anime,
loading,
error,
reload,
recommendations,
dismissRecommendation,
dismissingRecommendationId,
recommendationActionError,
clearRecommendation,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type RefObject } from 'react';
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
interface UseModalFocusOptions {
dialogRef: RefObject<HTMLElement | null>;
initialFocusRef: RefObject<HTMLElement | null>;
dismissDisabled?: boolean;
onDismiss: () => void;
}
export function useModalFocus({
dialogRef,
initialFocusRef,
dismissDisabled = false,
onDismiss,
}: UseModalFocusOptions): void {
const dismissDisabledRef = useRef(dismissDisabled);
const onDismissRef = useRef(onDismiss);
dismissDisabledRef.current = dismissDisabled;
onDismissRef.current = onDismiss;
useEffect(() => {
const previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
initialFocusRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (!dismissDisabledRef.current) {
event.preventDefault();
onDismissRef.current();
}
return;
}
if (event.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)];
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (
event.shiftKey &&
(document.activeElement === first || !dialog.contains(document.activeElement))
) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(document.activeElement === last || !dialog.contains(document.activeElement))
) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [dialogRef, initialFocusRef]);
}