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, 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; 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(); 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
Loading...
; if (!data) return
Failed to load episode details.
; 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 && (

Sessions

{sessions.map((s) => (
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'} {formatDuration(s.activeWatchedMs)} {formatNumber(s.cardsMined)} cards {formatNumber(getSessionDisplayWordCount(s))} words {formatNumber(s.knownWordsSeen)} known words
))}
)} {filteredCardEvents.length > 0 && (

Cards Mined

{filteredCardEvents.map((ev) => (
{formatRelativeDate(ev.tsMs)} {ev.noteIds.length > 0 ? ( ev.noteIds.map((noteId) => { const info = noteInfos.get(noteId); return (
{info?.expression && ( {info.expression} )}
); }) ) : ( +{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'} )}
))}
{hiddenCardCount > 0 && (
{hiddenCardCount} {hiddenCardCount === 1 ? 'card' : 'cards'} hidden (deleted from Anki)
)}
)} {sessions.length === 0 && cardEvents.length === 0 && (
No detailed data available.
)}
); }