fix(stats): drain full write queue before anime merge/move rebuilds

- Replace single flushNow() with drainWriteQueue loop so forced telemetry appended after a full batch isn't left unwritten before merge/move/rebuild summaries recompute
- Add dialog a11y to AnimeMergeDialog/LibraryEntryPicker: aria-modal, labelled headings, alert roles for errors, labelled search input, close button labels
This commit is contained in:
2026-08-10 23:18:02 -07:00
parent d6e6e29b5e
commit 73c553c6a1
4 changed files with 127 additions and 17 deletions
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useId, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { AnimeCoverImage } from './AnimeCoverImage';
@@ -19,6 +19,7 @@ function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
}
export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialogProps) {
const headingId = useId();
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
const [merging, setMerging] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -57,18 +58,22 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
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"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-ctp-text">
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
Merge {entries.length} Library Entries
</h3>
<button
type="button"
onClick={handleDismiss}
disabled={merging}
aria-label="Close"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
>
{'✕'}
@@ -86,6 +91,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
key={entry.animeId}
type="button"
disabled={merging}
aria-pressed={keeperId === entry.animeId}
onClick={() => setKeeperId(entry.animeId)}
className={`w-full flex items-center gap-3 p-2.5 rounded-lg transition-colors text-left disabled:opacity-50 ${
keeperId === entry.animeId ? 'bg-ctp-surface1' : 'hover:bg-ctp-surface0'
@@ -120,7 +126,11 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
</div>
<div className="p-4 border-t border-ctp-surface1 space-y-2">
{error ? <div className="text-xs text-ctp-red">{error}</div> : null}
{error ? (
<div role="alert" className="text-xs text-ctp-red">
{error}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-ctp-overlay2">
Result: {totalEpisodes} episode{totalEpisodes !== 1 ? 's' : ''} ·{' '}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration } from '../../lib/formatters';
import { AnimeCoverImage } from './AnimeCoverImage';
@@ -28,6 +28,8 @@ export function LibraryEntryPicker({
const [loadFailed, setLoadFailed] = useState(false);
const [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
const headingId = useId();
const searchId = useId();
useEffect(() => {
inputRef.current?.focus();
@@ -62,40 +64,56 @@ export function LibraryEntryPicker({
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
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"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">{heading}</h3>
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
{heading}
</h3>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
>
{'✕'}
</button>
</div>
<label htmlFor={searchId} className="sr-only">
Search library
</label>
<input
ref={inputRef}
id={searchId}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search library..."
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{error ? <div className="text-xs text-ctp-red mt-2">{error}</div> : null}
{error ? (
<div role="alert" className="text-xs text-ctp-red mt-2">
{error}
</div>
) : null}
</div>
<div className="flex-1 overflow-y-auto p-2">
{entries === null && <div className="text-xs text-ctp-overlay2 p-3">Loading...</div>}
{loadFailed && (
<div className="text-xs text-ctp-red p-3">
<div role="alert" 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>
<div className="text-xs text-ctp-overlay2 p-3">
{query.trim() ? 'No matches' : 'No other titles'}
</div>
)}
{visible.map((entry) => (
<button