feat: overhaul stats dashboard with navigation, trends, and anime views

Add navigation state machine for tab/detail routing, anime overview
stats with Yomitan lookup rates, session word count accuracy fixes,
vocabulary tab hook order fix, simplified trends data fetching from
backend-aggregated endpoints, and improved session detail charts.
This commit is contained in:
2026-03-17 19:54:15 -07:00
parent 08a5401a7d
commit f8e2ae4887
39 changed files with 2578 additions and 871 deletions

View File

@@ -3,23 +3,31 @@ import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab'; import { OverviewTab } from './components/overview/OverviewTab';
import { TrendsTab } from './components/trends/TrendsTab'; import { TrendsTab } from './components/trends/TrendsTab';
import { AnimeTab } from './components/anime/AnimeTab'; 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 { VocabularyTab } from './components/vocabulary/VocabularyTab';
import { SessionsTab } from './components/sessions/SessionsTab'; import { SessionsTab } from './components/sessions/SessionsTab';
import { WordDetailPanel } from './components/vocabulary/WordDetailPanel'; import { WordDetailPanel } from './components/vocabulary/WordDetailPanel';
import { useExcludedWords } from './hooks/useExcludedWords'; import { useExcludedWords } from './hooks/useExcludedWords';
import type { TabId } from './components/layout/TabBar'; 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() { export function App() {
const [activeTab, setActiveTab] = useState<TabId>('overview'); const [viewState, setViewState] = useState(createInitialStatsView);
const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview'])); const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview']));
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
const [focusedSessionId, setFocusedSessionId] = useState<number | null>(null);
const [globalWordId, setGlobalWordId] = useState<number | null>(null); const [globalWordId, setGlobalWordId] = useState<number | null>(null);
const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords(); const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords();
const { activeTab, selectedAnimeId, focusedSessionId, mediaDetail } = viewState;
const activateTab = useCallback((tabId: TabId) => { const activateTab = useCallback((tabId: TabId) => {
setActiveTab(tabId); setViewState((prev) => switchTab(prev, tabId));
setMountedTabs((prev) => { setMountedTabs((prev) => {
if (prev.has(tabId)) return prev; if (prev.has(tabId)) return prev;
const next = new Set(prev); const next = new Set(prev);
@@ -29,26 +37,49 @@ export function App() {
}, []); }, []);
const navigateToAnime = useCallback((animeId: number) => { const navigateToAnime = useCallback((animeId: number) => {
activateTab('anime'); setViewState((prev) => navigateToAnimeState(prev, animeId));
setSelectedAnimeId(animeId); setMountedTabs((prev) => {
}, [activateTab]); if (prev.has('anime')) return prev;
const next = new Set(prev);
next.add('anime');
return next;
});
}, []);
const navigateToSession = useCallback((sessionId: number) => { const navigateToSession = useCallback((sessionId: number) => {
activateTab('sessions'); setViewState((prev) => navigateToSessionState(prev, sessionId));
setFocusedSessionId(sessionId); setMountedTabs((prev) => {
}, [activateTab]); 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) => { const openWordDetail = useCallback((wordId: number) => {
setGlobalWordId(wordId); setGlobalWordId(wordId);
}, []); }, []);
const handleTabChange = useCallback((tabId: TabId) => { const handleTabChange = useCallback(
activateTab(tabId); (tabId: TabId) => {
setSelectedAnimeId(null); activateTab(tabId);
if (tabId !== 'sessions') { },
setFocusedSessionId(null); [activateTab],
} );
}, [activateTab]);
return ( return (
<div className="min-h-screen flex flex-col bg-ctp-base"> <div className="min-h-screen flex flex-col bg-ctp-base">
@@ -64,86 +95,109 @@ export function App() {
<TabBar activeTab={activeTab} onTabChange={handleTabChange} /> <TabBar activeTab={activeTab} onTabChange={handleTabChange} />
</header> </header>
<main className="flex-1 overflow-y-auto p-4"> <main className="flex-1 overflow-y-auto p-4">
{mountedTabs.has('overview') ? ( {mediaDetail ? (
<section <MediaDetailView
id="panel-overview" videoId={mediaDetail.videoId}
role="tabpanel" initialExpandedSessionId={mediaDetail.initialSessionId}
aria-labelledby="tab-overview" onConsumeInitialExpandedSession={() =>
hidden={activeTab !== 'overview'} setViewState((prev) =>
className="animate-fade-in" prev.mediaDetail
> ? {
<OverviewTab onNavigateToSession={navigateToSession} /> ...prev,
</section> mediaDetail: {
) : null} ...prev.mediaDetail,
{mountedTabs.has('anime') ? ( initialSessionId: null,
<section },
id="panel-anime" }
role="tabpanel" : prev,
aria-labelledby="tab-anime" )
hidden={activeTab !== 'anime'} }
className="animate-fade-in" onBack={() => setViewState((prev) => closeMediaDetail(prev))}
> backLabel={
<AnimeTab mediaDetail.origin.type === 'overview' ? 'Back to Overview' : 'Back to Library'
initialAnimeId={selectedAnimeId} }
onClearInitialAnime={() => setSelectedAnimeId(null)} />
onNavigateToWord={openWordDetail} ) : (
/> <>
</section> {mountedTabs.has('overview') ? (
) : null} <section
{mountedTabs.has('trends') ? ( id="panel-overview"
<section role="tabpanel"
id="panel-trends" aria-labelledby="tab-overview"
role="tabpanel" hidden={activeTab !== 'overview'}
aria-labelledby="tab-trends" className="animate-fade-in"
hidden={activeTab !== 'trends'} >
className="animate-fade-in" <OverviewTab
> onNavigateToMediaDetail={navigateToOverviewMediaDetail}
<TrendsTab /> onNavigateToSession={navigateToSession}
</section> />
) : null} </section>
{mountedTabs.has('vocabulary') ? ( ) : null}
<section {mountedTabs.has('anime') ? (
id="panel-vocabulary" <section
role="tabpanel" id="panel-anime"
aria-labelledby="tab-vocabulary" role="tabpanel"
hidden={activeTab !== 'vocabulary'} aria-labelledby="tab-anime"
className="animate-fade-in" hidden={activeTab !== 'anime'}
> className="animate-fade-in"
<VocabularyTab >
onNavigateToAnime={navigateToAnime} <AnimeTab
onOpenWordDetail={openWordDetail} initialAnimeId={selectedAnimeId}
excluded={excluded} onClearInitialAnime={() =>
isExcluded={isExcluded} setViewState((prev) => ({ ...prev, selectedAnimeId: null }))
onRemoveExclusion={removeExclusion} }
onClearExclusions={clearAll} onNavigateToWord={openWordDetail}
/> onOpenEpisodeDetail={navigateToEpisodeDetail}
</section> />
) : null} </section>
{mountedTabs.has('library') ? ( ) : null}
<section {mountedTabs.has('trends') ? (
id="panel-library" <section
role="tabpanel" id="panel-trends"
aria-labelledby="tab-library" role="tabpanel"
hidden={activeTab !== 'library'} aria-labelledby="tab-trends"
className="animate-fade-in" hidden={activeTab !== 'trends'}
> className="animate-fade-in"
<LibraryTab /> >
</section> <TrendsTab />
) : null} </section>
{mountedTabs.has('sessions') ? ( ) : null}
<section {mountedTabs.has('vocabulary') ? (
id="panel-sessions" <section
role="tabpanel" id="panel-vocabulary"
aria-labelledby="tab-sessions" role="tabpanel"
hidden={activeTab !== 'sessions'} aria-labelledby="tab-vocabulary"
className="animate-fade-in" hidden={activeTab !== 'vocabulary'}
> className="animate-fade-in"
<SessionsTab >
initialSessionId={focusedSessionId} <VocabularyTab
onClearInitialSession={() => setFocusedSessionId(null)} onNavigateToAnime={navigateToAnime}
/> onOpenWordDetail={openWordDetail}
</section> excluded={excluded}
) : null} isExcluded={isExcluded}
onRemoveExclusion={removeExclusion}
onClearExclusions={clearAll}
/>
</section>
) : null}
{mountedTabs.has('sessions') ? (
<section
id="panel-sessions"
role="tabpanel"
aria-labelledby="tab-sessions"
hidden={activeTab !== 'sessions'}
className="animate-fade-in"
>
<SessionsTab
initialSessionId={focusedSessionId}
onClearInitialSession={() =>
setViewState((prev) => ({ ...prev, focusedSessionId: null }))
}
/>
</section>
) : null}
</>
)}
</main> </main>
<WordDetailPanel <WordDetailPanel
wordId={globalWordId} wordId={globalWordId}

View File

@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail'; import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi'; import { getStatsClient } from '../../hooks/useStatsApi';
import { formatDuration, formatNumber, epochDayToDate } from '../../lib/formatters'; import { epochDayToDate } from '../../lib/formatters';
import { StatCard } from '../layout/StatCard';
import { AnimeHeader } from './AnimeHeader'; import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList'; import { EpisodeList } from './EpisodeList';
import { AnimeWordList } from './AnimeWordList'; import { AnimeWordList } from './AnimeWordList';
import { AnilistSelector } from './AnilistSelector'; import { AnilistSelector } from './AnilistSelector';
import { AnimeOverviewStats } from './AnimeOverviewStats';
import { CHART_THEME } from '../../lib/chart-theme'; import { CHART_THEME } from '../../lib/chart-theme';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import type { DailyRollup } from '../../types/stats'; import type { DailyRollup } from '../../types/stats';
@@ -15,6 +15,7 @@ interface AnimeDetailViewProps {
animeId: number; animeId: number;
onBack: () => void; onBack: () => void;
onNavigateToWord?: (wordId: number) => void; onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (videoId: number) => void;
} }
type Range = 14 | 30 | 90; 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 { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false); const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const knownWordsSummary = useAnimeKnownWords(animeId);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>; if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data; const { detail, episodes, anilistEntries } = data;
const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<button <button
@@ -130,29 +156,21 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
onClick={onBack} onClick={onBack}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors" className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
> >
&larr; Back to Anime &larr; Back to Library
</button> </button>
<AnimeHeader <AnimeHeader
detail={detail} detail={detail}
anilistEntries={anilistEntries ?? []} anilistEntries={anilistEntries ?? []}
onChangeAnilist={() => setShowAnilistSelector(true)} onChangeAnilist={() => setShowAnilistSelector(true)}
/> />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3"> <AnimeOverviewStats
<StatCard detail={detail}
label="Watch Time" knownWordsSummary={knownWordsSummary}
value={formatDuration(detail.totalActiveMs)} />
color="text-ctp-blue" <EpisodeList
/> episodes={episodes}
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" /> onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
<StatCard />
label="Words"
value={formatNumber(detail.totalWordsSeen)}
color="text-ctp-mauve"
/>
<StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" />
<StatCard label="Avg Session" value={formatDuration(avgSessionMs)} />
</div>
<EpisodeList episodes={episodes} />
<AnimeWatchChart animeId={animeId} /> <AnimeWatchChart animeId={animeId} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} /> <AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
{showAnilistSelector && ( {showAnilistSelector && (

View File

@@ -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 (
<Tooltip text={tooltip}>
<div className="flex flex-col items-center gap-1 px-3 py-3 rounded-lg bg-ctp-surface1/40 hover:bg-ctp-surface1/70 transition-colors">
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>
{value}
{unit && <span className="text-sm font-normal text-ctp-overlay2 ml-0.5">{unit}</span>}
</div>
<div className="text-[11px] uppercase tracking-wider text-ctp-overlay2 font-medium">
{label}
</div>
{sub && <div className="text-[11px] text-ctp-overlay1">{sub}</div>}
</div>
</Tooltip>
);
}
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 (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 space-y-3">
{/* Primary metrics - always 4 columns on sm+ */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Metric
label="Watch Time"
value={formatDuration(detail.totalActiveMs)}
color="text-ctp-blue"
tooltip="Total active watch time for this anime"
/>
<Metric
label="Sessions"
value={String(detail.totalSessions)}
color="text-ctp-peach"
tooltip="Number of immersion sessions on this anime"
/>
<Metric
label="Episodes"
value={String(detail.episodeCount)}
color="text-ctp-yellow"
tooltip="Number of completed episodes for this anime"
/>
<Metric
label="Words Seen"
value={formatNumber(detail.totalWordsSeen)}
color="text-ctp-mauve"
tooltip="Total word occurrences across all sessions"
/>
</div>
{/* Secondary metrics - fills row evenly */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Metric
label="Cards Mined"
value={formatNumber(detail.totalCards)}
color="text-ctp-green"
tooltip="Anki cards created from subtitle lines in this anime"
/>
<Metric
label="Lookups"
value={formatNumber(detail.totalYomitanLookupCount)}
color="text-ctp-lavender"
tooltip="Total Yomitan dictionary lookups during sessions"
/>
{lookupRate ? (
<Metric
label="Lookup Rate"
value={lookupRate.shortValue}
color="text-ctp-sapphire"
tooltip="Yomitan lookups per 100 words seen"
/>
) : (
<Metric
label="Lookup Rate"
value="—"
color="text-ctp-overlay2"
tooltip="No lookups recorded yet"
/>
)}
{knownPct !== null ? (
<Metric
label="Known Words"
value={`${knownPct}%`}
color="text-ctp-green"
tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this anime`}
/>
) : (
<Metric
label="Known Words"
value="—"
color="text-ctp-overlay2"
tooltip="No word data available yet"
/>
)}
</div>
</div>
);
}

View File

@@ -39,9 +39,15 @@ interface AnimeTabProps {
initialAnimeId?: number | null; initialAnimeId?: number | null;
onClearInitialAnime?: () => void; onClearInitialAnime?: () => void;
onNavigateToWord?: (wordId: number) => 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 { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched'); const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
@@ -70,6 +76,11 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
animeId={selectedAnimeId} animeId={selectedAnimeId}
onBack={() => setSelectedAnimeId(null)} onBack={() => setSelectedAnimeId(null)}
onNavigateToWord={onNavigateToWord} onNavigateToWord={onNavigateToWord}
onOpenEpisodeDetail={
onOpenEpisodeDetail
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
/> />
); );
} }

View File

@@ -3,6 +3,7 @@ import { getStatsClient } from '../../hooks/useStatsApi';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm'; import { confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import type { EpisodeDetailData } from '../../types/stats'; import type { EpisodeDetailData } from '../../types/stats';
interface EpisodeDetailProps { interface EpisodeDetailProps {
@@ -89,7 +90,9 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
</span> </span>
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span> <span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
<span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span> <span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-peach">{formatNumber(s.wordsSeen)} words</span> <span className="text-ctp-peach">
{formatNumber(getSessionDisplayWordCount(s))} words
</span>
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {

View File

@@ -2,15 +2,21 @@ import { Fragment, useState } from 'react';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client'; import { apiClient } from '../../lib/api-client';
import { confirmEpisodeDelete } from '../../lib/delete-confirm'; import { confirmEpisodeDelete } from '../../lib/delete-confirm';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { EpisodeDetail } from './EpisodeDetail'; import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats'; import type { AnimeEpisode } from '../../types/stats';
interface EpisodeListProps { interface EpisodeListProps {
episodes: AnimeEpisode[]; episodes: AnimeEpisode[];
onEpisodeDeleted?: () => void; onEpisodeDeleted?: () => void;
onOpenDetail?: (videoId: number) => void;
} }
export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: EpisodeListProps) { export function EpisodeList({
episodes: initialEpisodes,
onEpisodeDeleted,
onOpenDetail,
}: EpisodeListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null); const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
const [episodes, setEpisodes] = useState(initialEpisodes); const [episodes, setEpisodes] = useState(initialEpisodes);
@@ -65,92 +71,119 @@ export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: Epi
<th className="text-right py-2 pr-3 font-medium">Progress</th> <th className="text-right py-2 pr-3 font-medium">Progress</th>
<th className="text-right py-2 pr-3 font-medium">Watch Time</th> <th className="text-right py-2 pr-3 font-medium">Watch Time</th>
<th className="text-right py-2 pr-3 font-medium">Cards</th> <th className="text-right py-2 pr-3 font-medium">Cards</th>
<th className="text-right py-2 pr-3 font-medium">Lookup Rate</th>
<th className="text-right py-2 pr-3 font-medium">Last Watched</th> <th className="text-right py-2 pr-3 font-medium">Last Watched</th>
<th className="w-16 py-2 font-medium" /> <th className="w-28 py-2 font-medium" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.map((ep, idx) => ( {sorted.map((ep, idx) => {
<Fragment key={ep.videoId}> const lookupRate = buildLookupRateDisplay(
<tr ep.totalYomitanLookupCount,
onClick={() => ep.totalWordsSeen,
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" return (
> <Fragment key={ep.videoId}>
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6"> <tr
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'} onClick={() =>
</td> setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
<td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td> }
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]"> className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
{ep.canonicalTitle} >
</td> <td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
<td className="py-2 pr-3 text-right"> {expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
{ep.durationMs > 0 ? ( </td>
<span <td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
className={ <td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
ep.totalActiveMs >= ep.durationMs * 0.85 {ep.canonicalTitle}
? 'text-ctp-green' </td>
: ep.totalActiveMs >= ep.durationMs * 0.5 <td className="py-2 pr-3 text-right">
? 'text-ctp-peach' {ep.durationMs > 0 ? (
: 'text-ctp-overlay2' <span
} className={
> ep.totalActiveMs >= ep.durationMs * 0.85
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}% ? 'text-ctp-green'
</span> : ep.totalActiveMs >= ep.durationMs * 0.5
) : ( ? 'text-ctp-peach'
<span className="text-ctp-overlay2">{'\u2014'}</span> : 'text-ctp-overlay2'
)} }
</td> >
<td className="py-2 pr-3 text-right text-ctp-blue"> {Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
{formatDuration(ep.totalActiveMs)} </span>
</td> ) : (
<td className="py-2 pr-3 text-right text-ctp-green"> <span className="text-ctp-overlay2">{'\u2014'}</span>
{formatNumber(ep.totalCards)} )}
</td> </td>
<td className="py-2 pr-3 text-right text-ctp-overlay2"> <td className="py-2 pr-3 text-right text-ctp-blue">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'} {formatDuration(ep.totalActiveMs)}
</td> </td>
<td className="py-2 text-center w-16"> <td className="py-2 pr-3 text-right text-ctp-green">
<div className="flex items-center justify-center gap-1"> {formatNumber(ep.totalCards)}
<button </td>
type="button" <td className="py-2 pr-3 text-right">
onClick={(e) => { <div className="text-ctp-sapphire">{lookupRate?.shortValue ?? '\u2014'}</div>
e.stopPropagation(); <div className="text-[11px] text-ctp-overlay2">
void toggleWatched(ep.videoId, ep.watched); {lookupRate?.longValue ?? 'lookup rate'}
}} </div>
className={`w-5 h-5 rounded border transition-colors ${ </td>
ep.watched <td className="py-2 pr-3 text-right text-ctp-overlay2">
? 'bg-ctp-green border-ctp-green text-ctp-base' {ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0' </td>
}`} <td className="py-2 text-center w-28">
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'} <div className="flex items-center justify-center gap-1">
> {onOpenDetail ? (
{'\u2713'} <button
</button> type="button"
<button onClick={(e) => {
type="button" e.stopPropagation();
onClick={(e) => { onOpenDetail(ep.videoId);
e.stopPropagation(); }}
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle); className="px-2 py-1 rounded border border-ctp-surface2 text-[11px] text-ctp-blue hover:border-ctp-blue/50 hover:bg-ctp-blue/10 transition-colors"
}} title="Open episode details"
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center" >
title="Delete episode" Details
> </button>
{'\u2715'} ) : null}
</button> <button
</div> type="button"
</td> onClick={(e) => {
</tr> e.stopPropagation();
{expandedVideoId === ep.videoId && ( void toggleWatched(ep.videoId, ep.watched);
<tr> }}
<td colSpan={8} className="py-2"> className={`w-5 h-5 rounded border transition-colors ${
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} /> ep.watched
? 'bg-ctp-green border-ctp-green text-ctp-base'
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
}`}
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
>
{'\u2713'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
title="Delete episode"
>
{'\u2715'}
</button>
</div>
</td> </td>
</tr> </tr>
)} {expandedVideoId === ep.videoId && (
</Fragment> <tr>
))} <td colSpan={9} className="py-2">
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useRef, type KeyboardEvent } from 'react'; 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 { interface Tab {
id: TabId; id: TabId;
@@ -9,9 +9,8 @@ interface Tab {
const TABS: Tab[] = [ const TABS: Tab[] = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'anime', label: 'Anime' }, { id: 'anime', label: 'Library' },
{ id: 'trends', label: 'Trends' }, { id: 'trends', label: 'Trends' },
{ id: 'library', label: 'Library' },
{ id: 'vocabulary', label: 'Vocabulary' }, { id: 'vocabulary', label: 'Vocabulary' },
{ id: 'sessions', label: 'Sessions' }, { id: 'sessions', label: 'Sessions' },
]; ];

View File

@@ -0,0 +1,22 @@
interface TooltipProps {
text: string;
children: React.ReactNode;
}
export function Tooltip({ text, children }: TooltipProps) {
return (
<div className="group/tip relative">
{children}
<div
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
max-w-56 px-2.5 py-1.5 rounded-md text-xs text-ctp-text bg-ctp-surface2 border border-ctp-overlay0 shadow-lg
opacity-0 scale-95 transition-all duration-150
group-hover/tip:opacity-100 group-hover/tip:scale-100"
>
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-ctp-surface2" />
</div>
</div>
);
}

View File

@@ -4,7 +4,11 @@ import { formatDuration } from '../../lib/formatters';
import { MediaCard } from './MediaCard'; import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView'; import { MediaDetailView } from './MediaDetailView';
export function LibraryTab() { interface LibraryTabProps {
onNavigateToSession: (sessionId: number) => void;
}
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const { media, loading, error } = useMediaLibrary(); const { media, loading, error } = useMediaLibrary();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null); const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
@@ -18,7 +22,7 @@ export function LibraryTab() {
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0); const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
if (selectedVideoId !== null) { if (selectedVideoId !== null) {
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />; return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} onNavigateToSession={onNavigateToSession} />;
} }
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;

View File

@@ -1,20 +1,70 @@
import { useEffect, useState } from 'react';
import { useMediaDetail } from '../../hooks/useMediaDetail'; 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 { MediaHeader } from './MediaHeader';
import { MediaWatchChart } from './MediaWatchChart';
import { MediaSessionList } from './MediaSessionList'; import { MediaSessionList } from './MediaSessionList';
import type { SessionSummary } from '../../types/stats';
interface MediaDetailViewProps { interface MediaDetailViewProps {
videoId: number; videoId: number;
initialExpandedSessionId?: number | null;
onConsumeInitialExpandedSession?: () => void;
onBack: () => 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 { data, loading, error } = useMediaDetail(videoId);
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
useEffect(() => {
setLocalSessions(data?.sessions ?? null);
}, [data?.sessions]);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Media not found</div>; if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Media not found</div>;
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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<button <button
@@ -22,11 +72,17 @@ export function MediaDetailView({ videoId, onBack }: MediaDetailViewProps) {
onClick={onBack} onClick={onBack}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors" className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
> >
&larr; Back to Library &larr; {backLabel}
</button> </button>
<MediaHeader detail={data.detail} /> <MediaHeader detail={detail} />
<MediaWatchChart rollups={data.rollups} /> {deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList sessions={data.sessions} /> <MediaSessionList
sessions={sessions}
onDeleteSession={handleDeleteSession}
deletingSessionId={deletingSessionId}
initialExpandedSessionId={initialExpandedSessionId}
onConsumeInitialExpandedSession={onConsumeInitialExpandedSession}
/>
</div> </div>
); );
} }

View File

@@ -1,16 +1,44 @@
import { useState, useEffect } from 'react';
import { CoverImage } from './CoverImage'; import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters'; import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
import { getStatsClient } from '../../hooks/useStatsApi';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import type { MediaDetailData } from '../../types/stats'; import type { MediaDetailData } from '../../types/stats';
interface MediaHeaderProps { interface MediaHeaderProps {
detail: NonNullable<MediaDetailData['detail']>; detail: NonNullable<MediaDetailData['detail']>;
initialKnownWordsSummary?: {
totalUniqueWords: number;
knownWordCount: number;
} | null;
} }
export function MediaHeader({ detail }: MediaHeaderProps) { export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
const hitRate = const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null; detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs = const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0; 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 ( return (
<div className="flex gap-4"> <div className="flex gap-4">
@@ -32,12 +60,37 @@ export function MediaHeader({ detail }: MediaHeaderProps) {
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalWordsSeen)}</div> <div className="text-ctp-mauve font-medium">{formatNumber(detail.totalWordsSeen)}</div>
<div className="text-xs text-ctp-overlay2">words seen</div> <div className="text-xs text-ctp-overlay2">word occurrences</div>
</div> </div>
<div> <div>
<div className="text-ctp-peach font-medium">{formatPercent(hitRate)}</div> <div className="text-ctp-lavender font-medium">
<div className="text-xs text-ctp-overlay2">lookup rate</div> {formatNumber(detail.totalYomitanLookupCount)}
</div>
<div className="text-xs text-ctp-overlay2">Yomitan lookups</div>
</div> </div>
<div>
<div className="text-ctp-sapphire font-medium">
{lookupRate?.shortValue ?? '\u2014'}
</div>
<div className="text-xs text-ctp-overlay2">
{lookupRate?.longValue ?? 'lookup rate'}
</div>
</div>
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? (
<div>
<div className="text-ctp-green font-medium">
{formatNumber(knownWordsSummary.knownWordCount)} / {formatNumber(knownWordsSummary.totalUniqueWords)}
</div>
<div className="text-xs text-ctp-overlay2">
known unique words ({Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)}%)
</div>
</div>
) : (
<div>
<div className="text-ctp-peach font-medium">{formatPercent(knownTokenRate)}</div>
<div className="text-xs text-ctp-overlay2">known token match rate</div>
</div>
)}
<div> <div>
<div className="text-ctp-text font-medium">{detail.totalSessions}</div> <div className="text-ctp-text font-medium">{detail.totalSessions}</div>
<div className="text-xs text-ctp-overlay2">sessions</div> <div className="text-xs text-ctp-overlay2">sessions</div>

View File

@@ -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'; import type { SessionSummary } from '../../types/stats';
interface MediaSessionListProps { interface MediaSessionListProps {
sessions: SessionSummary[]; 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<number | null>(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) { if (sessions.length === 0) {
return <div className="text-sm text-ctp-overlay2">No sessions recorded</div>; return <div className="text-sm text-ctp-overlay2">No sessions recorded</div>;
} }
@@ -14,25 +41,22 @@ export function MediaSessionList({ sessions }: MediaSessionListProps) {
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold text-ctp-text">Session History</h3> <h3 className="text-sm font-semibold text-ctp-text">Session History</h3>
{sessions.map((s) => ( {sessions.map((s) => (
<div <div key={s.sessionId}>
key={s.sessionId} <SessionRow
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center justify-between" session={s}
> isExpanded={expandedId === s.sessionId}
<div className="min-w-0"> detailsId={`media-session-details-${s.sessionId}`}
<div className="text-sm text-ctp-text"> onToggle={() =>
{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 ? (
<div id={`media-session-details-${s.sessionId}`}>
<SessionDetail session={s} />
</div> </div>
</div> ) : null}
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(s.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { useOverview } from '../../hooks/useOverview'; import { useOverview } from '../../hooks/useOverview';
import { useStreakCalendar } from '../../hooks/useStreakCalendar'; import { useStreakCalendar } from '../../hooks/useStreakCalendar';
import { HeroStats } from './HeroStats'; import { HeroStats } from './HeroStats';
@@ -6,14 +7,113 @@ import { RecentSessions } from './RecentSessions';
import { TrendChart } from '../trends/TrendChart'; import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data'; import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { formatNumber } from '../../lib/formatters'; 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 { interface OverviewTabProps {
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void; onNavigateToSession: (sessionId: number) => void;
} }
export function OverviewTab({ onNavigateToSession }: OverviewTabProps) { export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
const { data, sessions, loading, error } = useOverview(); const { data, sessions, setSessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90); const { calendar, loading: calLoading } = useStreakCalendar(90);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<number>>(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 <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
@@ -21,7 +121,7 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
const summary = buildOverviewSummary(data); const summary = buildOverviewSummary(data);
const streakData = buildStreakCalendar(calendar); const streakData = buildStreakCalendar(calendar);
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.totalSessions > 0; const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -40,7 +140,7 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3> <h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
<p className="mt-1 mb-3 text-xs text-ctp-overlay2"> <p className="mt-1 mb-3 text-xs text-ctp-overlay2">
Today cards/episodes are daily values. Lifetime totals are sourced from summary tables. Lifetime totals sourced from summary tables.
</p> </p>
{showTrackedCardNote && ( {showTrackedCardNote && (
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0"> <div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
@@ -48,57 +148,131 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
appear here. appear here.
</div> </div>
)} )}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <Tooltip text="Total immersion sessions recorded across all time">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
Lifetime Sessions <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
{formatNumber(summary.totalSessions)}
</div>
</div> </div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender"> </Tooltip>
{formatNumber(summary.totalSessions)} <Tooltip text="Total active watch time across all sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
{summary.allTimeMinutes < 60
? `${summary.allTimeMinutes}m`
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
</div>
</div> </div>
</div> </Tooltip>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <Tooltip text="Number of distinct days with at least one session">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Today</div> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-teal"> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
{formatNumber(summary.episodesToday)} <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
{formatNumber(summary.activeDays)}
</div>
</div> </div>
</div> </Tooltip>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <Tooltip text="Average active watch time per session in minutes">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Hours</div> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve"> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
{formatNumber(summary.allTimeHours)} <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
{formatNumber(summary.averageSessionMinutes)}
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
</div>
</div> </div>
</div> </Tooltip>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <Tooltip text="Total unique episodes (videos) watched across all anime">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Days</div> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach"> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
{formatNumber(summary.activeDays)} <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</div>
</div> </div>
</div> </Tooltip>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <Tooltip text="Number of anime series fully completed">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Cards</div> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green"> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
{formatNumber(summary.totalTrackedCards)} <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)}
</div>
</div> </div>
</div> </Tooltip>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2"> <div className="rounded-lg bg-ctp-surface1/60 p-3">
Lifetime Episodes <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(summary.totalTrackedCards)}
</div>
</div> </div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue"> </Tooltip>
{formatNumber(summary.totalEpisodesWatched)} <Tooltip text="Percentage of dictionary lookups that matched a known word">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
{summary.lookupRate != null ? `${summary.lookupRate}%` : '—'}
</div>
</div> </div>
</div> </Tooltip>
<div className="rounded-lg bg-ctp-surface1/60 p-3"> <Tooltip text="Total word occurrences encountered in today's sessions">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Anime</div> <div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire"> <div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
{formatNumber(summary.totalAnimeCompleted)} <div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
{formatNumber(summary.todayWords)}
</div>
</div> </div>
</div> </Tooltip>
<Tooltip text="Unique words seen for the first time today">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
New Words Today
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
{formatNumber(summary.newWordsToday)}
</div>
</div>
</Tooltip>
<Tooltip text="Unique words seen for the first time this week">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
{formatNumber(summary.newWordsThisWeek)}
</div>
</div>
</Tooltip>
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
<>
<Tooltip text="Words matched against your known-words list out of all unique words seen">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Known Words
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(knownWordsSummary.knownWordCount)}
<span className="text-sm text-ctp-overlay2 ml-1">
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
</span>
</div>
</div>
</Tooltip>
</>
)}
</div> </div>
</div> </div>
<RecentSessions sessions={sessions} onNavigateToSession={onNavigateToSession} /> {deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<RecentSessions
sessions={sessions}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={handleDeleteSession}
onDeleteDayGroup={handleDeleteDayGroup}
onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds}
/>
</div> </div>
); );
} }

View File

@@ -6,11 +6,18 @@ import {
formatSessionDayLabel, formatSessionDayLabel,
} from '../../lib/formatters'; } from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client'; 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'; import type { SessionSummary } from '../../types/stats';
interface RecentSessionsProps { interface RecentSessionsProps {
sessions: SessionSummary[]; sessions: SessionSummary[];
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void; onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
deletingIds: Set<number>;
} }
interface AnimeGroup { interface AnimeGroup {
@@ -52,10 +59,11 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
: `session-${session.sessionId}`; : `session-${session.sessionId}`;
const existing = map.get(key); const existing = map.get(key);
const displayWordCount = getSessionDisplayWordCount(session);
if (existing) { if (existing) {
existing.sessions.push(session); existing.sessions.push(session);
existing.totalCards += session.cardsMined; existing.totalCards += session.cardsMined;
existing.totalWords += session.wordsSeen; existing.totalWords += displayWordCount;
existing.totalActiveMs += session.activeWatchedMs; existing.totalActiveMs += session.activeWatchedMs;
} else { } else {
map.set(key, { map.set(key, {
@@ -65,7 +73,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
videoId: session.videoId, videoId: session.videoId,
sessions: [session], sessions: [session],
totalCards: session.cardsMined, totalCards: session.cardsMined,
totalWords: session.wordsSeen, totalWords: displayWordCount,
totalActiveMs: session.activeWatchedMs, totalActiveMs: session.activeWatchedMs,
}); });
} }
@@ -111,61 +119,104 @@ function CoverThumbnail({
function SessionItem({ function SessionItem({
session, session,
onNavigateToMediaDetail,
onNavigateToSession, onNavigateToSession,
onDelete,
deleteDisabled,
}: { }: {
session: SessionSummary; session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void; onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
}) { }) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
return ( return (
<button <div className="relative group">
type="button" <button
onClick={() => onNavigateToSession(session.sessionId)} type="button"
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer" onClick={() => {
> if (navigationTarget.type === 'media-detail') {
<CoverThumbnail onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
animeId={session.animeId} return;
videoId={session.videoId} }
title={session.canonicalTitle ?? 'Unknown'} onNavigateToSession(navigationTarget.sessionId);
/> }}
<div className="min-w-0 flex-1"> className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer"
<div className="text-sm font-medium text-ctp-text truncate"> >
{session.canonicalTitle ?? 'Unknown Media'} <CoverThumbnail
</div> animeId={session.animeId}
<div className="text-xs text-ctp-overlay2"> videoId={session.videoId}
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '} title={session.canonicalTitle ?? 'Unknown'}
active />
</div> <div className="min-w-0 flex-1">
</div> <div className="text-sm font-medium text-ctp-text truncate">
<div className="flex gap-4 text-xs text-center shrink-0"> {session.canonicalTitle ?? 'Unknown Media'}
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div> </div>
<div className="text-ctp-overlay2">cards</div> <div className="text-xs text-ctp-overlay2">
</div> {formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
<div> active
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
</div> </div>
<div className="text-ctp-overlay2">words</div>
</div> </div>
</div> <div className="flex gap-4 text-xs text-center shrink-0">
</button> <div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(displayWordCount)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</button>
<button
type="button"
onClick={onDelete}
disabled={deleteDisabled}
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
); );
} }
function AnimeGroupRow({ function AnimeGroupRow({
group, group,
onNavigateToMediaDetail,
onNavigateToSession, onNavigateToSession,
onDeleteSession,
onDeleteAnimeGroup,
deletingIds,
}: { }: {
group: AnimeGroup; group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void; onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
if (group.sessions.length === 1) { if (group.sessions.length === 1) {
const s = group.sessions[0]!;
return ( return (
<SessionItem session={group.sessions[0]!} onNavigateToSession={onNavigateToSession} /> <SessionItem
session={s}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
/>
); );
} }
@@ -174,91 +225,141 @@ function AnimeGroupRow({
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`; const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
return ( return (
<div> <div className="group/anime">
<button <div className="relative">
type="button" <button
onClick={() => setExpanded((value) => !value)} type="button"
aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}
aria-controls={disclosureId} aria-expanded={expanded}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left" aria-controls={disclosureId}
> className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
<CoverThumbnail
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
aria-hidden="true"
> >
{'\u25B8'} <CoverThumbnail
</div> animeId={group.animeId}
</button> videoId={mostRecentSession.videoId}
title={displayTitle}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
aria-hidden="true"
>
{'\u25B8'}
</div>
</button>
<button
type="button"
onClick={() => onDeleteAnimeGroup(group)}
disabled={groupDeleting}
aria-label={`Delete all sessions for ${displayTitle}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/anime:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title={`Delete all sessions for ${displayTitle}`}
>
{groupDeleting ? '\u2026' : '\u2715'}
</button>
</div>
{expanded && ( {expanded && (
<div id={disclosureId} role="region" aria-label={`${displayTitle} sessions`} className="ml-6 mt-1 space-y-1"> <div
{group.sessions.map((s) => ( id={disclosureId}
<button role="region"
type="button" aria-label={`${displayTitle} sessions`}
key={s.sessionId} className="ml-6 mt-1 space-y-1"
onClick={() => onNavigateToSession(s.sessionId)} >
className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer" {group.sessions.map((s) => {
> const navigationTarget = getSessionNavigationTarget(s);
<CoverThumbnail
animeId={s.animeId} return (
videoId={s.videoId} <div key={s.sessionId} className="relative group/nested">
title={s.canonicalTitle ?? 'Unknown'} <button
/> type="button"
<div className="min-w-0 flex-1"> onClick={() => {
<div className="text-sm font-medium text-ctp-subtext1 truncate"> if (navigationTarget.type === 'media-detail') {
{s.canonicalTitle ?? 'Unknown Media'} onNavigateToMediaDetail(
</div> navigationTarget.videoId,
<div className="text-xs text-ctp-overlay2"> navigationTarget.sessionId,
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active );
</div> return;
</div> }
<div className="flex gap-4 text-xs text-center shrink-0"> onNavigateToSession(navigationTarget.sessionId);
<div> }}
<div className="text-ctp-green font-medium font-mono tabular-nums"> className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 pr-10 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer"
{formatNumber(s.cardsMined)} >
<CoverThumbnail
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
{s.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)}{' '}
active
</div>
</div> </div>
<div className="text-ctp-overlay2">cards</div> <div className="flex gap-4 text-xs text-center shrink-0">
</div> <div>
<div> <div className="text-ctp-green font-medium font-mono tabular-nums">
<div className="text-ctp-mauve font-medium font-mono tabular-nums"> {formatNumber(s.cardsMined)}
{formatNumber(s.wordsSeen)} </div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(getSessionDisplayWordCount(s))}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div> </div>
<div className="text-ctp-overlay2">words</div> </button>
</div> <button
type="button"
onClick={() => onDeleteSession(s)}
disabled={deletingIds.has(s.sessionId)}
aria-label={`Delete session ${s.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/nested:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div> </div>
</button> );
))} })}
</div> </div>
)} )}
</div> </div>
); );
} }
export function RecentSessions({ sessions, onNavigateToSession }: RecentSessionsProps) { export function RecentSessions({
sessions,
onNavigateToMediaDetail,
onNavigateToSession,
onDeleteSession,
onDeleteDayGroup,
onDeleteAnimeGroup,
deletingIds,
}: RecentSessionsProps) {
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4"> <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -268,22 +369,42 @@ export function RecentSessions({ sessions, onNavigateToSession }: RecentSessions
} }
const groups = groupSessionsByDay(sessions); const groups = groupSessionsByDay(sessions);
const anyDeleting = deletingIds.size > 0;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => { {Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const animeGroups = groupSessionsByAnime(daySessions); const animeGroups = groupSessionsByAnime(daySessions);
const groupDeleting = daySessions.some((s) => deletingIds.has(s.sessionId));
return ( return (
<div key={dayLabel}> <div key={dayLabel} className="group/day">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0"> <h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel} {dayLabel}
</h3> </h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" /> <div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
<button
type="button"
onClick={() => onDeleteDayGroup(dayLabel, daySessions)}
disabled={anyDeleting}
aria-label={`Delete all sessions from ${dayLabel}`}
className="shrink-0 text-xs text-transparent hover:text-ctp-red transition-colors opacity-0 group-hover/day:opacity-100 focus:opacity-100 disabled:opacity-40 disabled:cursor-not-allowed"
title={`Delete all sessions from ${dayLabel}`}
>
{groupDeleting ? '\u2026' : '\u2715'}
</button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{animeGroups.map((group) => ( {animeGroups.map((group) => (
<AnimeGroupRow key={group.key} group={group} onNavigateToSession={onNavigateToSession} /> <AnimeGroupRow
key={group.key}
group={group}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds}
/>
))} ))}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { import {
ComposedChart, AreaChart,
Area, Area,
LineChart,
Line, Line,
XAxis, XAxis,
YAxis, YAxis,
@@ -8,15 +9,18 @@ import {
ResponsiveContainer, ResponsiveContainer,
ReferenceArea, ReferenceArea,
ReferenceLine, ReferenceLine,
CartesianGrid,
} from 'recharts'; } from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions'; import { useSessionDetail } from '../../hooks/useSessions';
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme'; 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 { EventType } from '../../types/stats';
import type { SessionEvent } from '../../types/stats'; import type { SessionEvent, SessionSummary } from '../../types/stats';
interface SessionDetailProps { interface SessionDetailProps {
sessionId: number; session: SessionSummary;
cardsMined: number;
} }
const tooltipStyle = { const tooltipStyle = {
@@ -35,6 +39,30 @@ function formatTime(ms: number): string {
}); });
} }
/** Build a lookup: linesSeen → knownWordsSeen */
function buildKnownWordsLookup(
knownWordsTimeline: KnownWordsTimelinePoint[],
): Map<number, number> {
const map = new Map<number, number>();
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<number, number>, 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 { interface PauseRegion {
startMs: number; startMs: number;
endMs: number; endMs: number;
@@ -55,223 +83,524 @@ function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
return regions; return regions;
} }
interface ChartPoint { interface RatioChartPoint {
tsMs: number; tsMs: number;
activity: number; knownPct: number;
unknownPct: number;
knownWords: number;
unknownWords: number;
totalWords: number; totalWords: number;
paused: boolean;
} }
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) { interface FallbackChartPoint {
const { timeline, events, loading, error } = useSessionDetail(sessionId); 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 <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>; if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>; if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
const sorted = [...timeline].reverse(); const sorted = [...timeline].reverse();
const pauseRegions = buildPauseRegions(events); const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
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 cardEvents = events.filter((e) => e.eventType === EventType.CARD_MINED); const cardEvents = events.filter((e) => e.eventType === EventType.CARD_MINED);
const seekEvents = events.filter( const seekEvents = events.filter(
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD, (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 pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length; const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length; const cardEventCount = cardEvents.length;
const pauseRegions = buildPauseRegions(events);
const maxActivity = Math.max(...chartData.map((d) => d.activity), 1); if (hasKnownWords) {
const yMax = Math.ceil(maxActivity * 1.3); return (
<RatioView
const tsMin = chartData.length > 0 ? chartData[0]!.tsMs : 0; sorted={sorted}
const tsMax = chartData.length > 0 ? chartData[chartData.length - 1]!.tsMs : 0; knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
/>
);
}
return ( return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3"> <FallbackView
{chartData.length > 0 && ( sorted={sorted}
<ResponsiveContainer width="100%" height={150}> cardEvents={cardEvents}
<ComposedChart data={chartData} barCategoryGap={0} barGap={0}> yomitanLookupEvents={yomitanLookupEvents}
<defs> pauseRegions={pauseRegions}
<linearGradient id={`actGrad-${sessionId}`} x1="0" y1="0" x2="0" y2="1"> pauseCount={pauseCount}
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.5} /> seekCount={seekCount}
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.05} /> cardEventCount={cardEventCount}
</linearGradient> lookupRate={lookupRate}
</defs> session={session}
<XAxis />
dataKey="tsMs" );
type="number" }
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={24}
domain={[0, yMax]}
allowDecimals={false}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
allowDecimals={false}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(value: number, name: string) => {
if (name === 'New words') return [`${value}`, 'New words'];
if (name === 'Total words') return [`${value}`, 'Total words'];
return [value, name];
}}
/>
{/* Pause shaded regions */} /* ── Ratio View (primary design) ────────────────────────────────── */
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="left"
x1={r.startMs}
x2={r.endMs}
y1={0}
y2={yMax}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
{/* Seek markers */} function RatioView({
{seekEvents.map((e, i) => ( sorted,
<ReferenceLine knownWordsMap,
key={`seek-${i}`} cardEvents,
yAxisId="left" yomitanLookupEvents,
x={e.tsMs} pauseRegions,
stroke="#91d7e3" pauseCount,
strokeWidth={1} seekCount,
strokeDasharray="3 4" cardEventCount,
strokeOpacity={0.5} lookupRate,
/> session,
))} }: {
sorted: TimelineEntry[];
knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
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 */} if (chartData.length === 0) {
{cardEvents.map((e, i) => ( return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
<ReferenceLine }
key={`card-${i}`}
yAxisId="left"
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '⛏',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}}
/>
))}
<Area const tsMin = chartData[0]!.tsMs;
yAxisId="left" const tsMax = chartData[chartData.length - 1]!.tsMs;
dataKey="activity" const finalTotal = chartData[chartData.length - 1]!.totalWords;
stroke="#c6a0f6"
strokeWidth={1.5}
fill={`url(#actGrad-${sessionId})`}
name="New words"
dot={false}
activeDot={{ r: 3, fill: '#c6a0f6', stroke: '#1e2030', strokeWidth: 1 }}
type="monotone"
isAnimationActive={false}
/>
<Line
yAxisId="right"
dataKey="totalWords"
stroke="#8aadf4"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
name="Total words"
type="monotone"
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
)}
<div className="flex flex-wrap items-center gap-4 text-[11px]"> const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords }));
<span className="flex items-center gap-1.5">
<span return (
className="inline-block w-3 h-2 rounded-sm" <div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
style={{ {/* ── Top: Percentage area chart ── */}
background: <ResponsiveContainer width="100%" height={130}>
'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))', <AreaChart data={chartData}>
}} <defs>
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
<stop offset="100%" stopColor="#a6da95" stopOpacity={0.15} />
</linearGradient>
<linearGradient id={`unknownGrad-${session.sessionId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.25} />
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.08} />
</linearGradient>
</defs>
<CartesianGrid
horizontal
vertical={false}
stroke="#494d64"
strokeDasharray="4 4"
strokeOpacity={0.4}
/> />
<span className="text-ctp-overlay2">New words</span>
</span> <XAxis
<span className="flex items-center gap-1.5"> dataKey="tsMs"
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#8aadf4' }} /> type="number"
<span className="text-ctp-overlay2">Total words</span> domain={[tsMin, tsMax]}
</span> tick={{ fontSize: 9, fill: CHART_THEME.tick }}
{pauseCount > 0 && ( axisLine={false}
<span className="flex items-center gap-1.5"> tickLine={false}
<span tickFormatter={formatTime}
className="inline-block w-3 h-2 rounded-sm" interval="preserveStartEnd"
style={{ />
background: 'rgba(245,169,127,0.2)', <YAxis
border: '1px solid rgba(245,169,127,0.5)', yAxisId="pct"
orientation="right"
domain={[0, 100]}
ticks={[0, 50, 100]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
tickFormatter={(v: number) => `${v}%`}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
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) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="pct"
x1={r.startMs}
x2={r.endMs}
y1={0}
y2={100}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
{/* Card mine markers */}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '\u26CF',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}} }}
/> />
<span className="text-ctp-overlay2"> ))}
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
</span> {/* Yomitan lookup markers */}
</span> {yomitanLookupEvents.map((e, i) => (
)} <ReferenceLine
{seekCount > 0 && ( key={`yomitan-${i}`}
<span className="flex items-center gap-1.5"> yAxisId="pct"
<span x={e.tsMs}
className="inline-block w-3 h-0.5 rounded" stroke="#b7bdf8"
style={{ background: '#91d7e3', opacity: 0.7 }} strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/> />
<span className="text-ctp-overlay2"> ))}
{seekCount} seek{seekCount !== 1 ? 's' : ''}
</span> <Area
</span> yAxisId="pct"
)} dataKey="knownPct"
<span className="flex items-center gap-1.5"> stackId="ratio"
<span className="text-[12px]"></span> stroke="#a6da95"
<span className="text-ctp-green"> strokeWidth={1.5}
{Math.max(cardEventCount, cardsMined)} card fill={`url(#knownGrad-${session.sessionId})`}
{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined name="Known"
</span> type="monotone"
dot={false}
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
isAnimationActive={false}
/>
<Area
yAxisId="pct"
dataKey="unknownPct"
stackId="ratio"
stroke="#c6a0f6"
strokeWidth={0}
fill={`url(#unknownGrad-${session.sessionId})`}
name="Unknown"
type="monotone"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
{/* ── Bottom: Word accumulation sparkline ── */}
<div className="flex items-center gap-2 border-t border-ctp-surface1 pt-1">
<span className="text-[9px] text-ctp-overlay0 whitespace-nowrap">total words</span>
<div className="flex-1 h-[28px]">
<ResponsiveContainer width="100%" height={28}>
<LineChart data={sparkData}>
<XAxis dataKey="tsMs" type="number" domain={[tsMin, tsMax]} hide />
<YAxis hide />
<Line
dataKey="totalWords"
stroke="#8aadf4"
strokeWidth={1.5}
strokeOpacity={0.8}
dot={false}
type="monotone"
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<span className="text-[10px] text-ctp-blue font-semibold whitespace-nowrap tabular-nums">
{finalTotal.toLocaleString()}
</span> </span>
</div> </div>
{/* ── Stats bar ── */}
<StatsBar
hasKnownWords
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
/>
</div>
);
}
/* ── 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<typeof buildLookupRateDisplay>;
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 <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
}
const tsMin = chartData[0]!.tsMs;
const tsMax = chartData[chartData.length - 1]!.tsMs;
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
<ResponsiveContainer width="100%" height={130}>
<LineChart data={chartData}>
<XAxis
dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
allowDecimals={false}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total words']}
/>
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
x1={r.startMs}
x2={r.endMs}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '\u26CF',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}}
/>
))}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
))}
<Line
dataKey="totalWords"
stroke="#8aadf4"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
name="Total words"
type="monotone"
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
<StatsBar
hasKnownWords={false}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
/>
</div>
);
}
/* ── Stats Bar ──────────────────────────────────────────────────── */
function StatsBar({
hasKnownWords,
pauseCount,
seekCount,
cardEventCount,
session,
lookupRate,
}: {
hasKnownWords: boolean;
pauseCount: number;
seekCount: number;
cardEventCount: number;
session: SessionSummary;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
}) {
return (
<div className="flex flex-wrap items-center gap-4 text-[11px] pt-1">
{/* Group 1: Legend */}
{hasKnownWords && (
<>
<span className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 rounded-sm"
style={{ background: 'rgba(166,218,149,0.4)', border: '1px solid #a6da95' }}
/>
<span className="text-ctp-overlay2">Known</span>
</span>
<span className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 rounded-sm"
style={{ background: 'rgba(198,160,246,0.2)', border: '1px solid #c6a0f6' }}
/>
<span className="text-ctp-overlay2">Unknown</span>
</span>
<span className="text-ctp-surface2">|</span>
</>
)}
{/* Group 2: Playback stats */}
{pauseCount > 0 && (
<span className="text-ctp-overlay2">
<span className="text-ctp-peach">{pauseCount}</span> pause
{pauseCount !== 1 ? 's' : ''}
</span>
)}
{seekCount > 0 && (
<span className="text-ctp-overlay2">
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
</span>
)}
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
{/* Group 3: Learning events */}
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-0.5 rounded"
style={{ background: '#b7bdf8', opacity: 0.8 }}
/>
<span className="text-ctp-overlay2">
{session.yomitanLookupCount} Yomitan lookup
{session.yomitanLookupCount !== 1 ? 's' : ''}
</span>
</span>
{lookupRate && (
<span className="text-ctp-overlay2">
lookup rate: <span className="text-ctp-sapphire">{lookupRate.shortValue}</span>{' '}
<span className="text-ctp-subtext0">({lookupRate.longValue})</span>
</span>
)}
<span className="flex items-center gap-1.5">
<span className="text-[12px]">{'\u26CF'}</span>
<span className="text-ctp-green">
{Math.max(cardEventCount, session.cardsMined)} card
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
</span>
</span>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client'; import { BASE_URL } from '../../lib/api-client';
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters'; import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import type { SessionSummary } from '../../types/stats'; import type { SessionSummary } from '../../types/stats';
interface SessionRowProps { interface SessionRowProps {
@@ -56,15 +57,17 @@ export function SessionRow({
onDelete, onDelete,
deleteDisabled = false, deleteDisabled = false,
}: SessionRowProps) { }: SessionRowProps) {
const displayWordCount = getSessionDisplayWordCount(session);
return ( return (
<div className="relative group"> <div className="relative group">
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-controls={detailsId} aria-controls={detailsId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left" className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
> >
<CoverThumbnail <CoverThumbnail
animeId={session.animeId} animeId={session.animeId}
videoId={session.videoId} videoId={session.videoId}
@@ -88,7 +91,7 @@ export function SessionRow({
</div> </div>
<div> <div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums"> <div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)} {formatNumber(displayWordCount)}
</div> </div>
<div className="text-ctp-overlay2">words</div> <div className="text-ctp-overlay2">words</div>
</div> </div>

View File

@@ -126,7 +126,7 @@ export function SessionsTab({ initialSessionId, onClearInitialSession }: Session
/> />
{expandedId === s.sessionId && ( {expandedId === s.sessionId && (
<div id={detailsId}> <div id={detailsId}>
<SessionDetail sessionId={s.sessionId} cardsMined={s.cardsMined} /> <SessionDetail session={s} />
</div> </div>
)} )}
</div> </div>

View File

@@ -2,113 +2,12 @@ import { useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends'; import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector'; import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart'; import { TrendChart } from './TrendChart';
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart'; import { StackedTrendChart } from './StackedTrendChart';
import { import {
buildAnimeVisibilityOptions, buildAnimeVisibilityOptions,
filterHiddenAnimeData, filterHiddenAnimeData,
pruneHiddenAnime, pruneHiddenAnime,
} from './anime-visibility'; } 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<string, Map<number, number>>();
const allDays = new Set<number>();
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<string, Map<number, number>>();
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<string, Map<number, Set<number | null>>>();
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 }) { function SectionHeader({ children }: { children: React.ReactNode }) {
return ( return (
@@ -201,41 +100,34 @@ export function TrendsTab() {
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
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([ const animeTitles = buildAnimeVisibilityOptions([
episodesPerAnime, data.animePerDay.episodes,
watchTimePerAnime, data.animePerDay.watchTime,
cardsPerAnime, data.animePerDay.cards,
wordsPerAnime, data.animePerDay.words,
animeProgress, data.animePerDay.lookups,
cardsProgress, data.animeCumulative.episodes,
wordsProgress, data.animeCumulative.cards,
data.animeCumulative.words,
data.animeCumulative.watchTime,
]); ]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles); const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
const filteredEpisodesPerAnime = filterHiddenAnimeData(episodesPerAnime, activeHiddenAnime); const filteredEpisodesPerAnime = filterHiddenAnimeData(data.animePerDay.episodes, activeHiddenAnime);
const filteredWatchTimePerAnime = filterHiddenAnimeData(watchTimePerAnime, activeHiddenAnime); const filteredWatchTimePerAnime = filterHiddenAnimeData(data.animePerDay.watchTime, activeHiddenAnime);
const filteredCardsPerAnime = filterHiddenAnimeData(cardsPerAnime, activeHiddenAnime); const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(wordsPerAnime, activeHiddenAnime); const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredAnimeProgress = filterHiddenAnimeData(animeProgress, activeHiddenAnime); const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(cardsProgress, activeHiddenAnime); const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
const filteredWordsProgress = filterHiddenAnimeData(wordsProgress, activeHiddenAnime); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -245,23 +137,27 @@ export function TrendsTab() {
onRangeChange={setRange} onRangeChange={setRange}
onGroupByChange={setGroupBy} onGroupByChange={setGroupBy}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity</SectionHeader> <SectionHeader>Activity</SectionHeader>
<TrendChart <TrendChart
title="Watch Time (min)" title="Watch Time (min)"
data={dashboard.watchTime} data={data.activity.watchTime}
color="#8aadf4" color="#8aadf4"
type="bar" type="bar"
/> />
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" /> <TrendChart title="Cards Mined" data={data.activity.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" /> <TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" /> <TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<TrendChart
title="Avg Session (min)" <SectionHeader>Period Trends</SectionHeader>
data={dashboard.averageSessionMinutes} <TrendChart title="Watch Time (min)" data={data.progress.watchTime} color="#8aadf4" type="line" />
color="#f5bde6" <TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
type="line" <TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
/> <TrendChart title="New Words Seen" data={data.progress.newWords} color="#c6a0f6" type="line" />
<TrendChart title="Cards Mined" data={data.progress.cards} color="#a6da95" type="line" />
<TrendChart title="Episodes Watched" data={data.progress.episodes} color="#91d7e3" type="line" />
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart title="Lookups / 100 Words" data={data.ratios.lookupsPerHundred} color="#f5a97f" type="line" />
<SectionHeader>Anime Per Day</SectionHeader> <SectionHeader>Anime Per Day</SectionHeader>
<AnimeVisibilityFilter <AnimeVisibilityFilter
@@ -285,8 +181,11 @@ export function TrendsTab() {
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} /> <StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} /> <StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} /> <StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
<StackedTrendChart title="Lookups/100w per Anime" data={filteredLookupsPerHundredPerAnime} />
<SectionHeader>Anime Cumulative</SectionHeader> <SectionHeader>Anime Cumulative</SectionHeader>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} /> <StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} /> <StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} /> <StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
@@ -294,13 +193,13 @@ export function TrendsTab() {
<SectionHeader>Patterns</SectionHeader> <SectionHeader>Patterns</SectionHeader>
<TrendChart <TrendChart
title="Watch Time by Day of Week (min)" title="Watch Time by Day of Week (min)"
data={watchByDow} data={data.patterns.watchTimeByDayOfWeek}
color="#8aadf4" color="#8aadf4"
type="bar" type="bar"
/> />
<TrendChart <TrendChart
title="Watch Time by Hour (min)" title="Watch Time by Hour (min)"
data={watchByHour} data={data.patterns.watchTimeByHour}
color="#c6a0f6" color="#c6a0f6"
type="bar" type="bar"
/> />

View File

@@ -65,6 +65,13 @@ export function VocabularyTab({
const summary = buildVocabularySummary(filteredWords, kanji); 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 => { const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId); onOpenWordDetail?.(entry.wordId);
}; };
@@ -80,16 +87,23 @@ export function VocabularyTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3"> <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard <StatCard
label="Unique Words" label="Unique Words"
value={formatNumber(summary.uniqueWords)} value={formatNumber(summary.uniqueWords)}
color="text-ctp-blue" color="text-ctp-blue"
/> />
{knownWords.size > 0 && (
<StatCard
label="Known Words"
value={`${formatNumber(knownWordCount)} (${summary.uniqueWords > 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`}
color="text-ctp-green"
/>
)}
<StatCard <StatCard
label="Unique Kanji" label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)} value={formatNumber(summary.uniqueKanji)}
color="text-ctp-green" color="text-ctp-teal"
/> />
<StatCard <StatCard
label="New This Week" label="New This Week"

View File

@@ -135,6 +135,10 @@ export function WordDetailPanel({
occ: VocabularyOccurrenceEntry, occ: VocabularyOccurrenceEntry,
mode: 'word' | 'sentence' | 'audio', mode: 'word' | 'sentence' | 'audio',
) => { ) => {
if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) {
return;
}
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`; const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } })); setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try { try {
@@ -358,60 +362,75 @@ export function WordDetailPanel({
{formatNumber(occ.occurrenceCount)} in line {formatNumber(occ.occurrenceCount)} in line
</div> </div>
</div> </div>
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1"> <div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
<span> <span>
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '} {formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
· session {occ.sessionId} · session {occ.sessionId}
</span> </span>
{occ.sourcePath && {(() => {
occ.segmentStartMs != null && const canMine =
occ.segmentEndMs != null && !!occ.sourcePath &&
(() => { occ.segmentStartMs != null &&
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; occ.segmentEndMs != null;
const wordStatus = mineStatus[`${baseKey}-word`]; const unavailableReason = canMine
const sentenceStatus = mineStatus[`${baseKey}-sentence`]; ? null
const audioStatus = mineStatus[`${baseKey}-audio`]; : occ.sourcePath
return ( ? 'This line is missing segment timing.'
<> : 'This source has no local file path.';
<button const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
type="button" const wordStatus = mineStatus[`${baseKey}-word`];
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60" const sentenceStatus = mineStatus[`${baseKey}-sentence`];
disabled={wordStatus?.loading} const audioStatus = mineStatus[`${baseKey}-audio`];
onClick={() => void handleMine(occ, 'word')} return (
> <>
{wordStatus?.loading <button
? 'Mining...' type="button"
: wordStatus?.success title={unavailableReason ?? 'Mine this word from video clip'}
? 'Mined!' className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
disabled={wordStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Word'} : 'Mine Word'}
</button> </button>
<button <button
type="button" type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60" title={unavailableReason ?? 'Mine this sentence from video clip'}
disabled={sentenceStatus?.loading} className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleMine(occ, 'sentence')} disabled={sentenceStatus?.loading || !!unavailableReason}
> onClick={() => void handleMine(occ, 'sentence')}
{sentenceStatus?.loading >
? 'Mining...' {sentenceStatus?.loading
: sentenceStatus?.success ? 'Mining...'
? 'Mined!' : sentenceStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Sentence'} : 'Mine Sentence'}
</button> </button>
<button <button
type="button" type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60" title={unavailableReason ?? 'Mine this line as audio-only card'}
disabled={audioStatus?.loading} className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleMine(occ, 'audio')} disabled={audioStatus?.loading || !!unavailableReason}
> onClick={() => void handleMine(occ, 'audio')}
{audioStatus?.loading >
? 'Mining...' {audioStatus?.loading
: audioStatus?.success ? 'Mining...'
? 'Mined!' : audioStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Audio'} : 'Mine Audio'}
</button> </button>
</> </>
); );
})()} })()}
</div> </div>
{(() => { {(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;

View File

@@ -32,5 +32,5 @@ export function useOverview() {
}; };
}, []); }, []);
return { data, sessions, loading, error }; return { data, sessions, setSessions, loading, error };
} }

View File

@@ -34,9 +34,15 @@ export function useSessions(limit = 50) {
return { sessions, loading, error }; return { sessions, loading, error };
} }
export interface KnownWordsTimelinePoint {
linesSeen: number;
knownWordsSeen: number;
}
export function useSessionDetail(sessionId: number | null) { export function useSessionDetail(sessionId: number | null) {
const [timeline, setTimeline] = useState<SessionTimelinePoint[]>([]); const [timeline, setTimeline] = useState<SessionTimelinePoint[]>([]);
const [events, setEvents] = useState<SessionEvent[]>([]); const [events, setEvents] = useState<SessionEvent[]>([]);
const [knownWordsTimeline, setKnownWordsTimeline] = useState<KnownWordsTimelinePoint[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -46,6 +52,7 @@ export function useSessionDetail(sessionId: number | null) {
if (sessionId == null) { if (sessionId == null) {
setTimeline([]); setTimeline([]);
setEvents([]); setEvents([]);
setKnownWordsTimeline([]);
setLoading(false); setLoading(false);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -54,12 +61,18 @@ export function useSessionDetail(sessionId: number | null) {
setLoading(true); setLoading(true);
setTimeline([]); setTimeline([]);
setEvents([]); setEvents([]);
setKnownWordsTimeline([]);
const client = getStatsClient(); const client = getStatsClient();
Promise.all([client.getSessionTimeline(sessionId), client.getSessionEvents(sessionId)]) Promise.all([
.then(([nextTimeline, nextEvents]) => { client.getSessionTimeline(sessionId),
client.getSessionEvents(sessionId),
client.getSessionKnownWordsTimeline(sessionId),
])
.then(([nextTimeline, nextEvents, nextKnownWords]) => {
if (cancelled) return; if (cancelled) return;
setTimeline(nextTimeline); setTimeline(nextTimeline);
setEvents(nextEvents); setEvents(nextEvents);
setKnownWordsTimeline(nextKnownWords);
}) })
.catch((err) => { .catch((err) => {
if (cancelled) return; if (cancelled) return;
@@ -74,5 +87,5 @@ export function useSessionDetail(sessionId: number | null) {
}; };
}, [sessionId]); }, [sessionId]);
return { timeline, events, loading, error }; return { timeline, events, knownWordsTimeline, loading, error };
} }

View File

@@ -1,36 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi'; import { getStatsClient } from './useStatsApi';
import type { import type { TrendsDashboardData } from '../types/stats';
DailyRollup,
MonthlyRollup,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
SessionSummary,
AnimeLibraryItem,
} from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | 'all'; export type TimeRange = '7d' | '30d' | '90d' | 'all';
export type GroupBy = 'day' | 'month'; 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) { export function useTrends(range: TimeRange, groupBy: GroupBy) {
const [data, setData] = useState<TrendsData>({ const [data, setData] = useState<TrendsDashboardData | null>(null);
rollups: [],
episodesPerDay: [],
newAnimePerDay: [],
watchTimePerAnime: [],
sessions: [],
animeLibrary: [],
});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -38,51 +14,12 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(null); setError(null);
const client = getStatsClient(); getStatsClient()
const limitMap: Record<TimeRange, number> = { '7d': 7, '30d': 30, '90d': 90, all: 365 }; .getTrendsDashboard(range, groupBy)
const limit = limitMap[range]; .then((nextData) => {
const monthlyLimit = Math.max(1, Math.ceil(limit / 30)); if (cancelled) return;
const sessionsLimitMap: Record<TimeRange, number> = { setData(nextData);
'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,
});
},
)
.catch((err) => { .catch((err) => {
if (cancelled) return; if (cancelled) return;
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));

View File

@@ -65,3 +65,55 @@ test('deleteSession throws when the stats API delete request fails', async () =>
globalThis.fetch = originalFetch; 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;
}
});

View File

@@ -17,6 +17,7 @@ import type {
EpisodesPerDay, EpisodesPerDay,
NewAnimePerDay, NewAnimePerDay,
WatchTimePerAnime, WatchTimePerAnime,
TrendsDashboardData,
WordDetailData, WordDetailData,
KanjiDetailData, KanjiDetailData,
EpisodeDetailData, EpisodeDetailData,
@@ -73,6 +74,10 @@ export const apiClient = {
fetchJson<SessionTimelinePoint[]>(`/api/stats/sessions/${id}/timeline?limit=${limit}`), fetchJson<SessionTimelinePoint[]>(`/api/stats/sessions/${id}/timeline?limit=${limit}`),
getSessionEvents: (id: number, limit = 500) => getSessionEvents: (id: number, limit = 500) =>
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`), fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
getSessionKnownWordsTimeline: (id: number) =>
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number }>>(
`/api/stats/sessions/${id}/known-words-timeline`,
),
getVocabulary: (limit = 100) => getVocabulary: (limit = 100) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`), fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) => getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
@@ -101,6 +106,10 @@ export const apiClient = {
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`), fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
getWatchTimePerAnime: (limit = 90) => getWatchTimePerAnime: (limit = 90) =>
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
getTrendsDashboard: (range: '7d' | '30d' | '90d' | 'all', groupBy: 'day' | 'month') =>
fetchJson<TrendsDashboardData>(
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
),
getWordDetail: (wordId: number) => getWordDetail: (wordId: number) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`), fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) => getKanjiDetail: (kanjiId: number) =>
@@ -117,10 +126,27 @@ export const apiClient = {
deleteSession: async (sessionId: number): Promise<void> => { deleteSession: async (sessionId: number): Promise<void> => {
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }); await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
}, },
deleteSessions: async (sessionIds: number[]): Promise<void> => {
await fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds }),
});
},
deleteVideo: async (videoId: number): Promise<void> => { deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }); await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
}, },
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'), getKnownWords: () => fetchJson<string[]>('/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) => searchAnilist: (query: string) =>
fetchJson< fetchJson<
Array<{ Array<{

View File

@@ -35,6 +35,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
cardsMined: 2, cardsMined: 2,
lookupCount: 10, lookupCount: 10,
lookupHits: 8, lookupHits: 8,
yomitanLookupCount: 0,
}, },
]; ];
const rollups: DailyRollup[] = [ const rollups: DailyRollup[] = [
@@ -56,7 +57,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
sessions, sessions,
rollups, rollups,
hints: { hints: {
totalSessions: 1, totalSessions: 15,
activeSessions: 0, activeSessions: 0,
episodesToday: 2, episodesToday: 2,
activeAnimeCount: 3, activeAnimeCount: 3,
@@ -65,6 +66,10 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
totalActiveMin: 50, totalActiveMin: 50,
activeDays: 2, activeDays: 2,
totalCards: 9, 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.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3); assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50); assert.equal(summary.averageSessionMinutes, 50);
assert.equal(summary.allTimeHours, 1); assert.equal(summary.allTimeMinutes, 50);
assert.equal(summary.activeDays, 2); assert.equal(summary.activeDays, 2);
assert.equal(summary.totalSessions, 15);
assert.equal(summary.lookupRate, 80);
}); });
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => { test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
const now = Date.UTC(2026, 2, 13, 12); const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000); const today = Math.floor(now / 86_400_000);
const overview: OverviewData = { const overview: OverviewData = {
sessions: [ sessions: [
{ {
sessionId: 2, sessionId: 2,
canonicalTitle: 'B', canonicalTitle: 'B',
@@ -99,6 +106,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
cardsMined: 10, cardsMined: 10,
lookupCount: 1, lookupCount: 1,
lookupHits: 1, lookupHits: 1,
yomitanLookupCount: 0,
}, },
], ],
rollups: [ rollups: [
@@ -117,7 +125,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
}, },
], ],
hints: { hints: {
totalSessions: 999, totalSessions: 50,
activeSessions: 0, activeSessions: 0,
episodesToday: 0, episodesToday: 0,
activeAnimeCount: 0, activeAnimeCount: 0,
@@ -126,13 +134,16 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
totalActiveMin: 120, totalActiveMin: 120,
activeDays: 40, activeDays: 40,
totalCards: 5, totalCards: 5,
totalLookupCount: 0,
totalLookupHits: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}, },
}; };
const summary = buildOverviewSummary(overview, now); const summary = buildOverviewSummary(overview, now);
assert.equal(summary.totalTrackedCards, 5); assert.equal(summary.totalTrackedCards, 5);
assert.equal(summary.totalSessions, 999); assert.equal(summary.allTimeMinutes, 120);
assert.equal(summary.allTimeHours, 2);
assert.equal(summary.activeDays, 40); assert.equal(summary.activeDays, 40);
}); });
@@ -150,6 +161,8 @@ test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
pos2: null, pos2: null,
pos3: null, pos3: null,
frequency: 4, frequency: 4,
frequencyRank: null,
animeCount: 1,
firstSeen: nowSec - 2 * 86_400, firstSeen: nowSec - 2 * 86_400,
lastSeen: nowSec - 1, lastSeen: nowSec - 1,
}, },

View File

@@ -16,15 +16,19 @@ export interface OverviewSummary {
todayActiveMs: number; todayActiveMs: number;
todayCards: number; todayCards: number;
streakDays: number; streakDays: number;
allTimeHours: number; allTimeMinutes: number;
totalTrackedCards: number; totalTrackedCards: number;
episodesToday: number; episodesToday: number;
activeAnimeCount: number; activeAnimeCount: number;
totalEpisodesWatched: number; totalEpisodesWatched: number;
totalAnimeCompleted: number; totalAnimeCompleted: number;
averageSessionMinutes: number; averageSessionMinutes: number;
totalSessions: number;
activeDays: number; activeDays: number;
totalSessions: number;
lookupRate: number | null;
todayWords: number;
newWordsToday: number;
newWordsThisWeek: number;
recentWatchTime: ChartPoint[]; recentWatchTime: ChartPoint[];
} }
@@ -161,7 +165,7 @@ export function buildOverviewSummary(
sumBy(todaySessions, (session) => session.cardsMined), sumBy(todaySessions, (session) => session.cardsMined),
), ),
streakDays, streakDays,
allTimeHours: Math.max(0, Math.round(totalActiveMin / 60)), allTimeMinutes: Math.max(0, Math.round(totalActiveMin)),
totalTrackedCards: lifetimeCards, totalTrackedCards: lifetimeCards,
episodesToday: overview.hints.episodesToday ?? 0, episodesToday: overview.hints.episodesToday ?? 0,
activeAnimeCount: overview.hints.activeAnimeCount ?? 0, activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
@@ -175,8 +179,18 @@ export function buildOverviewSummary(
60_000, 60_000,
) )
: 0, : 0,
totalSessions: overview.hints.totalSessions,
activeDays: overview.hints.activeDays ?? daysWithActivity.size, 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 recentWatchTime: aggregated
.slice(-14) .slice(-14)
.map((row) => ({ label: row.label, value: row.activeMin })), .map((row) => ({ label: row.label, value: row.activeMin })),

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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', () => { test('confirmSessionDelete uses the shared session delete warning copy', () => {
const calls: string[] = []; 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', () => { test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
const calls: string[] = []; const calls: string[] = [];
const originalConfirm = globalThis.confirm; const originalConfirm = globalThis.confirm;

View File

@@ -2,6 +2,18 @@ export function confirmSessionDelete(): boolean {
return globalThis.confirm('Delete this session and all associated data?'); 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 { export function confirmEpisodeDelete(title: string): boolean {
return globalThis.confirm(`Delete "${title}" and all its sessions?`); return globalThis.confirm(`Delete "${title}" and all its sessions?`);
} }

View File

@@ -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(
<MediaSessionList
sessions={[
{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 9,
animeId: 3,
animeTitle: 'Anime',
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 1_000,
activeWatchedMs: 900,
linesSeen: 12,
wordsSeen: 24,
tokensSeen: 24,
cardsMined: 2,
lookupCount: 3,
lookupHits: 2,
yomitanLookupCount: 1,
},
]}
onDeleteSession={() => {}}
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/);
});

View File

@@ -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(
<SessionDetail
session={{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 7,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 12,
wordsSeen: 24,
tokensSeen: 24,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
}}
/>,
);
assert.match(markup, /Total words/);
assert.doesNotMatch(markup, /New words/);
});

View File

@@ -0,0 +1,8 @@
type SessionWordCountLike = {
wordsSeen: number;
tokensSeen: number;
};
export function getSessionDisplayWordCount(value: SessionWordCountLike): number {
return value.tokensSeen > 0 ? value.tokensSeen : value.wordsSeen;
}

View File

@@ -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);
});

View File

@@ -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<SessionSummary, 'sessionId' | 'videoId'>):
| {
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,
};
}

View File

@@ -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(<TabBar activeTab="overview" onTabChange={() => {}} />);
assert.doesNotMatch(markup, />Anime</);
assert.match(markup, />Overview</);
assert.match(markup, />Library</);
});
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
const markup = renderToStaticMarkup(
<EpisodeList
episodes={[
{
videoId: 9,
episode: 9,
season: 1,
durationMs: 1,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalWordsSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
onOpenDetail={() => {}}
/>,
);
assert.match(markup, />Details</);
assert.match(markup, /Episode 9/);
});

View File

@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const VOCABULARY_TAB_PATH = path.resolve(
import.meta.dir,
'../components/vocabulary/VocabularyTab.tsx',
);
test('VocabularyTab declares all hooks before loading and error early returns', () => {
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 ?? [], []);
});

View File

@@ -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(
<MediaHeader
detail={{
videoId: 7,
canonicalTitle: 'Episode 7',
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalWordsSeen: 1000,
totalLinesSeen: 120,
totalLookupCount: 30,
totalLookupHits: 21,
totalYomitanLookupCount: 23,
}}
/>,
);
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(
<MediaHeader
detail={{
videoId: 7,
canonicalTitle: 'Episode 7',
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalWordsSeen: 30,
totalLinesSeen: 120,
totalLookupCount: 30,
totalLookupHits: 21,
totalYomitanLookupCount: 0,
}}
initialKnownWordsSummary={{
knownWordCount: 17,
totalUniqueWords: 34,
}}
/>,
);
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(
<EpisodeList
episodes={[
{
videoId: 9,
episode: 9,
season: 1,
durationMs: 1,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalWordsSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
/>,
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.0 \/ 100 words/);
});
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
const markup = renderToStaticMarkup(
<AnimeOverviewStats
detail={{
animeId: 1,
canonicalTitle: 'Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
description: null,
totalSessions: 5,
totalActiveMs: 100_000,
totalCards: 8,
totalWordsSeen: 800,
totalLinesSeen: 100,
totalLookupCount: 50,
totalLookupHits: 30,
totalYomitanLookupCount: 16,
episodeCount: 3,
lastWatchedMs: 0,
}}
avgSessionMs={20_000}
knownWordsSummary={null}
/>,
);
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(
<SessionRow
session={{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 7,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 12,
wordsSeen: 12,
tokensSeen: 42,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
}}
isExpanded={false}
detailsId="session-7"
onToggle={() => {}}
onDelete={() => {}}
/>,
);
assert.match(markup, />42</);
assert.doesNotMatch(markup, />12</);
});

View File

@@ -0,0 +1,25 @@
import type { SessionEvent } from '../types/stats';
import { EventType } from '../types/stats';
export interface LookupRateDisplay {
shortValue: string;
longValue: string;
}
export function buildLookupRateDisplay(
yomitanLookupCount: number,
wordsSeen: number,
): LookupRateDisplay | null {
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(wordsSeen) || wordsSeen <= 0) {
return null;
}
const per100 = ((Math.max(0, yomitanLookupCount) / wordsSeen) * 100).toFixed(1);
return {
shortValue: `${per100} / 100 words`,
longValue: `${per100} lookups per 100 words`,
};
}
export function getYomitanLookupEvents(events: SessionEvent[]): SessionEvent[] {
return events.filter((event) => event.eventType === EventType.YOMITAN_LOOKUP);
}

View File

@@ -14,6 +14,7 @@ export interface SessionSummary {
cardsMined: number; cardsMined: number;
lookupCount: number; lookupCount: number;
lookupHits: number; lookupHits: number;
yomitanLookupCount: number;
} }
export interface DailyRollup { export interface DailyRollup {
@@ -100,6 +101,10 @@ export interface OverviewData {
totalActiveMin: number; totalActiveMin: number;
activeDays: number; activeDays: number;
totalCards?: number; totalCards?: number;
totalLookupCount: number;
totalLookupHits: number;
newWordsToday: number;
newWordsThisWeek: number;
}; };
} }
@@ -125,6 +130,7 @@ export interface MediaDetailData {
totalLinesSeen: number; totalLinesSeen: number;
totalLookupCount: number; totalLookupCount: number;
totalLookupHits: number; totalLookupHits: number;
totalYomitanLookupCount: number;
} | null; } | null;
sessions: SessionSummary[]; sessions: SessionSummary[];
rollups: DailyRollup[]; rollups: DailyRollup[];
@@ -139,6 +145,7 @@ export const EventType = {
SEEK_BACKWARD: 6, SEEK_BACKWARD: 6,
PAUSE_START: 7, PAUSE_START: 7,
PAUSE_END: 8, PAUSE_END: 8,
YOMITAN_LOOKUP: 9,
} as const; } as const;
export type EventType = (typeof EventType)[keyof typeof EventType]; export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -179,6 +186,7 @@ export interface AnimeDetailData {
totalLinesSeen: number; totalLinesSeen: number;
totalLookupCount: number; totalLookupCount: number;
totalLookupHits: number; totalLookupHits: number;
totalYomitanLookupCount: number;
episodeCount: number; episodeCount: number;
lastWatchedMs: number; lastWatchedMs: number;
}; };
@@ -196,6 +204,8 @@ export interface AnimeEpisode {
totalSessions: number; totalSessions: number;
totalActiveMs: number; totalActiveMs: number;
totalCards: number; totalCards: number;
totalWordsSeen: number;
totalYomitanLookupCount: number;
lastWatchedMs: number; lastWatchedMs: number;
} }
@@ -230,6 +240,56 @@ export interface WatchTimePerAnime {
totalActiveMin: number; 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 { export interface WordDetailData {
detail: { detail: {
wordId: number; wordId: number;