diff --git a/stats/src/App.tsx b/stats/src/App.tsx index 22ab806..d1fe215 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -3,23 +3,31 @@ import { TabBar } from './components/layout/TabBar'; import { OverviewTab } from './components/overview/OverviewTab'; import { TrendsTab } from './components/trends/TrendsTab'; import { AnimeTab } from './components/anime/AnimeTab'; -import { LibraryTab } from './components/library/LibraryTab'; +import { MediaDetailView } from './components/library/MediaDetailView'; import { VocabularyTab } from './components/vocabulary/VocabularyTab'; import { SessionsTab } from './components/sessions/SessionsTab'; import { WordDetailPanel } from './components/vocabulary/WordDetailPanel'; import { useExcludedWords } from './hooks/useExcludedWords'; import type { TabId } from './components/layout/TabBar'; +import { + closeMediaDetail, + createInitialStatsView, + navigateToAnime as navigateToAnimeState, + navigateToSession as navigateToSessionState, + openAnimeEpisodeDetail, + openOverviewMediaDetail, + switchTab, +} from './lib/stats-navigation'; export function App() { - const [activeTab, setActiveTab] = useState('overview'); + const [viewState, setViewState] = useState(createInitialStatsView); const [mountedTabs, setMountedTabs] = useState>(() => new Set(['overview'])); - const [selectedAnimeId, setSelectedAnimeId] = useState(null); - const [focusedSessionId, setFocusedSessionId] = useState(null); const [globalWordId, setGlobalWordId] = useState(null); const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords(); + const { activeTab, selectedAnimeId, focusedSessionId, mediaDetail } = viewState; const activateTab = useCallback((tabId: TabId) => { - setActiveTab(tabId); + setViewState((prev) => switchTab(prev, tabId)); setMountedTabs((prev) => { if (prev.has(tabId)) return prev; const next = new Set(prev); @@ -29,26 +37,49 @@ export function App() { }, []); const navigateToAnime = useCallback((animeId: number) => { - activateTab('anime'); - setSelectedAnimeId(animeId); - }, [activateTab]); + setViewState((prev) => navigateToAnimeState(prev, animeId)); + setMountedTabs((prev) => { + if (prev.has('anime')) return prev; + const next = new Set(prev); + next.add('anime'); + return next; + }); + }, []); const navigateToSession = useCallback((sessionId: number) => { - activateTab('sessions'); - setFocusedSessionId(sessionId); - }, [activateTab]); + setViewState((prev) => navigateToSessionState(prev, sessionId)); + setMountedTabs((prev) => { + if (prev.has('sessions')) return prev; + const next = new Set(prev); + next.add('sessions'); + return next; + }); + }, []); + + const navigateToEpisodeDetail = useCallback( + (animeId: number, videoId: number, sessionId: number | null = null) => { + setViewState((prev) => openAnimeEpisodeDetail(prev, animeId, videoId, sessionId)); + }, + [], + ); + + const navigateToOverviewMediaDetail = useCallback( + (videoId: number, sessionId: number | null = null) => { + setViewState((prev) => openOverviewMediaDetail(prev, videoId, sessionId)); + }, + [], + ); const openWordDetail = useCallback((wordId: number) => { setGlobalWordId(wordId); }, []); - const handleTabChange = useCallback((tabId: TabId) => { - activateTab(tabId); - setSelectedAnimeId(null); - if (tabId !== 'sessions') { - setFocusedSessionId(null); - } - }, [activateTab]); + const handleTabChange = useCallback( + (tabId: TabId) => { + activateTab(tabId); + }, + [activateTab], + ); return (
@@ -64,86 +95,109 @@ export function App() {
- {mountedTabs.has('overview') ? ( - - ) : null} - {mountedTabs.has('anime') ? ( - - ) : null} - {mountedTabs.has('trends') ? ( - - ) : null} - {mountedTabs.has('vocabulary') ? ( - - ) : null} - {mountedTabs.has('library') ? ( - - ) : null} - {mountedTabs.has('sessions') ? ( - - ) : null} + {mediaDetail ? ( + + setViewState((prev) => + prev.mediaDetail + ? { + ...prev, + mediaDetail: { + ...prev.mediaDetail, + initialSessionId: null, + }, + } + : prev, + ) + } + onBack={() => setViewState((prev) => closeMediaDetail(prev))} + backLabel={ + mediaDetail.origin.type === 'overview' ? 'Back to Overview' : 'Back to Library' + } + /> + ) : ( + <> + {mountedTabs.has('overview') ? ( + + ) : null} + {mountedTabs.has('anime') ? ( + + ) : null} + {mountedTabs.has('trends') ? ( + + ) : null} + {mountedTabs.has('vocabulary') ? ( + + ) : null} + {mountedTabs.has('sessions') ? ( + + ) : null} + + )}
void; onNavigateToWord?: (wordId: number) => void; + onOpenEpisodeDetail?: (videoId: number) => void; } type Range = 14 | 30 | 90; @@ -111,18 +112,43 @@ function AnimeWatchChart({ animeId }: { animeId: number }) { ); } -export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) { +function useAnimeKnownWords(animeId: number) { + const [summary, setSummary] = useState<{ + totalUniqueWords: number; + knownWordCount: number; + } | null>(null); + useEffect(() => { + let cancelled = false; + getStatsClient() + .getAnimeKnownWordsSummary(animeId) + .then((data) => { + if (!cancelled) setSummary(data); + }) + .catch(() => { + if (!cancelled) setSummary(null); + }); + return () => { + cancelled = true; + }; + }, [animeId]); + return summary; +} + +export function AnimeDetailView({ + animeId, + onBack, + onNavigateToWord, + onOpenEpisodeDetail, +}: AnimeDetailViewProps) { const { data, loading, error, reload } = useAnimeDetail(animeId); const [showAnilistSelector, setShowAnilistSelector] = useState(false); + const knownWordsSummary = useAnimeKnownWords(animeId); if (loading) return
Loading...
; if (error) return
Error: {error}
; if (!data?.detail) return
Anime not found
; const { detail, episodes, anilistEntries } = data; - const avgSessionMs = - detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0; - return (
setShowAnilistSelector(true)} /> -
- - - - - -
- + + onOpenEpisodeDetail(videoId) : undefined} + /> {showAnilistSelector && ( diff --git a/stats/src/components/anime/AnimeOverviewStats.tsx b/stats/src/components/anime/AnimeOverviewStats.tsx new file mode 100644 index 0000000..a6aab4f --- /dev/null +++ b/stats/src/components/anime/AnimeOverviewStats.tsx @@ -0,0 +1,133 @@ +import { formatDuration, formatNumber } from '../../lib/formatters'; +import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; +import { Tooltip } from '../layout/Tooltip'; +import type { AnimeDetailData } from '../../types/stats'; + +interface AnimeOverviewStatsProps { + detail: AnimeDetailData['detail']; + knownWordsSummary: { + totalUniqueWords: number; + knownWordCount: number; + } | null; +} + +interface MetricProps { + label: string; + value: string; + unit?: string; + color: string; + tooltip: string; + sub?: string; +} + +function Metric({ label, value, unit, color, tooltip, sub }: MetricProps) { + return ( + +
+
+ {value} + {unit && {unit}} +
+
+ {label} +
+ {sub &&
{sub}
} +
+
+ ); +} + +export function AnimeOverviewStats({ + detail, + knownWordsSummary, +}: AnimeOverviewStatsProps) { + const lookupRate = buildLookupRateDisplay( + detail.totalYomitanLookupCount, + detail.totalWordsSeen, + ); + + const knownPct = + knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 + ? Math.round( + (knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100, + ) + : null; + + return ( +
+ {/* Primary metrics - always 4 columns on sm+ */} +
+ + + + +
+ + {/* Secondary metrics - fills row evenly */} +
+ + + {lookupRate ? ( + + ) : ( + + )} + {knownPct !== null ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/stats/src/components/anime/AnimeTab.tsx b/stats/src/components/anime/AnimeTab.tsx index 7116768..06bcf92 100644 --- a/stats/src/components/anime/AnimeTab.tsx +++ b/stats/src/components/anime/AnimeTab.tsx @@ -39,9 +39,15 @@ interface AnimeTabProps { initialAnimeId?: number | null; onClearInitialAnime?: () => void; onNavigateToWord?: (wordId: number) => void; + onOpenEpisodeDetail?: (animeId: number, videoId: number) => void; } -export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord }: AnimeTabProps) { +export function AnimeTab({ + initialAnimeId, + onClearInitialAnime, + onNavigateToWord, + onOpenEpisodeDetail, +}: AnimeTabProps) { const { anime, loading, error } = useAnimeLibrary(); const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('lastWatched'); @@ -70,6 +76,11 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord animeId={selectedAnimeId} onBack={() => setSelectedAnimeId(null)} onNavigateToWord={onNavigateToWord} + onOpenEpisodeDetail={ + onOpenEpisodeDetail + ? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId) + : undefined + } /> ); } diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx index 569cef4..678316f 100644 --- a/stats/src/components/anime/EpisodeDetail.tsx +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -3,6 +3,7 @@ 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 { @@ -89,7 +90,9 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {formatDuration(s.activeWatchedMs)} {formatNumber(s.cardsMined)} cards - {formatNumber(s.wordsSeen)} words + + {formatNumber(getSessionDisplayWordCount(s))} words + - -
- - - {expandedVideoId === ep.videoId && ( - - - + {sorted.map((ep, idx) => { + const lookupRate = buildLookupRateDisplay( + ep.totalYomitanLookupCount, + ep.totalWordsSeen, + ); + + return ( + + + setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId) + } + className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group" + > + + {expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'} + + {ep.episode ?? idx + 1} + + {ep.canonicalTitle} + + + {ep.durationMs > 0 ? ( + = ep.durationMs * 0.85 + ? 'text-ctp-green' + : ep.totalActiveMs >= ep.durationMs * 0.5 + ? 'text-ctp-peach' + : 'text-ctp-overlay2' + } + > + {Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}% + + ) : ( + {'\u2014'} + )} + + + {formatDuration(ep.totalActiveMs)} + + + {formatNumber(ep.totalCards)} + + +
{lookupRate?.shortValue ?? '\u2014'}
+
+ {lookupRate?.longValue ?? 'lookup rate'} +
+ + + {ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'} + + +
+ {onOpenDetail ? ( + + ) : null} + + +
- )} -
- ))} + {expandedVideoId === ep.videoId && ( + + + + + + )} + + ); + })}
diff --git a/stats/src/components/layout/TabBar.tsx b/stats/src/components/layout/TabBar.tsx index 390eb52..ceebb71 100644 --- a/stats/src/components/layout/TabBar.tsx +++ b/stats/src/components/layout/TabBar.tsx @@ -1,6 +1,6 @@ import { useRef, type KeyboardEvent } from 'react'; -export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions' | 'library'; +export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions'; interface Tab { id: TabId; @@ -9,9 +9,8 @@ interface Tab { const TABS: Tab[] = [ { id: 'overview', label: 'Overview' }, - { id: 'anime', label: 'Anime' }, + { id: 'anime', label: 'Library' }, { id: 'trends', label: 'Trends' }, - { id: 'library', label: 'Library' }, { id: 'vocabulary', label: 'Vocabulary' }, { id: 'sessions', label: 'Sessions' }, ]; diff --git a/stats/src/components/layout/Tooltip.tsx b/stats/src/components/layout/Tooltip.tsx new file mode 100644 index 0000000..95bf88d --- /dev/null +++ b/stats/src/components/layout/Tooltip.tsx @@ -0,0 +1,22 @@ +interface TooltipProps { + text: string; + children: React.ReactNode; +} + +export function Tooltip({ text, children }: TooltipProps) { + return ( +
+ {children} +
+ {text} +
+
+
+ ); +} diff --git a/stats/src/components/library/LibraryTab.tsx b/stats/src/components/library/LibraryTab.tsx index e142ed5..55b9595 100644 --- a/stats/src/components/library/LibraryTab.tsx +++ b/stats/src/components/library/LibraryTab.tsx @@ -4,7 +4,11 @@ import { formatDuration } from '../../lib/formatters'; import { MediaCard } from './MediaCard'; import { MediaDetailView } from './MediaDetailView'; -export function LibraryTab() { +interface LibraryTabProps { + onNavigateToSession: (sessionId: number) => void; +} + +export function LibraryTab({ onNavigateToSession }: LibraryTabProps) { const { media, loading, error } = useMediaLibrary(); const [search, setSearch] = useState(''); const [selectedVideoId, setSelectedVideoId] = useState(null); @@ -18,7 +22,7 @@ export function LibraryTab() { const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0); if (selectedVideoId !== null) { - return setSelectedVideoId(null)} />; + return setSelectedVideoId(null)} onNavigateToSession={onNavigateToSession} />; } if (loading) return
Loading...
; diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx index 08e774a..22566a3 100644 --- a/stats/src/components/library/MediaDetailView.tsx +++ b/stats/src/components/library/MediaDetailView.tsx @@ -1,20 +1,70 @@ +import { useEffect, useState } from 'react'; import { useMediaDetail } from '../../hooks/useMediaDetail'; +import { apiClient } from '../../lib/api-client'; +import { confirmSessionDelete } from '../../lib/delete-confirm'; +import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import { MediaHeader } from './MediaHeader'; -import { MediaWatchChart } from './MediaWatchChart'; import { MediaSessionList } from './MediaSessionList'; +import type { SessionSummary } from '../../types/stats'; interface MediaDetailViewProps { videoId: number; + initialExpandedSessionId?: number | null; + onConsumeInitialExpandedSession?: () => void; onBack: () => void; + backLabel?: string; } -export function MediaDetailView({ videoId, onBack }: MediaDetailViewProps) { +export function MediaDetailView({ + videoId, + initialExpandedSessionId = null, + onConsumeInitialExpandedSession, + onBack, + backLabel = 'Back to Library', +}: MediaDetailViewProps) { const { data, loading, error } = useMediaDetail(videoId); + const [localSessions, setLocalSessions] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const [deletingSessionId, setDeletingSessionId] = useState(null); + + useEffect(() => { + setLocalSessions(data?.sessions ?? null); + }, [data?.sessions]); if (loading) return
Loading...
; if (error) return
Error: {error}
; if (!data?.detail) return
Media not found
; + const sessions = localSessions ?? data.sessions; + const detail = { + ...data.detail, + totalSessions: sessions.length, + totalActiveMs: sessions.reduce((sum, session) => sum + session.activeWatchedMs, 0), + totalCards: sessions.reduce((sum, session) => sum + session.cardsMined, 0), + totalWordsSeen: sessions.reduce((sum, session) => sum + getSessionDisplayWordCount(session), 0), + totalLinesSeen: sessions.reduce((sum, session) => sum + session.linesSeen, 0), + totalLookupCount: sessions.reduce((sum, session) => sum + session.lookupCount, 0), + totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0), + totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0), + }; + + const handleDeleteSession = async (session: SessionSummary) => { + if (!confirmSessionDelete()) return; + + setDeleteError(null); + setDeletingSessionId(session.sessionId); + try { + await apiClient.deleteSession(session.sessionId); + setLocalSessions((prev) => + (prev ?? data.sessions).filter((item) => item.sessionId !== session.sessionId), + ); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.'); + } finally { + setDeletingSessionId(null); + } + }; + return (
- - - + + {deleteError ?
{deleteError}
: null} +
); } diff --git a/stats/src/components/library/MediaHeader.tsx b/stats/src/components/library/MediaHeader.tsx index 2c45112..6d58721 100644 --- a/stats/src/components/library/MediaHeader.tsx +++ b/stats/src/components/library/MediaHeader.tsx @@ -1,16 +1,44 @@ +import { useState, useEffect } from 'react'; import { CoverImage } from './CoverImage'; import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters'; +import { getStatsClient } from '../../hooks/useStatsApi'; +import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import type { MediaDetailData } from '../../types/stats'; interface MediaHeaderProps { detail: NonNullable; + initialKnownWordsSummary?: { + totalUniqueWords: number; + knownWordCount: number; + } | null; } -export function MediaHeader({ detail }: MediaHeaderProps) { - const hitRate = +export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) { + const knownTokenRate = detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null; const avgSessionMs = detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0; + const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalWordsSeen); + + const [knownWordsSummary, setKnownWordsSummary] = useState<{ + totalUniqueWords: number; + knownWordCount: number; + } | null>(initialKnownWordsSummary); + + useEffect(() => { + let cancelled = false; + getStatsClient() + .getMediaKnownWordsSummary(detail.videoId) + .then((data) => { + if (!cancelled) setKnownWordsSummary(data); + }) + .catch(() => { + if (!cancelled) setKnownWordsSummary(null); + }); + return () => { + cancelled = true; + }; + }, [detail.videoId]); return (
@@ -32,12 +60,37 @@ export function MediaHeader({ detail }: MediaHeaderProps) {
{formatNumber(detail.totalWordsSeen)}
-
words seen
+
word occurrences
-
{formatPercent(hitRate)}
-
lookup rate
+
+ {formatNumber(detail.totalYomitanLookupCount)} +
+
Yomitan lookups
+
+
+ {lookupRate?.shortValue ?? '\u2014'} +
+
+ {lookupRate?.longValue ?? 'lookup rate'} +
+
+ {knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? ( +
+
+ {formatNumber(knownWordsSummary.knownWordCount)} / {formatNumber(knownWordsSummary.totalUniqueWords)} +
+
+ known unique words ({Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)}%) +
+
+ ) : ( +
+
{formatPercent(knownTokenRate)}
+
known token match rate
+
+ )}
{detail.totalSessions}
sessions
diff --git a/stats/src/components/library/MediaSessionList.tsx b/stats/src/components/library/MediaSessionList.tsx index 0d5cb68..29c3c40 100644 --- a/stats/src/components/library/MediaSessionList.tsx +++ b/stats/src/components/library/MediaSessionList.tsx @@ -1,11 +1,38 @@ -import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters'; +import { useEffect, useState } from 'react'; +import { SessionDetail } from '../sessions/SessionDetail'; +import { SessionRow } from '../sessions/SessionRow'; import type { SessionSummary } from '../../types/stats'; interface MediaSessionListProps { sessions: SessionSummary[]; + onDeleteSession: (session: SessionSummary) => void; + deletingSessionId?: number | null; + initialExpandedSessionId?: number | null; + onConsumeInitialExpandedSession?: () => void; } -export function MediaSessionList({ sessions }: MediaSessionListProps) { +export function MediaSessionList({ + sessions, + onDeleteSession, + deletingSessionId = null, + initialExpandedSessionId = null, + onConsumeInitialExpandedSession, +}: MediaSessionListProps) { + const [expandedId, setExpandedId] = useState(initialExpandedSessionId); + + useEffect(() => { + if (initialExpandedSessionId == null) return; + if (!sessions.some((session) => session.sessionId === initialExpandedSessionId)) return; + setExpandedId(initialExpandedSessionId); + onConsumeInitialExpandedSession?.(); + }, [initialExpandedSessionId, onConsumeInitialExpandedSession, sessions]); + + useEffect(() => { + if (expandedId == null) return; + if (sessions.some((session) => session.sessionId === expandedId)) return; + setExpandedId(null); + }, [expandedId, sessions]); + if (sessions.length === 0) { return
No sessions recorded
; } @@ -14,25 +41,22 @@ export function MediaSessionList({ sessions }: MediaSessionListProps) {

Session History

{sessions.map((s) => ( -
-
-
- {formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active +
+ + setExpandedId((current) => (current === s.sessionId ? null : s.sessionId)) + } + onDelete={() => onDeleteSession(s)} + deleteDisabled={deletingSessionId === s.sessionId} + /> + {expandedId === s.sessionId ? ( +
+
-
-
-
-
{formatNumber(s.cardsMined)}
-
cards
-
-
-
{formatNumber(s.wordsSeen)}
-
words
-
-
+ ) : null}
))}
diff --git a/stats/src/components/overview/OverviewTab.tsx b/stats/src/components/overview/OverviewTab.tsx index 82ed3b5..43fb826 100644 --- a/stats/src/components/overview/OverviewTab.tsx +++ b/stats/src/components/overview/OverviewTab.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import { useOverview } from '../../hooks/useOverview'; import { useStreakCalendar } from '../../hooks/useStreakCalendar'; import { HeroStats } from './HeroStats'; @@ -6,14 +7,113 @@ import { RecentSessions } from './RecentSessions'; import { TrendChart } from '../trends/TrendChart'; import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data'; import { formatNumber } from '../../lib/formatters'; +import { apiClient } from '../../lib/api-client'; +import { getStatsClient } from '../../hooks/useStatsApi'; +import { Tooltip } from '../layout/Tooltip'; +import { + confirmSessionDelete, + confirmDayGroupDelete, + confirmAnimeGroupDelete, +} from '../../lib/delete-confirm'; +import type { SessionSummary } from '../../types/stats'; interface OverviewTabProps { + onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToSession: (sessionId: number) => void; } -export function OverviewTab({ onNavigateToSession }: OverviewTabProps) { - const { data, sessions, loading, error } = useOverview(); +export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) { + const { data, sessions, setSessions, loading, error } = useOverview(); const { calendar, loading: calLoading } = useStreakCalendar(90); + const [deleteError, setDeleteError] = useState(null); + const [deletingIds, setDeletingIds] = useState>(new Set()); + const [knownWordsSummary, setKnownWordsSummary] = useState<{ + totalUniqueWords: number; + knownWordCount: number; + } | null>(null); + + useEffect(() => { + let cancelled = false; + getStatsClient() + .getKnownWordsSummary() + .then((data) => { + if (!cancelled) setKnownWordsSummary(data); + }) + .catch(() => { + if (!cancelled) setKnownWordsSummary(null); + }); + return () => { + cancelled = true; + }; + }, []); + + const handleDeleteSession = async (session: SessionSummary) => { + if (!confirmSessionDelete()) return; + setDeleteError(null); + setDeletingIds((prev) => new Set(prev).add(session.sessionId)); + try { + await apiClient.deleteSession(session.sessionId); + setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId)); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.'); + } finally { + setDeletingIds((prev) => { + const next = new Set(prev); + next.delete(session.sessionId); + return next; + }); + } + }; + + const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => { + if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return; + setDeleteError(null); + const ids = daySessions.map((s) => s.sessionId); + setDeletingIds((prev) => { + const next = new Set(prev); + for (const id of ids) next.add(id); + return next; + }); + try { + await apiClient.deleteSessions(ids); + const idSet = new Set(ids); + setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId))); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.'); + } finally { + setDeletingIds((prev) => { + const next = new Set(prev); + for (const id of ids) next.delete(id); + return next; + }); + } + }; + + const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => { + const title = + groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media'; + if (!confirmAnimeGroupDelete(title, groupSessions.length)) return; + setDeleteError(null); + const ids = groupSessions.map((s) => s.sessionId); + setDeletingIds((prev) => { + const next = new Set(prev); + for (const id of ids) next.add(id); + return next; + }); + try { + await apiClient.deleteSessions(ids); + const idSet = new Set(ids); + setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId))); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.'); + } finally { + setDeletingIds((prev) => { + const next = new Set(prev); + for (const id of ids) next.delete(id); + return next; + }); + } + }; if (loading) return
Loading...
; if (error) return
Error: {error}
; @@ -21,7 +121,7 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) { const summary = buildOverviewSummary(data); const streakData = buildStreakCalendar(calendar); - const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.totalSessions > 0; + const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0; return (
@@ -40,7 +140,7 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {

Tracking Snapshot

- Today cards/episodes are daily values. Lifetime totals are sourced from summary tables. + Lifetime totals sourced from summary tables.

{showTrackedCardNote && (
@@ -48,57 +148,131 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) { appear here.
)} -
-
-
- Lifetime Sessions +
+ +
+
Sessions
+
+ {formatNumber(summary.totalSessions)} +
-
- {formatNumber(summary.totalSessions)} + + +
+
Watch Time
+
+ {summary.allTimeMinutes < 60 + ? `${summary.allTimeMinutes}m` + : `${(summary.allTimeMinutes / 60).toFixed(1)}h`} +
-
-
-
Episodes Today
-
- {formatNumber(summary.episodesToday)} + + +
+
Active Days
+
+ {formatNumber(summary.activeDays)} +
-
-
-
Lifetime Hours
-
- {formatNumber(summary.allTimeHours)} + + +
+
Avg Session
+
+ {formatNumber(summary.averageSessionMinutes)} + min +
-
-
-
Lifetime Days
-
- {formatNumber(summary.activeDays)} + + +
+
Episodes
+
+ {formatNumber(summary.totalEpisodesWatched)} +
-
-
-
Lifetime Cards
-
- {formatNumber(summary.totalTrackedCards)} + + +
+
Anime
+
+ {formatNumber(summary.totalAnimeCompleted)} +
-
-
-
- Lifetime Episodes + + +
+
Cards Mined
+
+ {formatNumber(summary.totalTrackedCards)} +
-
- {formatNumber(summary.totalEpisodesWatched)} + + +
+
Lookup Rate
+
+ {summary.lookupRate != null ? `${summary.lookupRate}%` : '—'} +
-
-
-
Lifetime Anime
-
- {formatNumber(summary.totalAnimeCompleted)} + + +
+
Words Today
+
+ {formatNumber(summary.todayWords)} +
-
+ + +
+
+ New Words Today +
+
+ {formatNumber(summary.newWordsToday)} +
+
+
+ +
+
New Words
+
+ {formatNumber(summary.newWordsThisWeek)} +
+
+
+ {knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && ( + <> + +
+
+ Known Words +
+
+ {formatNumber(knownWordsSummary.knownWordCount)} + + / {formatNumber(knownWordsSummary.totalUniqueWords)} + +
+
+
+ + )}
- + {deleteError ?
{deleteError}
: null} + +
); } diff --git a/stats/src/components/overview/RecentSessions.tsx b/stats/src/components/overview/RecentSessions.tsx index a6884fb..c938a31 100644 --- a/stats/src/components/overview/RecentSessions.tsx +++ b/stats/src/components/overview/RecentSessions.tsx @@ -6,11 +6,18 @@ import { formatSessionDayLabel, } from '../../lib/formatters'; import { BASE_URL } from '../../lib/api-client'; +import { getSessionDisplayWordCount } from '../../lib/session-word-count'; +import { getSessionNavigationTarget } from '../../lib/stats-navigation'; import type { SessionSummary } from '../../types/stats'; interface RecentSessionsProps { sessions: SessionSummary[]; + onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToSession: (sessionId: number) => void; + onDeleteSession: (session: SessionSummary) => void; + onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void; + onDeleteAnimeGroup: (sessions: SessionSummary[]) => void; + deletingIds: Set; } interface AnimeGroup { @@ -52,10 +59,11 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] { : `session-${session.sessionId}`; const existing = map.get(key); + const displayWordCount = getSessionDisplayWordCount(session); if (existing) { existing.sessions.push(session); existing.totalCards += session.cardsMined; - existing.totalWords += session.wordsSeen; + existing.totalWords += displayWordCount; existing.totalActiveMs += session.activeWatchedMs; } else { map.set(key, { @@ -65,7 +73,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] { videoId: session.videoId, sessions: [session], totalCards: session.cardsMined, - totalWords: session.wordsSeen, + totalWords: displayWordCount, totalActiveMs: session.activeWatchedMs, }); } @@ -111,61 +119,104 @@ function CoverThumbnail({ function SessionItem({ session, + onNavigateToMediaDetail, onNavigateToSession, + onDelete, + deleteDisabled, }: { session: SessionSummary; + onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToSession: (sessionId: number) => void; + onDelete: () => void; + deleteDisabled: boolean; }) { + const displayWordCount = getSessionDisplayWordCount(session); + const navigationTarget = getSessionNavigationTarget(session); + return ( - +
+
+
+ {formatNumber(session.cardsMined)} +
+
cards
+
+
+
+ {formatNumber(displayWordCount)} +
+
words
+
+
+ + +
); } function AnimeGroupRow({ group, + onNavigateToMediaDetail, onNavigateToSession, + onDeleteSession, + onDeleteAnimeGroup, + deletingIds, }: { group: AnimeGroup; + onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToSession: (sessionId: number) => void; + onDeleteSession: (session: SessionSummary) => void; + onDeleteAnimeGroup: (group: AnimeGroup) => void; + deletingIds: Set; }) { const [expanded, setExpanded] = useState(false); + const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId)); if (group.sessions.length === 1) { + const s = group.sessions[0]!; return ( - + onDeleteSession(s)} + deleteDisabled={deletingIds.has(s.sessionId)} + /> ); } @@ -174,91 +225,141 @@ function AnimeGroupRow({ const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`; return ( -
-
- + +
+
{displayTitle}
+
+ {group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active +
+
+
+
+
+ {formatNumber(group.totalCards)} +
+
cards
+
+
+
+ {formatNumber(group.totalWords)} +
+
words
+
+
+ + + +
{expanded && ( -
- {group.sessions.map((s) => ( -
-
-
- {formatNumber(s.wordsSeen)} +
+
+
+ {formatNumber(s.cardsMined)} +
+
cards
+
+
+
+ {formatNumber(getSessionDisplayWordCount(s))} +
+
words
+
-
words
-
+ +
- - ))} + ); + })}
)}
); } -export function RecentSessions({ sessions, onNavigateToSession }: RecentSessionsProps) { +export function RecentSessions({ + sessions, + onNavigateToMediaDetail, + onNavigateToSession, + onDeleteSession, + onDeleteDayGroup, + onDeleteAnimeGroup, + deletingIds, +}: RecentSessionsProps) { if (sessions.length === 0) { return (
@@ -268,22 +369,42 @@ export function RecentSessions({ sessions, onNavigateToSession }: RecentSessions } const groups = groupSessionsByDay(sessions); + const anyDeleting = deletingIds.size > 0; return (
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => { const animeGroups = groupSessionsByAnime(daySessions); + const groupDeleting = daySessions.some((s) => deletingIds.has(s.sessionId)); return ( -
+

{dayLabel}

+
{animeGroups.map((group) => ( - + onDeleteAnimeGroup(g.sessions)} + deletingIds={deletingIds} + /> ))}
diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx index 3b2fa48..5c20b1b 100644 --- a/stats/src/components/sessions/SessionDetail.tsx +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -1,6 +1,7 @@ import { - ComposedChart, + AreaChart, Area, + LineChart, Line, XAxis, YAxis, @@ -8,15 +9,18 @@ import { ResponsiveContainer, ReferenceArea, ReferenceLine, + CartesianGrid, } from 'recharts'; import { useSessionDetail } from '../../hooks/useSessions'; +import type { KnownWordsTimelinePoint } from '../../hooks/useSessions'; import { CHART_THEME } from '../../lib/chart-theme'; +import { buildLookupRateDisplay, getYomitanLookupEvents } from '../../lib/yomitan-lookup'; +import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import { EventType } from '../../types/stats'; -import type { SessionEvent } from '../../types/stats'; +import type { SessionEvent, SessionSummary } from '../../types/stats'; interface SessionDetailProps { - sessionId: number; - cardsMined: number; + session: SessionSummary; } const tooltipStyle = { @@ -35,6 +39,30 @@ function formatTime(ms: number): string { }); } +/** 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; +} + interface PauseRegion { startMs: number; endMs: number; @@ -55,223 +83,524 @@ function buildPauseRegions(events: SessionEvent[]): PauseRegion[] { return regions; } -interface ChartPoint { +interface RatioChartPoint { tsMs: number; - activity: number; + knownPct: number; + unknownPct: number; + knownWords: number; + unknownWords: number; totalWords: number; - paused: boolean; } -export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) { - const { timeline, events, loading, error } = useSessionDetail(sessionId); +interface FallbackChartPoint { + tsMs: number; + totalWords: number; +} + +type TimelineEntry = { + sampleMs: number; + linesSeen: number; + wordsSeen: number; + tokensSeen: number; +}; + +export function SessionDetail({ session }: SessionDetailProps) { + const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail( + session.sessionId, + ); if (loading) return
Loading timeline...
; if (error) return
Error: {error}
; 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, - activity: delta, - totalWords: t.wordsSeen, - paused, - }; - }); + const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline); + const hasKnownWords = knownWordsMap.size > 0; 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 yomitanLookupEvents = getYomitanLookupEvents(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 pauseRegions = buildPauseRegions(events); - 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; + if (hasKnownWords) { + return ( + + ); + } return ( -
- {chartData.length > 0 && ( - - - - - - - - - - - - { - 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) => ( - - ))} +/* ── Ratio View (primary design) ────────────────────────────────── */ - {/* Seek markers */} - {seekEvents.map((e, i) => ( - - ))} +function RatioView({ + sorted, + knownWordsMap, + cardEvents, + yomitanLookupEvents, + pauseRegions, + pauseCount, + seekCount, + cardEventCount, + lookupRate, + session, +}: { + sorted: TimelineEntry[]; + knownWordsMap: Map; + cardEvents: SessionEvent[]; + yomitanLookupEvents: SessionEvent[]; + pauseRegions: PauseRegion[]; + 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, + }); + } - {/* Card mined markers */} - {cardEvents.map((e, i) => ( - - ))} + if (chartData.length === 0) { + return
No word data for this session.
; + } - - -
-
- )} + const tsMin = chartData[0]!.tsMs; + const tsMax = chartData[chartData.length - 1]!.tsMs; + const finalTotal = chartData[chartData.length - 1]!.totalWords; -
- - ({ tsMs: d.tsMs, totalWords: d.totalWords })); + + return ( +
+ {/* ── Top: Percentage area chart ── */} + + + + + + + + + + + + + + - New words - - - - Total words - - {pauseCount > 0 && ( - - + `${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) => ( + - - {pauseCount} pause{pauseCount !== 1 ? 's' : ''} - - - )} - {seekCount > 0 && ( - - ( + - - {seekCount} seek{seekCount !== 1 ? 's' : ''} - - - )} - - - - {Math.max(cardEventCount, cardsMined)} card - {Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined - + ))} + + + + + + + {/* ── Bottom: Word accumulation sparkline ── */} +
+ total words +
+ + + + + + + +
+ + {finalTotal.toLocaleString()}
+ + {/* ── Stats bar ── */} + +
+ ); +} + +/* ── Fallback View (no known words data) ────────────────────────── */ + +function FallbackView({ + sorted, + cardEvents, + yomitanLookupEvents, + pauseRegions, + pauseCount, + seekCount, + cardEventCount, + lookupRate, + session, +}: { + sorted: TimelineEntry[]; + cardEvents: SessionEvent[]; + yomitanLookupEvents: SessionEvent[]; + pauseRegions: PauseRegion[]; + 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 word data for this session.
; + } + + const tsMin = chartData[0]!.tsMs; + const tsMax = chartData[chartData.length - 1]!.tsMs; + + return ( +
+ + + + + [`${value.toLocaleString()}`, 'Total words']} + /> + + {pauseRegions.map((r, i) => ( + + ))} + + {cardEvents.map((e, i) => ( + + ))} + {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 + +
); } diff --git a/stats/src/components/sessions/SessionRow.tsx b/stats/src/components/sessions/SessionRow.tsx index 5573a94..4e70132 100644 --- a/stats/src/components/sessions/SessionRow.tsx +++ b/stats/src/components/sessions/SessionRow.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { BASE_URL } from '../../lib/api-client'; import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters'; +import { getSessionDisplayWordCount } from '../../lib/session-word-count'; import type { SessionSummary } from '../../types/stats'; interface SessionRowProps { @@ -56,15 +57,17 @@ export function SessionRow({ onDelete, deleteDisabled = false, }: SessionRowProps) { + const displayWordCount = getSessionDisplayWordCount(session); + return (
diff --git a/stats/src/components/trends/TrendsTab.tsx b/stats/src/components/trends/TrendsTab.tsx index 2869758..2e9b3ad 100644 --- a/stats/src/components/trends/TrendsTab.tsx +++ b/stats/src/components/trends/TrendsTab.tsx @@ -2,113 +2,12 @@ import { useState } from 'react'; import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends'; import { DateRangeSelector } from './DateRangeSelector'; import { TrendChart } from './TrendChart'; -import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart'; +import { StackedTrendChart } from './StackedTrendChart'; import { buildAnimeVisibilityOptions, filterHiddenAnimeData, pruneHiddenAnime, } from './anime-visibility'; -import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data'; -import { localDayFromMs } from '../../lib/formatters'; -import type { SessionSummary } from '../../types/stats'; - -const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - -function buildWatchTimeByDayOfWeek(sessions: SessionSummary[]): ChartPoint[] { - const totals = new Array(7).fill(0); - for (const s of sessions) { - const dow = new Date(s.startedAtMs).getDay(); - totals[dow] += s.activeWatchedMs; - } - return DAY_NAMES.map((name, i) => ({ label: name, value: Math.round(totals[i] / 60_000) })); -} - -function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] { - const totals = new Array(24).fill(0); - for (const s of sessions) { - const hour = new Date(s.startedAtMs).getHours(); - totals[hour] += s.activeWatchedMs; - } - return totals.map((ms, i) => ({ - label: `${String(i).padStart(2, '0')}:00`, - value: Math.round(ms / 60_000), - })); -} - -function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] { - const byAnime = new Map>(); - const allDays = new Set(); - for (const p of points) { - const dayMap = byAnime.get(p.animeTitle) ?? new Map(); - dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value); - byAnime.set(p.animeTitle, dayMap); - allDays.add(p.epochDay); - } - - const sortedDays = [...allDays].sort((a, b) => a - b); - if (sortedDays.length < 2) return points; - - const minDay = sortedDays[0]!; - const maxDay = sortedDays[sortedDays.length - 1]!; - const everyDay: number[] = []; - for (let d = minDay; d <= maxDay; d++) { - everyDay.push(d); - } - - const result: PerAnimeDataPoint[] = []; - for (const [animeTitle, dayMap] of byAnime) { - let cumulative = 0; - const firstDay = Math.min(...dayMap.keys()); - for (const day of everyDay) { - if (day < firstDay) continue; - cumulative += dayMap.get(day) ?? 0; - result.push({ epochDay: day, animeTitle, value: cumulative }); - } - } - return result; -} - -function buildPerAnimeFromSessions( - sessions: SessionSummary[], - getValue: (s: SessionSummary) => number, -): PerAnimeDataPoint[] { - const map = new Map>(); - for (const s of sessions) { - const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown'; - const day = localDayFromMs(s.startedAtMs); - const animeMap = map.get(title) ?? new Map(); - animeMap.set(day, (animeMap.get(day) ?? 0) + getValue(s)); - map.set(title, animeMap); - } - const points: PerAnimeDataPoint[] = []; - for (const [animeTitle, dayMap] of map) { - for (const [epochDay, value] of dayMap) { - points.push({ epochDay, animeTitle, value }); - } - } - return points; -} - -function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnimeDataPoint[] { - // Group by anime+day, counting distinct videoIds - const map = new Map>>(); - for (const s of sessions) { - const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown'; - const day = localDayFromMs(s.startedAtMs); - const animeMap = map.get(title) ?? new Map(); - const videoSet = animeMap.get(day) ?? new Set(); - videoSet.add(s.videoId); - animeMap.set(day, videoSet); - map.set(title, animeMap); - } - const points: PerAnimeDataPoint[] = []; - for (const [animeTitle, dayMap] of map) { - for (const [epochDay, videoSet] of dayMap) { - points.push({ epochDay, animeTitle, value: videoSet.size }); - } - } - return points; -} function SectionHeader({ children }: { children: React.ReactNode }) { return ( @@ -201,41 +100,34 @@ export function TrendsTab() { if (loading) return
Loading...
; if (error) return
Error: {error}
; + if (!data) return null; - const dashboard = buildTrendDashboard(data.rollups); - const watchByDow = buildWatchTimeByDayOfWeek(data.sessions); - const watchByHour = buildWatchTimeByHour(data.sessions); - - const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({ - epochDay: e.epochDay, - animeTitle: e.animeTitle, - value: e.totalActiveMin, - })); - const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions); - const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined); - const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen); - - const animeProgress = buildCumulativePerAnime(episodesPerAnime); - const cardsProgress = buildCumulativePerAnime(cardsPerAnime); - const wordsProgress = buildCumulativePerAnime(wordsPerAnime); const animeTitles = buildAnimeVisibilityOptions([ - episodesPerAnime, - watchTimePerAnime, - cardsPerAnime, - wordsPerAnime, - animeProgress, - cardsProgress, - wordsProgress, + data.animePerDay.episodes, + data.animePerDay.watchTime, + data.animePerDay.cards, + data.animePerDay.words, + data.animePerDay.lookups, + data.animeCumulative.episodes, + data.animeCumulative.cards, + data.animeCumulative.words, + data.animeCumulative.watchTime, ]); const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles); - const filteredEpisodesPerAnime = filterHiddenAnimeData(episodesPerAnime, activeHiddenAnime); - const filteredWatchTimePerAnime = filterHiddenAnimeData(watchTimePerAnime, activeHiddenAnime); - const filteredCardsPerAnime = filterHiddenAnimeData(cardsPerAnime, activeHiddenAnime); - const filteredWordsPerAnime = filterHiddenAnimeData(wordsPerAnime, activeHiddenAnime); - const filteredAnimeProgress = filterHiddenAnimeData(animeProgress, activeHiddenAnime); - const filteredCardsProgress = filterHiddenAnimeData(cardsProgress, activeHiddenAnime); - const filteredWordsProgress = filterHiddenAnimeData(wordsProgress, activeHiddenAnime); + const filteredEpisodesPerAnime = filterHiddenAnimeData(data.animePerDay.episodes, activeHiddenAnime); + const filteredWatchTimePerAnime = filterHiddenAnimeData(data.animePerDay.watchTime, activeHiddenAnime); + const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime); + const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime); + const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime); + const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData( + data.animePerDay.lookupsPerHundred, + activeHiddenAnime, + ); + const filteredAnimeProgress = filterHiddenAnimeData(data.animeCumulative.episodes, activeHiddenAnime); + const filteredCardsProgress = filterHiddenAnimeData(data.animeCumulative.cards, activeHiddenAnime); + const filteredWordsProgress = filterHiddenAnimeData(data.animeCumulative.words, activeHiddenAnime); + const filteredWatchTimeProgress = filterHiddenAnimeData(data.animeCumulative.watchTime, activeHiddenAnime); return (
@@ -245,23 +137,27 @@ export function TrendsTab() { onRangeChange={setRange} onGroupByChange={setGroupBy} /> -
+
Activity - - - - + + + + + Period Trends + + + + + + + + Anime — Per Day + + Anime — Cumulative + @@ -294,13 +193,13 @@ export function TrendsTab() { Patterns diff --git a/stats/src/components/vocabulary/VocabularyTab.tsx b/stats/src/components/vocabulary/VocabularyTab.tsx index 341dde6..1819b60 100644 --- a/stats/src/components/vocabulary/VocabularyTab.tsx +++ b/stats/src/components/vocabulary/VocabularyTab.tsx @@ -65,6 +65,13 @@ export function VocabularyTab({ const summary = buildVocabularySummary(filteredWords, kanji); + let knownWordCount = 0; + if (knownWords.size > 0) { + for (const w of filteredWords) { + if (knownWords.has(w.headword)) knownWordCount++; + } + } + const handleSelectWord = (entry: VocabularyEntry): void => { onOpenWordDetail?.(entry.wordId); }; @@ -80,16 +87,23 @@ export function VocabularyTab({ return (
-
+
+ {knownWords.size > 0 && ( + 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`} + color="text-ctp-green" + /> + )} { + if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) { + return; + } + const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`; setMineStatus((prev) => ({ ...prev, [key]: { loading: true } })); try { @@ -358,60 +362,75 @@ export function WordDetailPanel({ {formatNumber(occ.occurrenceCount)} in line
-
+
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '} · session {occ.sessionId} - {occ.sourcePath && - occ.segmentStartMs != null && - occ.segmentEndMs != null && - (() => { - const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; - const wordStatus = mineStatus[`${baseKey}-word`]; - const sentenceStatus = mineStatus[`${baseKey}-sentence`]; - const audioStatus = mineStatus[`${baseKey}-audio`]; - return ( - <> - - + - + - - ); - })()} + + + ); + })()}
{(() => { const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; diff --git a/stats/src/hooks/useOverview.ts b/stats/src/hooks/useOverview.ts index 60c61cc..ac73cd4 100644 --- a/stats/src/hooks/useOverview.ts +++ b/stats/src/hooks/useOverview.ts @@ -32,5 +32,5 @@ export function useOverview() { }; }, []); - return { data, sessions, loading, error }; + return { data, sessions, setSessions, loading, error }; } diff --git a/stats/src/hooks/useSessions.ts b/stats/src/hooks/useSessions.ts index daee82c..1b0178e 100644 --- a/stats/src/hooks/useSessions.ts +++ b/stats/src/hooks/useSessions.ts @@ -34,9 +34,15 @@ export function useSessions(limit = 50) { return { sessions, loading, error }; } +export interface KnownWordsTimelinePoint { + linesSeen: number; + knownWordsSeen: number; +} + export function useSessionDetail(sessionId: number | null) { const [timeline, setTimeline] = useState([]); const [events, setEvents] = useState([]); + const [knownWordsTimeline, setKnownWordsTimeline] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -46,6 +52,7 @@ export function useSessionDetail(sessionId: number | null) { if (sessionId == null) { setTimeline([]); setEvents([]); + setKnownWordsTimeline([]); setLoading(false); return () => { cancelled = true; @@ -54,12 +61,18 @@ export function useSessionDetail(sessionId: number | null) { setLoading(true); setTimeline([]); setEvents([]); + setKnownWordsTimeline([]); const client = getStatsClient(); - Promise.all([client.getSessionTimeline(sessionId), client.getSessionEvents(sessionId)]) - .then(([nextTimeline, nextEvents]) => { + Promise.all([ + client.getSessionTimeline(sessionId), + client.getSessionEvents(sessionId), + client.getSessionKnownWordsTimeline(sessionId), + ]) + .then(([nextTimeline, nextEvents, nextKnownWords]) => { if (cancelled) return; setTimeline(nextTimeline); setEvents(nextEvents); + setKnownWordsTimeline(nextKnownWords); }) .catch((err) => { if (cancelled) return; @@ -74,5 +87,5 @@ export function useSessionDetail(sessionId: number | null) { }; }, [sessionId]); - return { timeline, events, loading, error }; + return { timeline, events, knownWordsTimeline, loading, error }; } diff --git a/stats/src/hooks/useTrends.ts b/stats/src/hooks/useTrends.ts index 6d39121..4f65a01 100644 --- a/stats/src/hooks/useTrends.ts +++ b/stats/src/hooks/useTrends.ts @@ -1,36 +1,12 @@ import { useState, useEffect } from 'react'; import { getStatsClient } from './useStatsApi'; -import type { - DailyRollup, - MonthlyRollup, - EpisodesPerDay, - NewAnimePerDay, - WatchTimePerAnime, - SessionSummary, - AnimeLibraryItem, -} from '../types/stats'; +import type { TrendsDashboardData } from '../types/stats'; export type TimeRange = '7d' | '30d' | '90d' | 'all'; export type GroupBy = 'day' | 'month'; -export interface TrendsData { - rollups: DailyRollup[] | MonthlyRollup[]; - episodesPerDay: EpisodesPerDay[]; - newAnimePerDay: NewAnimePerDay[]; - watchTimePerAnime: WatchTimePerAnime[]; - sessions: SessionSummary[]; - animeLibrary: AnimeLibraryItem[]; -} - export function useTrends(range: TimeRange, groupBy: GroupBy) { - const [data, setData] = useState({ - rollups: [], - episodesPerDay: [], - newAnimePerDay: [], - watchTimePerAnime: [], - sessions: [], - animeLibrary: [], - }); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -38,51 +14,12 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) { let cancelled = false; setLoading(true); setError(null); - const client = getStatsClient(); - const limitMap: Record = { '7d': 7, '30d': 30, '90d': 90, all: 365 }; - const limit = limitMap[range]; - const monthlyLimit = Math.max(1, Math.ceil(limit / 30)); - const sessionsLimitMap: Record = { - '7d': 200, - '30d': 500, - '90d': 500, - all: 500, - }; - - const rollupFetcher = - groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit); - - Promise.all([ - rollupFetcher, - client.getEpisodesPerDay(limit), - client.getNewAnimePerDay(limit), - client.getWatchTimePerAnime(limit), - client.getSessions(sessionsLimitMap[range]), - client.getAnimeLibrary(), - ]) - .then( - ([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => { - if (cancelled) return; - const now = new Date(); - const localMidnight = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - ).getTime(); - const cutoffMs = - range === 'all' ? null : localMidnight - (limitMap[range] - 1) * 86_400_000; - const filteredSessions = - cutoffMs == null ? sessions : sessions.filter((s) => s.startedAtMs >= cutoffMs); - setData({ - rollups, - episodesPerDay, - newAnimePerDay, - watchTimePerAnime, - sessions: filteredSessions, - animeLibrary, - }); - }, - ) + getStatsClient() + .getTrendsDashboard(range, groupBy) + .then((nextData) => { + if (cancelled) return; + setData(nextData); + }) .catch((err) => { if (cancelled) return; setError(err instanceof Error ? err.message : String(err)); diff --git a/stats/src/lib/api-client.test.ts b/stats/src/lib/api-client.test.ts index d298e8b..3da24fd 100644 --- a/stats/src/lib/api-client.test.ts +++ b/stats/src/lib/api-client.test.ts @@ -65,3 +65,55 @@ test('deleteSession throws when the stats API delete request fails', async () => globalThis.fetch = originalFetch; } }); + +test('getTrendsDashboard requests the chart-ready trends endpoint with range and grouping', async () => { + const originalFetch = globalThis.fetch; + let seenUrl = ''; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seenUrl = String(input); + return new Response( + JSON.stringify({ + activity: { watchTime: [], cards: [], words: [], sessions: [] }, + progress: { + watchTime: [], + sessions: [], + words: [], + newWords: [], + cards: [], + episodes: [], + lookups: [], + }, + ratios: { lookupsPerHundred: [] }, + animePerDay: { + episodes: [], + watchTime: [], + cards: [], + words: [], + lookups: [], + lookupsPerHundred: [], + }, + animeCumulative: { + watchTime: [], + episodes: [], + cards: [], + words: [], + }, + patterns: { + watchTimeByDayOfWeek: [], + watchTimeByHour: [], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof globalThis.fetch; + + try { + await apiClient.getTrendsDashboard('90d', 'month'); + assert.equal( + seenUrl, + `${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index e544cac..1e0685f 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -17,6 +17,7 @@ import type { EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime, + TrendsDashboardData, WordDetailData, KanjiDetailData, EpisodeDetailData, @@ -73,6 +74,10 @@ export const apiClient = { fetchJson(`/api/stats/sessions/${id}/timeline?limit=${limit}`), getSessionEvents: (id: number, limit = 500) => fetchJson(`/api/stats/sessions/${id}/events?limit=${limit}`), + getSessionKnownWordsTimeline: (id: number) => + fetchJson>( + `/api/stats/sessions/${id}/known-words-timeline`, + ), getVocabulary: (limit = 100) => fetchJson(`/api/stats/vocabulary?limit=${limit}`), getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) => @@ -101,6 +106,10 @@ export const apiClient = { fetchJson(`/api/stats/trends/new-anime-per-day?limit=${limit}`), getWatchTimePerAnime: (limit = 90) => fetchJson(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), + getTrendsDashboard: (range: '7d' | '30d' | '90d' | 'all', groupBy: 'day' | 'month') => + fetchJson( + `/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`, + ), getWordDetail: (wordId: number) => fetchJson(`/api/stats/vocabulary/${wordId}/detail`), getKanjiDetail: (kanjiId: number) => @@ -117,10 +126,27 @@ export const apiClient = { deleteSession: async (sessionId: number): Promise => { await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }); }, + deleteSessions: async (sessionIds: number[]): Promise => { + await fetchResponse('/api/stats/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionIds }), + }); + }, deleteVideo: async (videoId: number): Promise => { await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }); }, getKnownWords: () => fetchJson('/api/stats/known-words'), + getKnownWordsSummary: () => + fetchJson<{ totalUniqueWords: number; knownWordCount: number }>('/api/stats/known-words-summary'), + getAnimeKnownWordsSummary: (animeId: number) => + fetchJson<{ totalUniqueWords: number; knownWordCount: number }>( + `/api/stats/anime/${animeId}/known-words-summary`, + ), + getMediaKnownWordsSummary: (videoId: number) => + fetchJson<{ totalUniqueWords: number; knownWordCount: number }>( + `/api/stats/media/${videoId}/known-words-summary`, + ), searchAnilist: (query: string) => fetchJson< Array<{ diff --git a/stats/src/lib/dashboard-data.test.ts b/stats/src/lib/dashboard-data.test.ts index f5dabfc..e1433a4 100644 --- a/stats/src/lib/dashboard-data.test.ts +++ b/stats/src/lib/dashboard-data.test.ts @@ -35,6 +35,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () => cardsMined: 2, lookupCount: 10, lookupHits: 8, + yomitanLookupCount: 0, }, ]; const rollups: DailyRollup[] = [ @@ -56,7 +57,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () => sessions, rollups, hints: { - totalSessions: 1, + totalSessions: 15, activeSessions: 0, episodesToday: 2, activeAnimeCount: 3, @@ -65,6 +66,10 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () => totalActiveMin: 50, activeDays: 2, totalCards: 9, + totalLookupCount: 100, + totalLookupHits: 80, + newWordsToday: 5, + newWordsThisWeek: 20, }, }; @@ -74,15 +79,17 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () => assert.equal(summary.episodesToday, 2); assert.equal(summary.activeAnimeCount, 3); assert.equal(summary.averageSessionMinutes, 50); - assert.equal(summary.allTimeHours, 1); + assert.equal(summary.allTimeMinutes, 50); assert.equal(summary.activeDays, 2); + assert.equal(summary.totalSessions, 15); + assert.equal(summary.lookupRate, 80); }); test('buildOverviewSummary prefers lifetime totals from hints when provided', () => { const now = Date.UTC(2026, 2, 13, 12); const today = Math.floor(now / 86_400_000); - const overview: OverviewData = { - sessions: [ + const overview: OverviewData = { + sessions: [ { sessionId: 2, canonicalTitle: 'B', @@ -99,6 +106,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', () cardsMined: 10, lookupCount: 1, lookupHits: 1, + yomitanLookupCount: 0, }, ], rollups: [ @@ -117,7 +125,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', () }, ], hints: { - totalSessions: 999, + totalSessions: 50, activeSessions: 0, episodesToday: 0, activeAnimeCount: 0, @@ -126,13 +134,16 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', () totalActiveMin: 120, activeDays: 40, totalCards: 5, + totalLookupCount: 0, + totalLookupHits: 0, + newWordsToday: 0, + newWordsThisWeek: 0, }, }; const summary = buildOverviewSummary(overview, now); assert.equal(summary.totalTrackedCards, 5); - assert.equal(summary.totalSessions, 999); - assert.equal(summary.allTimeHours, 2); + assert.equal(summary.allTimeMinutes, 120); assert.equal(summary.activeDays, 40); }); @@ -150,6 +161,8 @@ test('buildVocabularySummary treats firstSeen timestamps as seconds', () => { pos2: null, pos3: null, frequency: 4, + frequencyRank: null, + animeCount: 1, firstSeen: nowSec - 2 * 86_400, lastSeen: nowSec - 1, }, diff --git a/stats/src/lib/dashboard-data.ts b/stats/src/lib/dashboard-data.ts index 0a24181..9cdddf3 100644 --- a/stats/src/lib/dashboard-data.ts +++ b/stats/src/lib/dashboard-data.ts @@ -16,15 +16,19 @@ export interface OverviewSummary { todayActiveMs: number; todayCards: number; streakDays: number; - allTimeHours: number; + allTimeMinutes: number; totalTrackedCards: number; episodesToday: number; activeAnimeCount: number; totalEpisodesWatched: number; totalAnimeCompleted: number; averageSessionMinutes: number; - totalSessions: number; activeDays: number; + totalSessions: number; + lookupRate: number | null; + todayWords: number; + newWordsToday: number; + newWordsThisWeek: number; recentWatchTime: ChartPoint[]; } @@ -161,7 +165,7 @@ export function buildOverviewSummary( sumBy(todaySessions, (session) => session.cardsMined), ), streakDays, - allTimeHours: Math.max(0, Math.round(totalActiveMin / 60)), + allTimeMinutes: Math.max(0, Math.round(totalActiveMin)), totalTrackedCards: lifetimeCards, episodesToday: overview.hints.episodesToday ?? 0, activeAnimeCount: overview.hints.activeAnimeCount ?? 0, @@ -175,8 +179,18 @@ export function buildOverviewSummary( 60_000, ) : 0, - totalSessions: overview.hints.totalSessions, activeDays: overview.hints.activeDays ?? daysWithActivity.size, + totalSessions: overview.hints.totalSessions ?? overview.sessions.length, + lookupRate: + overview.hints.totalLookupCount > 0 + ? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100) + : null, + todayWords: Math.max( + todayRow?.words ?? 0, + sumBy(todaySessions, (session) => session.wordsSeen), + ), + newWordsToday: overview.hints.newWordsToday ?? 0, + newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0, recentWatchTime: aggregated .slice(-14) .map((row) => ({ label: row.label, value: row.activeMin })), diff --git a/stats/src/lib/delete-confirm.test.ts b/stats/src/lib/delete-confirm.test.ts index 7be70db..709e1e2 100644 --- a/stats/src/lib/delete-confirm.test.ts +++ b/stats/src/lib/delete-confirm.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm'; +import { confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm'; test('confirmSessionDelete uses the shared session delete warning copy', () => { const calls: string[] = []; @@ -18,6 +18,38 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => { } }); +test('confirmDayGroupDelete includes the day label and count in the warning copy', () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + globalThis.confirm = ((message?: string) => { + calls.push(message ?? ''); + return true; + }) as typeof globalThis.confirm; + + try { + assert.equal(confirmDayGroupDelete('Today', 3), true); + assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']); + } finally { + globalThis.confirm = originalConfirm; + } +}); + +test('confirmDayGroupDelete uses singular for one session', () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + globalThis.confirm = ((message?: string) => { + calls.push(message ?? ''); + return true; + }) as typeof globalThis.confirm; + + try { + assert.equal(confirmDayGroupDelete('Yesterday', 1), true); + assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']); + } finally { + globalThis.confirm = originalConfirm; + } +}); + test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; diff --git a/stats/src/lib/delete-confirm.ts b/stats/src/lib/delete-confirm.ts index 2222cdb..b3f7cd3 100644 --- a/stats/src/lib/delete-confirm.ts +++ b/stats/src/lib/delete-confirm.ts @@ -2,6 +2,18 @@ export function confirmSessionDelete(): boolean { return globalThis.confirm('Delete this session and all associated data?'); } +export function confirmDayGroupDelete(dayLabel: string, count: number): boolean { + return globalThis.confirm( + `Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`, + ); +} + +export function confirmAnimeGroupDelete(title: string, count: number): boolean { + return globalThis.confirm( + `Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`, + ); +} + export function confirmEpisodeDelete(title: string): boolean { return globalThis.confirm(`Delete "${title}" and all its sessions?`); } diff --git a/stats/src/lib/media-session-list.test.tsx b/stats/src/lib/media-session-list.test.tsx new file mode 100644 index 0000000..3f37494 --- /dev/null +++ b/stats/src/lib/media-session-list.test.tsx @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MediaSessionList } from '../components/library/MediaSessionList'; + +test('MediaSessionList renders expandable session rows with delete affordance', () => { + const markup = renderToStaticMarkup( + {}} + initialExpandedSessionId={7} + />, + ); + + assert.match(markup, /Session History/); + assert.match(markup, /aria-expanded="true"/); + assert.match(markup, /Delete session Episode 7/); + assert.match(markup, /Total words/); + assert.match(markup, /1 Yomitan lookup/); +}); diff --git a/stats/src/lib/session-detail.test.tsx b/stats/src/lib/session-detail.test.tsx new file mode 100644 index 0000000..7e5e4fb --- /dev/null +++ b/stats/src/lib/session-detail.test.tsx @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SessionDetail } from '../components/sessions/SessionDetail'; + +test('SessionDetail omits the misleading new words metric', () => { + const markup = renderToStaticMarkup( + , + ); + + assert.match(markup, /Total words/); + assert.doesNotMatch(markup, /New words/); +}); diff --git a/stats/src/lib/session-word-count.ts b/stats/src/lib/session-word-count.ts new file mode 100644 index 0000000..74740c6 --- /dev/null +++ b/stats/src/lib/session-word-count.ts @@ -0,0 +1,8 @@ +type SessionWordCountLike = { + wordsSeen: number; + tokensSeen: number; +}; + +export function getSessionDisplayWordCount(value: SessionWordCountLike): number { + return value.tokensSeen > 0 ? value.tokensSeen : value.wordsSeen; +} diff --git a/stats/src/lib/stats-navigation.test.ts b/stats/src/lib/stats-navigation.test.ts new file mode 100644 index 0000000..832887e --- /dev/null +++ b/stats/src/lib/stats-navigation.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + closeMediaDetail, + createInitialStatsView, + getSessionNavigationTarget, + navigateToAnime, + openAnimeEpisodeDetail, + openOverviewMediaDetail, + switchTab, + type StatsViewState, +} from './stats-navigation'; + +test('openAnimeEpisodeDetail opens dedicated media detail from anime context', () => { + const state = createInitialStatsView(); + + assert.deepEqual(openAnimeEpisodeDetail(state, 42, 7), { + activeTab: 'anime', + selectedAnimeId: 42, + focusedSessionId: null, + mediaDetail: { + videoId: 7, + initialSessionId: null, + origin: { + type: 'anime', + animeId: 42, + }, + }, + } satisfies StatsViewState); +}); + +test('closeMediaDetail returns to originating anime detail state', () => { + const state = openAnimeEpisodeDetail(navigateToAnime(createInitialStatsView(), 42), 42, 7); + + assert.deepEqual(closeMediaDetail(state), { + activeTab: 'anime', + selectedAnimeId: 42, + focusedSessionId: null, + mediaDetail: null, + } satisfies StatsViewState); +}); + +test('openOverviewMediaDetail opens dedicated media detail from overview context', () => { + assert.deepEqual(openOverviewMediaDetail(createInitialStatsView(), 9), { + activeTab: 'overview', + selectedAnimeId: null, + focusedSessionId: null, + mediaDetail: { + videoId: 9, + initialSessionId: null, + origin: { + type: 'overview', + }, + }, + } satisfies StatsViewState); +}); + +test('closeMediaDetail returns to overview when media detail originated there', () => { + const state = openOverviewMediaDetail(createInitialStatsView(), 9); + + assert.deepEqual(closeMediaDetail(state), createInitialStatsView()); +}); + +test('switchTab clears dedicated media detail state', () => { + const state = openAnimeEpisodeDetail(navigateToAnime(createInitialStatsView(), 42), 42, 7); + + assert.deepEqual(switchTab(state, 'sessions'), { + activeTab: 'sessions', + selectedAnimeId: null, + focusedSessionId: null, + mediaDetail: null, + } satisfies StatsViewState); +}); + +test('getSessionNavigationTarget prefers media detail when video id exists', () => { + assert.deepEqual(getSessionNavigationTarget({ sessionId: 4, videoId: 12 }), { + type: 'media-detail', + videoId: 12, + sessionId: 4, + }); +}); + +test('getSessionNavigationTarget falls back to session page when video id is missing', () => { + assert.deepEqual(getSessionNavigationTarget({ sessionId: 4, videoId: null }), { + type: 'session', + sessionId: 4, + }); +}); + +test('openOverviewMediaDetail can carry a target session id for auto-expansion', () => { + assert.deepEqual(openOverviewMediaDetail(createInitialStatsView(), 9, 33), { + activeTab: 'overview', + selectedAnimeId: null, + focusedSessionId: null, + mediaDetail: { + videoId: 9, + initialSessionId: 33, + origin: { + type: 'overview', + }, + }, + } satisfies StatsViewState); +}); diff --git a/stats/src/lib/stats-navigation.ts b/stats/src/lib/stats-navigation.ts new file mode 100644 index 0000000..360271b --- /dev/null +++ b/stats/src/lib/stats-navigation.ts @@ -0,0 +1,139 @@ +import type { SessionSummary } from '../types/stats'; +import type { TabId } from '../components/layout/TabBar'; + +export type MediaDetailOrigin = { type: 'anime'; animeId: number } | { type: 'overview' }; + +export interface MediaDetailState { + videoId: number; + initialSessionId: number | null; + origin: MediaDetailOrigin; +} + +export interface StatsViewState { + activeTab: TabId; + selectedAnimeId: number | null; + focusedSessionId: number | null; + mediaDetail: MediaDetailState | null; +} + +export function createInitialStatsView(): StatsViewState { + return { + activeTab: 'overview', + selectedAnimeId: null, + focusedSessionId: null, + mediaDetail: null, + }; +} + +export function switchTab(state: StatsViewState, tabId: TabId): StatsViewState { + return { + activeTab: tabId, + selectedAnimeId: null, + focusedSessionId: tabId === 'sessions' ? state.focusedSessionId : null, + mediaDetail: null, + }; +} + +export function navigateToAnime(state: StatsViewState, animeId: number): StatsViewState { + return { + ...state, + activeTab: 'anime', + selectedAnimeId: animeId, + mediaDetail: null, + }; +} + +export function navigateToSession(state: StatsViewState, sessionId: number): StatsViewState { + return { + ...state, + activeTab: 'sessions', + focusedSessionId: sessionId, + mediaDetail: null, + }; +} + +export function openAnimeEpisodeDetail( + state: StatsViewState, + animeId: number, + videoId: number, + sessionId: number | null = null, +): StatsViewState { + return { + activeTab: 'anime', + selectedAnimeId: animeId, + focusedSessionId: null, + mediaDetail: { + videoId, + initialSessionId: sessionId, + origin: { + type: 'anime', + animeId, + }, + }, + }; +} + +export function openOverviewMediaDetail( + state: StatsViewState, + videoId: number, + sessionId: number | null = null, +): StatsViewState { + return { + activeTab: 'overview', + selectedAnimeId: null, + focusedSessionId: null, + mediaDetail: { + videoId, + initialSessionId: sessionId, + origin: { + type: 'overview', + }, + }, + }; +} + +export function closeMediaDetail(state: StatsViewState): StatsViewState { + if (!state.mediaDetail) { + return state; + } + + if (state.mediaDetail.origin.type === 'overview') { + return { + activeTab: 'overview', + selectedAnimeId: null, + focusedSessionId: null, + mediaDetail: null, + }; + } + + return { + activeTab: 'anime', + selectedAnimeId: state.mediaDetail.origin.animeId, + focusedSessionId: null, + mediaDetail: null, + }; +} + +export function getSessionNavigationTarget(session: Pick): + | { + type: 'media-detail'; + videoId: number; + sessionId: number; + } + | { + type: 'session'; + sessionId: number; + } { + if (session.videoId != null) { + return { + type: 'media-detail', + videoId: session.videoId, + sessionId: session.sessionId, + }; + } + + return { + type: 'session', + sessionId: session.sessionId, + }; +} diff --git a/stats/src/lib/stats-ui-navigation.test.tsx b/stats/src/lib/stats-ui-navigation.test.tsx new file mode 100644 index 0000000..e8f6b23 --- /dev/null +++ b/stats/src/lib/stats-ui-navigation.test.tsx @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { TabBar } from '../components/layout/TabBar'; +import { EpisodeList } from '../components/anime/EpisodeList'; + +test('TabBar renders Library instead of Anime for the media library tab', () => { + const markup = renderToStaticMarkup( {}} />); + + assert.doesNotMatch(markup, />AnimeOverviewLibrary { + const markup = renderToStaticMarkup( + {}} + />, + ); + + assert.match(markup, />Details { + const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8'); + const loadingGuardIndex = source.indexOf('if (loading) {'); + + assert.notEqual(loadingGuardIndex, -1, 'expected loading early return'); + + const hooksAfterLoadingGuard = source + .slice(loadingGuardIndex) + .match(/\buse(?:State|Effect|Memo|Callback|Ref|Reducer)\s*\(/g); + + assert.deepEqual(hooksAfterLoadingGuard ?? [], []); +}); diff --git a/stats/src/lib/yomitan-lookup.test.tsx b/stats/src/lib/yomitan-lookup.test.tsx new file mode 100644 index 0000000..779364f --- /dev/null +++ b/stats/src/lib/yomitan-lookup.test.tsx @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MediaHeader } from '../components/library/MediaHeader'; +import { EpisodeList } from '../components/anime/EpisodeList'; +import { AnimeOverviewStats } from '../components/anime/AnimeOverviewStats'; +import { SessionRow } from '../components/sessions/SessionRow'; +import { EventType, type SessionEvent } from '../types/stats'; +import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup'; + +test('buildLookupRateDisplay formats lookups per 100 words in short and long forms', () => { + assert.deepEqual(buildLookupRateDisplay(23, 1000), { + shortValue: '2.3 / 100 words', + longValue: '2.3 lookups per 100 words', + }); + assert.equal(buildLookupRateDisplay(0, 0), null); +}); + +test('getYomitanLookupEvents keeps only Yomitan lookup events', () => { + const events: SessionEvent[] = [ + { eventType: EventType.LOOKUP, tsMs: 1, payload: null }, + { eventType: EventType.YOMITAN_LOOKUP, tsMs: 2, payload: null }, + { eventType: EventType.CARD_MINED, tsMs: 3, payload: null }, + ]; + + assert.deepEqual( + getYomitanLookupEvents(events).map((event) => event.tsMs), + [2], + ); +}); + +test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => { + const markup = renderToStaticMarkup( + , + ); + + assert.match(markup, /23/); + assert.match(markup, /2\.3 \/ 100 words/); + assert.match(markup, /2\.3 lookups per 100 words/); +}); + +test('MediaHeader distinguishes word occurrences from known unique words', () => { + const markup = renderToStaticMarkup( + , + ); + + assert.match(markup, /word occurrences/); + assert.match(markup, /known unique words \(50%\)/); + assert.match(markup, /17 \/ 34/); +}); + +test('EpisodeList renders per-episode Yomitan lookup rate', () => { + const markup = renderToStaticMarkup( + , + ); + + assert.match(markup, /Lookup Rate/); + assert.match(markup, /2\.0 \/ 100 words/); +}); + +test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => { + const markup = renderToStaticMarkup( + , + ); + + assert.match(markup, /Lookups/); + assert.match(markup, /16/); + assert.match(markup, /2\.0 \/ 100 words/); + assert.match(markup, /2\.0 lookups per 100 words/); +}); + +test('SessionRow prefers token-based word count when available', () => { + const markup = renderToStaticMarkup( + {}} + onDelete={() => {}} + />, + ); + + assert.match(markup, />4212 event.eventType === EventType.YOMITAN_LOOKUP); +} diff --git a/stats/src/types/stats.ts b/stats/src/types/stats.ts index a4333c6..e1c7d4b 100644 --- a/stats/src/types/stats.ts +++ b/stats/src/types/stats.ts @@ -14,6 +14,7 @@ export interface SessionSummary { cardsMined: number; lookupCount: number; lookupHits: number; + yomitanLookupCount: number; } export interface DailyRollup { @@ -100,6 +101,10 @@ export interface OverviewData { totalActiveMin: number; activeDays: number; totalCards?: number; + totalLookupCount: number; + totalLookupHits: number; + newWordsToday: number; + newWordsThisWeek: number; }; } @@ -125,6 +130,7 @@ export interface MediaDetailData { totalLinesSeen: number; totalLookupCount: number; totalLookupHits: number; + totalYomitanLookupCount: number; } | null; sessions: SessionSummary[]; rollups: DailyRollup[]; @@ -139,6 +145,7 @@ export const EventType = { SEEK_BACKWARD: 6, PAUSE_START: 7, PAUSE_END: 8, + YOMITAN_LOOKUP: 9, } as const; export type EventType = (typeof EventType)[keyof typeof EventType]; @@ -179,6 +186,7 @@ export interface AnimeDetailData { totalLinesSeen: number; totalLookupCount: number; totalLookupHits: number; + totalYomitanLookupCount: number; episodeCount: number; lastWatchedMs: number; }; @@ -196,6 +204,8 @@ export interface AnimeEpisode { totalSessions: number; totalActiveMs: number; totalCards: number; + totalWordsSeen: number; + totalYomitanLookupCount: number; lastWatchedMs: number; } @@ -230,6 +240,56 @@ export interface WatchTimePerAnime { totalActiveMin: number; } +export interface TrendChartPoint { + label: string; + value: number; +} + +export interface TrendPerAnimePoint { + epochDay: number; + animeTitle: string; + value: number; +} + +export interface TrendsDashboardData { + activity: { + watchTime: TrendChartPoint[]; + cards: TrendChartPoint[]; + words: TrendChartPoint[]; + sessions: TrendChartPoint[]; + }; + progress: { + watchTime: TrendChartPoint[]; + sessions: TrendChartPoint[]; + words: TrendChartPoint[]; + newWords: TrendChartPoint[]; + cards: TrendChartPoint[]; + episodes: TrendChartPoint[]; + lookups: TrendChartPoint[]; + }; + ratios: { + lookupsPerHundred: TrendChartPoint[]; + }; + animePerDay: { + episodes: TrendPerAnimePoint[]; + watchTime: TrendPerAnimePoint[]; + cards: TrendPerAnimePoint[]; + words: TrendPerAnimePoint[]; + lookups: TrendPerAnimePoint[]; + lookupsPerHundred: TrendPerAnimePoint[]; + }; + animeCumulative: { + watchTime: TrendPerAnimePoint[]; + episodes: TrendPerAnimePoint[]; + cards: TrendPerAnimePoint[]; + words: TrendPerAnimePoint[]; + }; + patterns: { + watchTimeByDayOfWeek: TrendChartPoint[]; + watchTimeByHour: TrendChartPoint[]; + }; +} + export interface WordDetailData { detail: { wordId: number;