diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx index caf5e5b9..408b79d5 100644 --- a/stats/src/components/anime/EpisodeDetail.tsx +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -184,7 +184,8 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {hiddenCardCount > 0 && (
- {hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from Anki) + {hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from + Anki)
)} diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx index a2096001..8c07a286 100644 --- a/stats/src/components/library/MediaDetailView.tsx +++ b/stats/src/components/library/MediaDetailView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMediaDetail } from '../../hooks/useMediaDetail'; import { apiClient } from '../../lib/api-client'; import { confirmSessionDelete, confirmEpisodeDelete } from '../../lib/delete-confirm'; @@ -14,17 +14,31 @@ interface DeleteEpisodeHandlerOptions { confirmFn: (title: string) => boolean; onBack: () => void; setDeleteError: (msg: string | null) => void; + /** + * Ref used to guard against reentrant delete calls synchronously. When set, + * a subsequent invocation while the previous request is still pending is + * ignored so clicks during the await window can't trigger duplicate deletes. + */ + isDeletingRef?: { current: boolean }; + /** Optional React state setter so the UI can reflect the pending state. */ + setIsDeleting?: (value: boolean) => void; } export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise { return async () => { + if (opts.isDeletingRef?.current) return; if (!opts.confirmFn(opts.title)) return; + if (opts.isDeletingRef) opts.isDeletingRef.current = true; + opts.setIsDeleting?.(true); opts.setDeleteError(null); try { await opts.apiClient.deleteVideo(opts.videoId); opts.onBack(); } catch (err) { opts.setDeleteError(err instanceof Error ? err.message : 'Failed to delete episode.'); + } finally { + if (opts.isDeletingRef) opts.isDeletingRef.current = false; + opts.setIsDeleting?.(false); } }; } @@ -57,6 +71,8 @@ export function MediaDetailView({ const [localSessions, setLocalSessions] = useState(null); const [deleteError, setDeleteError] = useState(null); const [deletingSessionId, setDeletingSessionId] = useState(null); + const [isDeletingEpisode, setIsDeletingEpisode] = useState(false); + const isDeletingEpisodeRef = useRef(false); useEffect(() => { setLocalSessions(data?.sessions ?? null); @@ -108,6 +124,8 @@ export function MediaDetailView({ confirmFn: confirmEpisodeDelete, onBack, setDeleteError, + isDeletingRef: isDeletingEpisodeRef, + setIsDeleting: setIsDeletingEpisode, }); return ( @@ -130,7 +148,11 @@ export function MediaDetailView({ ) : null} - + {deleteError ?
{deleteError}
: null} void; + isDeletingEpisode?: boolean; } export function MediaHeader({ detail, initialKnownWordsSummary = null, onDeleteEpisode, + isDeletingEpisode = false, }: MediaHeaderProps) { const knownTokenRate = detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null; @@ -56,14 +58,17 @@ export function MediaHeader({ />
-

{detail.canonicalTitle}

+

+ {detail.canonicalTitle} +

{onDeleteEpisode != null ? ( ) : null}
diff --git a/stats/src/components/overview/WatchTimeChart.tsx b/stats/src/components/overview/WatchTimeChart.tsx index 7ab1e6c6..7a5d1d92 100644 --- a/stats/src/components/overview/WatchTimeChart.tsx +++ b/stats/src/components/overview/WatchTimeChart.tsx @@ -1,13 +1,5 @@ import { useState } from 'react'; -import { - BarChart, - Bar, - CartesianGrid, - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, -} from 'recharts'; +import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { epochDayToDate } from '../../lib/formatters'; import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme'; import type { DailyRollup } from '../../types/stats'; diff --git a/stats/src/components/sessions/SessionsTab.test.tsx b/stats/src/components/sessions/SessionsTab.test.tsx index a91020e8..ebf17334 100644 --- a/stats/src/components/sessions/SessionsTab.test.tsx +++ b/stats/src/components/sessions/SessionsTab.test.tsx @@ -75,10 +75,7 @@ test('buildBucketDeleteHandler is a no-op when confirm returns false', async () let deleteCalled = false; let successCalled = false; - const bucket = makeBucket([ - makeSession({ sessionId: 1 }), - makeSession({ sessionId: 2 }), - ]); + const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]); const handler = buildBucketDeleteHandler({ bucket, @@ -104,10 +101,7 @@ test('buildBucketDeleteHandler reports errors via onError without calling onSucc let errorMessage: string | null = null; let successCalled = false; - const bucket = makeBucket([ - makeSession({ sessionId: 1 }), - makeSession({ sessionId: 2 }), - ]); + const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]); const handler = buildBucketDeleteHandler({ bucket, diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx index 560c0d4d..c35e8c98 100644 --- a/stats/src/components/sessions/SessionsTab.tsx +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -269,9 +269,7 @@ export function SessionsTab({ isExpanded={expandedId === s.sessionId} detailsId={detailsId} onToggle={() => - setExpandedId( - expandedId === s.sessionId ? null : s.sessionId, - ) + setExpandedId(expandedId === s.sessionId ? null : s.sessionId) } onDelete={() => void handleDeleteSession(s)} deleteDisabled={deletingSessionId === s.sessionId} diff --git a/stats/src/components/vocabulary/FrequencyRankTable.test.tsx b/stats/src/components/vocabulary/FrequencyRankTable.test.tsx index 50f0d113..37f64d2e 100644 --- a/stats/src/components/vocabulary/FrequencyRankTable.test.tsx +++ b/stats/src/components/vocabulary/FrequencyRankTable.test.tsx @@ -36,5 +36,8 @@ test('omits reading when reading equals headword', () => { , ); assert.ok(markup.includes('カレー'), 'should include the headword'); - assert.ok(!markup.includes('【カレー】'), 'should not render reading in brackets when equal to headword'); + assert.ok( + !markup.includes('【'), + 'should not render any bracketed reading when equal to headword', + ); }); diff --git a/stats/src/components/vocabulary/FrequencyRankTable.tsx b/stats/src/components/vocabulary/FrequencyRankTable.tsx index c6488908..470a60c9 100644 --- a/stats/src/components/vocabulary/FrequencyRankTable.tsx +++ b/stats/src/components/vocabulary/FrequencyRankTable.tsx @@ -131,11 +131,13 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc {w.headword} {(() => { const reading = fullReading(w.headword, w.reading); - if (!reading || reading === w.headword) return null; + // `fullReading` normalizes katakana to hiragana, so we normalize the + // headword the same way before comparing — otherwise katakana-only + // entries like `カレー` would render `【かれー】`. + const normalizedHeadword = fullReading(w.headword, w.headword); + if (!reading || reading === normalizedHeadword) return null; return ( - - 【{reading}】 - + 【{reading}】 ); })()} diff --git a/stats/src/lib/delete-confirm.test.ts b/stats/src/lib/delete-confirm.test.ts index 044de2b7..585d19db 100644 --- a/stats/src/lib/delete-confirm.test.ts +++ b/stats/src/lib/delete-confirm.test.ts @@ -65,16 +65,15 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo try { assert.equal(confirmBucketDelete('My Episode', 3), true); - assert.equal(calls.length, 1); - assert.match(calls[0]!, /3/); - assert.match(calls[0]!, /My Episode/); - assert.match(calls[0]!, /sessions/); + assert.deepEqual(calls, [ + 'Delete all 3 sessions of "My Episode" from this day and all associated data?', + ]); } finally { globalThis.confirm = originalConfirm; } }); -test('confirmBucketDelete uses singular for one session', () => { +test('confirmBucketDelete uses a clean singular form for one session', () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -84,7 +83,9 @@ test('confirmBucketDelete uses singular for one session', () => { try { assert.equal(confirmBucketDelete('Solo Episode', 1), false); - assert.match(calls[0]!, /1 session of/); + assert.deepEqual(calls, [ + 'Delete this session of "Solo Episode" from this day and all associated data?', + ]); } finally { globalThis.confirm = originalConfirm; } diff --git a/stats/src/lib/delete-confirm.ts b/stats/src/lib/delete-confirm.ts index 14f7abf3..137e3996 100644 --- a/stats/src/lib/delete-confirm.ts +++ b/stats/src/lib/delete-confirm.ts @@ -19,7 +19,12 @@ export function confirmEpisodeDelete(title: string): boolean { } export function confirmBucketDelete(title: string, count: number): boolean { + if (count === 1) { + return globalThis.confirm( + `Delete this session of "${title}" from this day and all associated data?`, + ); + } return globalThis.confirm( - `Delete all ${count} session${count === 1 ? '' : 's'} of "${title}" from this day?`, + `Delete all ${count} sessions of "${title}" from this day and all associated data?`, ); } diff --git a/stats/src/lib/session-grouping.test.ts b/stats/src/lib/session-grouping.test.ts index feabd927..3215a447 100644 --- a/stats/src/lib/session-grouping.test.ts +++ b/stats/src/lib/session-grouping.test.ts @@ -32,8 +32,20 @@ test('empty input returns empty array', () => { test('two unique videoIds produce 2 singleton buckets', () => { const sessions = [ - makeSession({ sessionId: 1, videoId: 10, startedAtMs: 1000, activeWatchedMs: 100, cardsMined: 2 }), - makeSession({ sessionId: 2, videoId: 20, startedAtMs: 2000, activeWatchedMs: 200, cardsMined: 3 }), + makeSession({ + sessionId: 1, + videoId: 10, + startedAtMs: 1000, + activeWatchedMs: 100, + cardsMined: 2, + }), + makeSession({ + sessionId: 2, + videoId: 20, + startedAtMs: 2000, + activeWatchedMs: 200, + cardsMined: 3, + }), ]; const buckets = groupSessionsByVideo(sessions); assert.equal(buckets.length, 2); @@ -45,8 +57,20 @@ test('two unique videoIds produce 2 singleton buckets', () => { }); test('two sessions sharing a videoId collapse into 1 bucket with summed totals and most-recent representative', () => { - const older = makeSession({ sessionId: 1, videoId: 42, startedAtMs: 1000, activeWatchedMs: 300, cardsMined: 5 }); - const newer = makeSession({ sessionId: 2, videoId: 42, startedAtMs: 9000, activeWatchedMs: 500, cardsMined: 7 }); + const older = makeSession({ + sessionId: 1, + videoId: 42, + startedAtMs: 1000, + activeWatchedMs: 300, + cardsMined: 5, + }); + const newer = makeSession({ + sessionId: 2, + videoId: 42, + startedAtMs: 9000, + activeWatchedMs: 500, + cardsMined: 7, + }); const buckets = groupSessionsByVideo([older, newer]); assert.equal(buckets.length, 1); const [bucket] = buckets;