diff --git a/stats/src/components/anime/EpisodeDetail.test.tsx b/stats/src/components/anime/EpisodeDetail.test.tsx new file mode 100644 index 00000000..634fea1f --- /dev/null +++ b/stats/src/components/anime/EpisodeDetail.test.tsx @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { filterCardEvents } from './EpisodeDetail'; +import type { EpisodeCardEvent } from '../../types/stats'; + +function makeEvent(over: Partial & { eventId: number }): EpisodeCardEvent { + return { + sessionId: 1, + tsMs: 0, + cardsDelta: 1, + noteIds: [], + ...over, + }; +} + +test('filterCardEvents: before load, returns all events unchanged', () => { + const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); + const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); + const noteInfos = new Map(); // empty — simulates pre-load state + const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ false); + assert.equal(result.length, 2, 'should return both events before load'); + assert.deepEqual(result[0]?.noteIds, [101]); + assert.deepEqual(result[1]?.noteIds, [102]); +}); + +test('filterCardEvents: after load, drops noteIds not in noteInfos', () => { + const ev1 = makeEvent({ eventId: 1, noteIds: [101] }); // survives + const ev2 = makeEvent({ eventId: 2, noteIds: [102] }); // deleted from Anki + const noteInfos = new Map([[101, { noteId: 101, expression: '食べる' }]]); + const result = filterCardEvents([ev1, ev2], noteInfos, /* noteInfosLoaded */ true); + assert.equal(result.length, 1, 'should drop event whose noteId was deleted from Anki'); + assert.equal(result[0]?.eventId, 1); + assert.deepEqual(result[0]?.noteIds, [101]); +}); + +test('filterCardEvents: after load, legacy rollup events (empty noteIds, positive cardsDelta) are kept', () => { + const rollup = makeEvent({ eventId: 3, noteIds: [], cardsDelta: 5 }); + const noteInfos = new Map(); + const result = filterCardEvents([rollup], noteInfos, true); + assert.equal(result.length, 1, 'legacy rollup event should survive filtering'); + assert.equal(result[0]?.cardsDelta, 5); +}); + +test('filterCardEvents: after load, event with multiple noteIds keeps surviving ones', () => { + const ev = makeEvent({ eventId: 4, noteIds: [201, 202, 203] }); + const noteInfos = new Map([ + [201, { noteId: 201, expression: 'A' }], + [203, { noteId: 203, expression: 'C' }], + ]); + const result = filterCardEvents([ev], noteInfos, true); + assert.equal(result.length, 1, 'event with surviving noteIds should be kept'); + assert.deepEqual(result[0]?.noteIds, [201, 203], 'only surviving noteIds should remain'); +}); + +test('filterCardEvents: after load, event where all noteIds deleted is dropped', () => { + const ev = makeEvent({ eventId: 5, noteIds: [301, 302] }); + const noteInfos = new Map(); + const result = filterCardEvents([ev], noteInfos, true); + assert.equal(result.length, 0, 'event with all noteIds deleted should be dropped'); +}); diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx index 5415f6c2..caf5e5b9 100644 --- a/stats/src/components/anime/EpisodeDetail.tsx +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -16,10 +16,32 @@ interface NoteInfo { expression: string; } +export function filterCardEvents( + cardEvents: EpisodeDetailData['cardEvents'], + noteInfos: Map, + noteInfosLoaded: boolean, +): EpisodeDetailData['cardEvents'] { + if (!noteInfosLoaded) return cardEvents; + return cardEvents + .map((ev) => { + // Legacy rollup events: no noteIds, just a cardsDelta count — keep as-is. + if (ev.noteIds.length === 0) return ev; + const survivingNoteIds = ev.noteIds.filter((id) => noteInfos.has(id)); + return { ...ev, noteIds: survivingNoteIds }; + }) + .filter((ev, i) => { + // If the event originally had noteIds, only keep it if some survived. + if ((cardEvents[i]?.noteIds.length ?? 0) > 0) return ev.noteIds.length > 0; + // Legacy rollup event (originally no noteIds): keep if it has a positive delta. + return ev.cardsDelta > 0; + }); +} + export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [noteInfos, setNoteInfos] = useState>(new Map()); + const [noteInfosLoaded, setNoteInfosLoaded] = useState(false); useEffect(() => { let cancelled = false; @@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) map.set(note.noteId, { noteId: note.noteId, expression: expr }); } setNoteInfos(map); + setNoteInfosLoaded(true); }) - .catch((err) => console.warn('Failed to fetch Anki note info:', err)); + .catch((err) => { + console.warn('Failed to fetch Anki note info:', err); + if (!cancelled) setNoteInfosLoaded(true); + }); + } else { + if (!cancelled) setNoteInfosLoaded(true); } }) .catch(() => { @@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) const { sessions, cardEvents } = data; + const filteredCardEvents = filterCardEvents(cardEvents, noteInfos, noteInfosLoaded); + + const hiddenCardCount = noteInfosLoaded + ? cardEvents.reduce((sum, ev) => { + if (ev.noteIds.length === 0) return sum; + const surviving = ev.noteIds.filter((id) => noteInfos.has(id)); + return sum + (ev.noteIds.length - surviving.length); + }, 0) + : 0; + return (
{sessions.length > 0 && ( @@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
)} - {cardEvents.length > 0 && ( + {filteredCardEvents.length > 0 && (

Cards Mined

- {cardEvents.map((ev) => ( + {filteredCardEvents.map((ev) => (
{formatRelativeDate(ev.tsMs)} {ev.noteIds.length > 0 ? ( @@ -144,6 +182,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
))}
+ {hiddenCardCount > 0 && ( +
+ {hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from Anki) +
+ )}
)}