mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
fix(stats): hide cards deleted from Anki in episode detail
Filters out noteIds whose Anki note no longer exists, drops card events that have no surviving noteIds, and shows a muted footer counting hidden cards. Loading-state guard (noteInfosLoaded) prevents premature filtering before ankiNotesInfo resolves.
This commit is contained in:
60
stats/src/components/anime/EpisodeDetail.test.tsx
Normal file
60
stats/src/components/anime/EpisodeDetail.test.tsx
Normal file
@@ -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<EpisodeCardEvent> & { 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<number, { noteId: number; expression: string }>();
|
||||
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<number, { noteId: number; expression: string }>();
|
||||
const result = filterCardEvents([ev], noteInfos, true);
|
||||
assert.equal(result.length, 0, 'event with all noteIds deleted should be dropped');
|
||||
});
|
||||
@@ -16,10 +16,32 @@ interface NoteInfo {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export function filterCardEvents(
|
||||
cardEvents: EpisodeDetailData['cardEvents'],
|
||||
noteInfos: Map<number, NoteInfo>,
|
||||
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<EpisodeDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(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 (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||
{sessions.length > 0 && (
|
||||
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardEvents.length > 0 && (
|
||||
{filteredCardEvents.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||
<div className="space-y-1.5">
|
||||
{cardEvents.map((ev) => (
|
||||
{filteredCardEvents.map((ev) => (
|
||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||
{ev.noteIds.length > 0 ? (
|
||||
@@ -144,6 +182,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hiddenCardCount > 0 && (
|
||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
||||
{hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from Anki)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user