mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
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.
199 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|