diff --git a/stats/src/App.tsx b/stats/src/App.tsx index e71d5d4..5aa8144 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -30,7 +30,14 @@ export function App() { return (
-

SubMiner Stats

+
diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx index 89b1cbf..ebc367d 100644 --- a/stats/src/components/sessions/SessionDetail.tsx +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -1,10 +1,11 @@ import { - LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, - ReferenceLine, + ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, + ReferenceArea, ReferenceLine, } from 'recharts'; import { useSessionDetail } from '../../hooks/useSessions'; import { CHART_THEME } from '../../lib/chart-theme'; import { EventType } from '../../types/stats'; +import type { SessionEvent } from '../../types/stats'; interface SessionDetailProps { sessionId: number; @@ -27,10 +28,29 @@ function formatTime(ms: number): string { }); } -const EVENT_COLORS: Partial> = { - [EventType.CARD_MINED]: { color: '#a6da95', label: 'Card mined' }, - [EventType.PAUSE_START]: { color: '#f5a97f', label: 'Pause' }, -}; +interface PauseRegion { startMs: number; endMs: number } + +function buildPauseRegions(events: SessionEvent[]): PauseRegion[] { + const regions: PauseRegion[] = []; + const starts = events.filter((e) => e.eventType === EventType.PAUSE_START); + const ends = events.filter((e) => e.eventType === EventType.PAUSE_END); + + for (const start of starts) { + const end = ends.find((e) => e.tsMs > start.tsMs); + regions.push({ + startMs: start.tsMs, + endMs: end ? end.tsMs : start.tsMs + 2000, + }); + } + return regions; +} + +interface ChartPoint { + tsMs: number; + activity: number; + totalWords: number; + paused: boolean; +} export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) { const { timeline, events, loading, error } = useSessionDetail(sessionId); @@ -38,84 +58,188 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) { if (loading) return
Loading timeline...
; if (error) return
Error: {error}
; - const chartData = [...timeline] - .reverse() - .map((t) => ({ + const sorted = [...timeline].reverse(); + const pauseRegions = buildPauseRegions(events); + + const chartData: ChartPoint[] = sorted.map((t, i) => { + const prevWords = i > 0 ? sorted[i - 1]!.wordsSeen : 0; + const delta = Math.max(0, t.wordsSeen - prevWords); + const paused = pauseRegions.some((r) => t.sampleMs >= r.startMs && t.sampleMs <= r.endMs); + return { tsMs: t.sampleMs, - time: formatTime(t.sampleMs), - words: t.wordsSeen, - cards: t.cardsMined, - })); + activity: delta, + totalWords: t.wordsSeen, + paused, + }; + }); + + const cardEvents = events.filter((e) => e.eventType === EventType.CARD_MINED); + const seekEvents = events.filter( + (e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD, + ); const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; - const seekCount = events.filter( - (e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD, - ).length; - const cardEventCount = events.filter((e) => e.eventType === EventType.CARD_MINED).length; + const seekCount = seekEvents.length; + const cardEventCount = cardEvents.length; - const markerEvents = events.filter((e) => EVENT_COLORS[e.eventType]); + const maxActivity = Math.max(...chartData.map((d) => d.activity), 1); + const yMax = Math.ceil(maxActivity * 1.3); + + const tsMin = chartData.length > 0 ? chartData[0]!.tsMs : 0; + const tsMax = chartData.length > 0 ? chartData[chartData.length - 1]!.tsMs : 0; return (
{chartData.length > 0 && ( - - + + + + + + + + - - - - {markerEvents.map((e, i) => { - const cfg = EVENT_COLORS[e.eventType]!; - const matchIdx = chartData.findIndex((d) => d.tsMs >= e.tsMs); - const x = matchIdx >= 0 ? chartData[matchIdx]!.time : null; - if (!x) return null; - return ( - - ); - })} - + + { + if (name === 'New words') return [`${value}`, 'New words']; + if (name === 'Total words') return [`${value}`, 'Total words']; + return [value, name]; + }} + /> + + {/* Pause shaded regions */} + {pauseRegions.map((r, i) => ( + + ))} + + {/* Seek markers */} + {seekEvents.map((e, i) => ( + + ))} + + {/* Card mined markers */} + {cardEvents.map((e, i) => ( + + ))} + + + + )} -
- {pauseCount} pause{pauseCount !== 1 ? 's' : ''} - {seekCount} seek{seekCount !== 1 ? 's' : ''} - {Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined +
+ + + New words + + + + Total words + + {pauseCount > 0 && ( + + + {pauseCount} pause{pauseCount !== 1 ? 's' : ''} + + )} + {seekCount > 0 && ( + + + {seekCount} seek{seekCount !== 1 ? 's' : ''} + + )} + + + {Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined +
- - {markerEvents.length > 0 && ( -
- {Object.entries(EVENT_COLORS).map(([type, cfg]) => { - if (!cfg) return null; - const count = markerEvents.filter((e) => e.eventType === Number(type)).length; - if (count === 0) return null; - return ( - - - {cfg.label} ({count}) - - ); - })} -
- )}
); } diff --git a/stats/src/components/vocabulary/VocabularyTab.tsx b/stats/src/components/vocabulary/VocabularyTab.tsx index a8602a7..03e855d 100644 --- a/stats/src/components/vocabulary/VocabularyTab.tsx +++ b/stats/src/components/vocabulary/VocabularyTab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { useVocabulary } from '../../hooks/useVocabulary'; import { StatCard } from '../layout/StatCard'; import { WordList } from './WordList'; @@ -7,7 +7,6 @@ import { KanjiDetailPanel } from './KanjiDetailPanel'; import { formatNumber } from '../../lib/formatters'; import { TrendChart } from '../trends/TrendChart'; import { buildVocabularySummary } from '../../lib/dashboard-data'; -import { isFilterable } from './pos-helpers'; import type { KanjiEntry, VocabularyEntry } from '../../types/stats'; interface VocabularyTabProps { @@ -18,18 +17,12 @@ interface VocabularyTabProps { export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) { const { words, kanji, loading, error } = useVocabulary(); const [selectedKanjiId, setSelectedKanjiId] = useState(null); - const [hideParticles, setHideParticles] = useState(true); const [search, setSearch] = useState(''); - const filteredWords = useMemo( - () => hideParticles ? words.filter(w => !isFilterable(w)) : words, - [words, hideParticles], - ); - if (loading) return
Loading...
; if (error) return
Error: {error}
; - const summary = buildVocabularySummary(filteredWords, kanji); + const summary = buildVocabularySummary(words, kanji); const handleSelectWord = (entry: VocabularyEntry): void => { onOpenWordDetail?.(entry.wordId); @@ -52,15 +45,6 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
-