import { useEffect, useMemo, useRef, useState } from 'react'; import { AreaChart, Area, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceArea, ReferenceLine, CartesianGrid, } from 'recharts'; import { useSessionDetail } from '../../hooks/useSessions'; import { getStatsClient } from '../../hooks/useStatsApi'; import type { KnownWordsTimelinePoint } from '../../hooks/useSessions'; import { CHART_THEME } from '../../lib/chart-theme'; import { buildSessionChartEvents, type SessionChartMarker, type SessionEventNoteInfo, } from '../../lib/session-events'; import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import { EventType } from '../../types/stats'; import type { SessionEvent, SessionSummary } from '../../types/stats'; import { SessionEventOverlay } from './SessionEventOverlay'; interface SessionDetailProps { session: SessionSummary; } const tooltipStyle = { background: CHART_THEME.tooltipBg, border: `1px solid ${CHART_THEME.tooltipBorder}`, borderRadius: 6, color: CHART_THEME.tooltipText, fontSize: 11, }; function formatTime(ms: number): string { return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', }); } /** Build a lookup: linesSeen → knownWordsSeen */ function buildKnownWordsLookup(knownWordsTimeline: KnownWordsTimelinePoint[]): Map { const map = new Map(); for (const pt of knownWordsTimeline) { map.set(pt.linesSeen, pt.knownWordsSeen); } return map; } /** For a given linesSeen value, find the closest known words count (floor lookup). */ function lookupKnownWords(map: Map, linesSeen: number): number { if (map.size === 0) return 0; if (map.has(linesSeen)) return map.get(linesSeen)!; let best = 0; for (const k of map.keys()) { if (k <= linesSeen && k > best) { best = k; } } return best > 0 ? map.get(best)! : 0; } function extractNoteExpression(note: { noteId: number; fields: Record; }): SessionEventNoteInfo { const expression = note.fields?.Expression?.value ?? note.fields?.expression?.value ?? note.fields?.Word?.value ?? note.fields?.word?.value ?? ''; return { noteId: note.noteId, expression }; } interface RatioChartPoint { tsMs: number; knownPct: number; unknownPct: number; knownWords: number; unknownWords: number; totalWords: number; } interface FallbackChartPoint { tsMs: number; totalWords: number; } type TimelineEntry = { sampleMs: number; linesSeen: number; tokensSeen: number; }; export function SessionDetail({ session }: SessionDetailProps) { const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail( session.sessionId, ); const [activeMarkerKey, setActiveMarkerKey] = useState(null); const [noteInfos, setNoteInfos] = useState>(new Map()); const [loadingNoteIds, setLoadingNoteIds] = useState>(new Set()); const requestedNoteIdsRef = useRef>(new Set()); const sorted = [...timeline].reverse(); const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline); const hasKnownWords = knownWordsMap.size > 0; const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } = buildSessionChartEvents(events); const lookupRate = buildLookupRateDisplay( session.yomitanLookupCount, getSessionDisplayWordCount(session), ); const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; const seekCount = seekEvents.length; const cardEventCount = cardEvents.length; const activeMarker = useMemo( () => markers.find((marker) => marker.key === activeMarkerKey) ?? null, [markers, activeMarkerKey], ); useEffect(() => { if (!activeMarker || activeMarker.kind !== 'card' || activeMarker.noteIds.length === 0) { return; } const missingNoteIds = activeMarker.noteIds.filter( (noteId) => !requestedNoteIdsRef.current.has(noteId) && !noteInfos.has(noteId), ); if (missingNoteIds.length === 0) { return; } for (const noteId of missingNoteIds) { requestedNoteIdsRef.current.add(noteId); } let cancelled = false; setLoadingNoteIds((prev) => { const next = new Set(prev); for (const noteId of missingNoteIds) { next.add(noteId); } return next; }); getStatsClient() .ankiNotesInfo(missingNoteIds) .then((notes) => { if (cancelled) return; setNoteInfos((prev) => { const next = new Map(prev); for (const note of notes) { const info = extractNoteExpression(note); next.set(info.noteId, info); } return next; }); }) .catch((err) => { if (!cancelled) { console.warn('Failed to fetch session event Anki note info:', err); } }) .finally(() => { if (cancelled) return; setLoadingNoteIds((prev) => { const next = new Set(prev); for (const noteId of missingNoteIds) { next.delete(noteId); } return next; }); }); return () => { cancelled = true; }; }, [activeMarker, noteInfos]); const handleOpenNote = (noteId: number) => { void getStatsClient().ankiBrowse(noteId); }; if (loading) return
Loading timeline...
; if (error) return
Error: {error}
; if (hasKnownWords) { return ( ); } return ( ); } /* ── Ratio View (primary design) ────────────────────────────────── */ function RatioView({ sorted, knownWordsMap, cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers, activeMarkerKey, onActiveMarkerChange, noteInfos, loadingNoteIds, onOpenNote, pauseCount, seekCount, cardEventCount, lookupRate, session, }: { sorted: TimelineEntry[]; knownWordsMap: Map; cardEvents: SessionEvent[]; seekEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[]; pauseRegions: Array<{ startMs: number; endMs: number }>; markers: SessionChartMarker[]; activeMarkerKey: string | null; onActiveMarkerChange: (markerKey: string | null) => void; noteInfos: Map; loadingNoteIds: Set; onOpenNote: (noteId: number) => void; pauseCount: number; seekCount: number; cardEventCount: number; lookupRate: ReturnType; session: SessionSummary; }) { const chartData: RatioChartPoint[] = []; for (const t of sorted) { const totalWords = getSessionDisplayWordCount(t); if (totalWords === 0) continue; const knownWords = Math.min(lookupKnownWords(knownWordsMap, t.linesSeen), totalWords); const unknownWords = totalWords - knownWords; const knownPct = (knownWords / totalWords) * 100; chartData.push({ tsMs: t.sampleMs, knownPct, unknownPct: 100 - knownPct, knownWords, unknownWords, totalWords, }); } if (chartData.length === 0) { return
No token data for this session.
; } const tsMin = chartData[0]!.tsMs; const tsMax = chartData[chartData.length - 1]!.tsMs; const finalTotal = chartData[chartData.length - 1]!.totalWords; const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords })); return (
{/* ── Top: Percentage area chart ── */}
`${v}%`} axisLine={false} tickLine={false} width={32} /> { const d = props.payload; if (!d) return [_value, name]; if (name === 'Known') return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known']; if (name === 'Unknown') return [ `${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`, 'Unknown', ]; return [_value, name]; }} itemSorter={() => -1} /> {/* Pause shaded regions */} {pauseRegions.map((r, i) => ( ))} {/* Card mine markers */} {cardEvents.map((e, i) => ( ))} {seekEvents.map((e, i) => { const isBackward = e.eventType === EventType.SEEK_BACKWARD; const stroke = isBackward ? '#f5bde6' : '#8bd5ca'; return ( ); })} {/* Yomitan lookup markers */} {yomitanLookupEvents.map((e, i) => ( ))}
{/* ── Bottom: Token accumulation sparkline ── */}
total tokens
{finalTotal.toLocaleString()}
{/* ── Stats bar ── */}
); } /* ── Fallback View (no known words data) ────────────────────────── */ function FallbackView({ sorted, cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers, activeMarkerKey, onActiveMarkerChange, noteInfos, loadingNoteIds, onOpenNote, pauseCount, seekCount, cardEventCount, lookupRate, session, }: { sorted: TimelineEntry[]; cardEvents: SessionEvent[]; seekEvents: SessionEvent[]; yomitanLookupEvents: SessionEvent[]; pauseRegions: Array<{ startMs: number; endMs: number }>; markers: SessionChartMarker[]; activeMarkerKey: string | null; onActiveMarkerChange: (markerKey: string | null) => void; noteInfos: Map; loadingNoteIds: Set; onOpenNote: (noteId: number) => void; pauseCount: number; seekCount: number; cardEventCount: number; lookupRate: ReturnType; session: SessionSummary; }) { const chartData: FallbackChartPoint[] = []; for (const t of sorted) { const totalWords = getSessionDisplayWordCount(t); if (totalWords === 0) continue; chartData.push({ tsMs: t.sampleMs, totalWords }); } if (chartData.length === 0) { return
No token data for this session.
; } const tsMin = chartData[0]!.tsMs; const tsMax = chartData[chartData.length - 1]!.tsMs; return (
[`${value.toLocaleString()}`, 'Total tokens']} /> {pauseRegions.map((r, i) => ( ))} {cardEvents.map((e, i) => ( ))} {seekEvents.map((e, i) => { const isBackward = e.eventType === EventType.SEEK_BACKWARD; const stroke = isBackward ? '#f5bde6' : '#8bd5ca'; return ( ); })} {yomitanLookupEvents.map((e, i) => ( ))}
); } /* ── Stats Bar ──────────────────────────────────────────────────── */ function StatsBar({ hasKnownWords, pauseCount, seekCount, cardEventCount, session, lookupRate, }: { hasKnownWords: boolean; pauseCount: number; seekCount: number; cardEventCount: number; session: SessionSummary; lookupRate: ReturnType; }) { return (
{/* Group 1: Legend */} {hasKnownWords && ( <> Known Unknown | )} {/* Group 2: Playback stats */} {pauseCount > 0 && ( {pauseCount} pause {pauseCount !== 1 ? 's' : ''} )} {seekCount > 0 && ( {seekCount} seek{seekCount !== 1 ? 's' : ''} )} {(pauseCount > 0 || seekCount > 0) && |} {/* Group 3: Learning events */} {session.yomitanLookupCount} Yomitan lookup {session.yomitanLookupCount !== 1 ? 's' : ''} {lookupRate && ( lookup rate: {lookupRate.shortValue}{' '} ({lookupRate.longValue}) )} {'\u26CF'} {Math.max(cardEventCount, session.cardsMined)} card {Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
); }