Files
SubMiner/stats/src/components/anime/EpisodeDetail.tsx
sudacode 20976d63f0 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.
2026-04-09 00:53:39 -07:00

199 lines
7.7 KiB
TypeScript

import { useState, useEffect } from 'react';
import { getStatsClient } from '../../hooks/useStatsApi';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import type { EpisodeDetailData } from '../../types/stats';
interface EpisodeDetailProps {
videoId: number;
onSessionDeleted?: () => void;
}
interface NoteInfo {
noteId: number;
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;
setLoading(true);
getStatsClient()
.getEpisodeDetail(videoId)
.then((d) => {
if (cancelled) return;
setData(d);
const allNoteIds = d.cardEvents.flatMap((ev) => ev.noteIds);
if (allNoteIds.length > 0) {
getStatsClient()
.ankiNotesInfo(allNoteIds)
.then((notes) => {
if (cancelled) return;
const map = new Map<number, NoteInfo>();
for (const note of notes) {
const expr = note.preview?.word ?? '';
map.set(note.noteId, { noteId: note.noteId, expression: expr });
}
setNoteInfos(map);
setNoteInfosLoaded(true);
})
.catch((err) => {
console.warn('Failed to fetch Anki note info:', err);
if (!cancelled) setNoteInfosLoaded(true);
});
} else {
if (!cancelled) setNoteInfosLoaded(true);
}
})
.catch(() => {
if (!cancelled) setData(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [videoId]);
const handleDeleteSession = async (sessionId: number) => {
if (!confirmSessionDelete()) return;
await apiClient.deleteSession(sessionId);
setData((prev) => {
if (!prev) return prev;
return { ...prev, sessions: prev.sessions.filter((s) => s.sessionId !== sessionId) };
});
onSessionDeleted?.();
};
if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>;
if (!data)
return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
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 && (
<div className="p-3 border-b border-ctp-surface1">
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Sessions</h4>
<div className="space-y-1">
{sessions.map((s) => (
<div key={s.sessionId} className="flex items-center gap-3 text-xs group">
<span className="text-ctp-overlay2">
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
</span>
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
<span className="text-ctp-cards-mined">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-peach">
{formatNumber(getSessionDisplayWordCount(s))} words
</span>
<span className="text-ctp-green">{formatNumber(s.knownWordsSeen)} known words</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteSession(s.sessionId);
}}
className="ml-auto opacity-0 group-hover:opacity-100 text-ctp-red/70 hover:text-ctp-red transition-opacity text-[10px] px-1.5 py-0.5 rounded hover:bg-ctp-red/10"
title="Delete session"
>
Delete
</button>
</div>
))}
</div>
</div>
)}
{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">
{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 ? (
ev.noteIds.map((noteId) => {
const info = noteInfos.get(noteId);
return (
<div key={noteId} className="flex items-center gap-2 min-w-0 flex-1">
{info?.expression && (
<span className="text-ctp-text font-medium truncate">
{info.expression}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
getStatsClient().ankiBrowse(noteId);
}}
className="px-1.5 py-0.5 bg-ctp-surface1 text-ctp-blue rounded text-[10px] hover:bg-ctp-surface2 transition-colors cursor-pointer shrink-0 ml-auto"
>
Open in Anki
</button>
</div>
);
})
) : (
<span className="text-ctp-cards-mined">
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
</span>
)}
</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>
)}
{sessions.length === 0 && cardEvents.length === 0 && (
<div className="p-3 text-xs text-ctp-overlay2">No detailed data available.</div>
)}
</div>
);
}