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;
|
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) {
|
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
|
||||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||||
|
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -41,8 +63,14 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||||
}
|
}
|
||||||
setNoteInfos(map);
|
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(() => {
|
.catch(() => {
|
||||||
@@ -72,6 +100,16 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
|
|
||||||
const { sessions, cardEvents } = data;
|
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 (
|
return (
|
||||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
@@ -106,11 +144,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cardEvents.length > 0 && (
|
{filteredCardEvents.length > 0 && (
|
||||||
<div className="p-3 border-b border-ctp-surface1">
|
<div className="p-3 border-b border-ctp-surface1">
|
||||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{cardEvents.map((ev) => (
|
{filteredCardEvents.map((ev) => (
|
||||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||||
{ev.noteIds.length > 0 ? (
|
{ev.noteIds.length > 0 ? (
|
||||||
@@ -144,6 +182,11 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user