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

View File

@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi';
import { formatDuration, formatNumber, epochDayToDate } from '../../lib/formatters';
import { StatCard } from '../layout/StatCard';
import { epochDayToDate } from '../../lib/formatters';
import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
import { AnimeWordList } from './AnimeWordList';
import { AnilistSelector } from './AnilistSelector';
import { AnimeOverviewStats } from './AnimeOverviewStats';
import { CHART_THEME } from '../../lib/chart-theme';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import type { DailyRollup } from '../../types/stats';
@@ -15,6 +15,7 @@ interface AnimeDetailViewProps {
animeId: number;
onBack: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (videoId: number) => void;
}
type Range = 14 | 30 | 90;
@@ -111,18 +112,43 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
);
}
export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) {
function useAnimeKnownWords(animeId: number) {
const [summary, setSummary] = useState<{
totalUniqueWords: number;
knownWordCount: number;
} | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getAnimeKnownWordsSummary(animeId)
.then((data) => {
if (!cancelled) setSummary(data);
})
.catch(() => {
if (!cancelled) setSummary(null);
});
return () => {
cancelled = true;
};
}, [animeId]);
return summary;
}
export function AnimeDetailView({
animeId,
onBack,
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const knownWordsSummary = useAnimeKnownWords(animeId);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</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>;
const { detail, episodes, anilistEntries } = data;
const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
return (
<div className="space-y-4">
<button
@@ -130,29 +156,21 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
onClick={onBack}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
>
&larr; Back to Anime
&larr; Back to Library
</button>
<AnimeHeader
detail={detail}
anilistEntries={anilistEntries ?? []}
onChangeAnilist={() => setShowAnilistSelector(true)}
/>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
<StatCard
label="Watch Time"
value={formatDuration(detail.totalActiveMs)}
color="text-ctp-blue"
<AnimeOverviewStats
detail={detail}
knownWordsSummary={knownWordsSummary}
/>
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
<StatCard
label="Words"
value={formatNumber(detail.totalWordsSeen)}
color="text-ctp-mauve"
<EpisodeList
episodes={episodes}
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
/>
<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} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
{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;
onClearInitialAnime?: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (animeId: number, videoId: number) => void;
}
export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord }: AnimeTabProps) {
export function AnimeTab({
initialAnimeId,
onClearInitialAnime,
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
@@ -70,6 +76,11 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
animeId={selectedAnimeId}
onBack={() => setSelectedAnimeId(null)}
onNavigateToWord={onNavigateToWord}
onOpenEpisodeDetail={
onOpenEpisodeDetail
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
/>
);
}

View File

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

View File

@@ -2,15 +2,21 @@ import { Fragment, useState } from 'react';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client';
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats';
interface EpisodeListProps {
episodes: AnimeEpisode[];
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 [episodes, setEpisodes] = useState(initialEpisodes);
@@ -65,12 +71,19 @@ 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">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">Lookup Rate</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>
</thead>
<tbody>
{sorted.map((ep, idx) => (
{sorted.map((ep, idx) => {
const lookupRate = buildLookupRateDisplay(
ep.totalYomitanLookupCount,
ep.totalWordsSeen,
);
return (
<Fragment key={ep.videoId}>
<tr
onClick={() =>
@@ -108,11 +121,30 @@ export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: Epi
<td className="py-2 pr-3 text-right text-ctp-green">
{formatNumber(ep.totalCards)}
</td>
<td className="py-2 pr-3 text-right">
<div className="text-ctp-sapphire">{lookupRate?.shortValue ?? '\u2014'}</div>
<div className="text-[11px] text-ctp-overlay2">
{lookupRate?.longValue ?? 'lookup rate'}
</div>
</td>
<td className="py-2 pr-3 text-right text-ctp-overlay2">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
</td>
<td className="py-2 text-center w-16">
<td className="py-2 text-center w-28">
<div className="flex items-center justify-center gap-1">
{onOpenDetail ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenDetail(ep.videoId);
}}
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"
>
Details
</button>
) : null}
<button
type="button"
onClick={(e) => {
@@ -144,13 +176,14 @@ export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: Epi
</tr>
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={8} className="py-2">
<td colSpan={9} className="py-2">
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
</td>
</tr>
)}
</Fragment>
))}
);
})}
</tbody>
</table>
</div>

View File

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

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 { MediaDetailView } from './MediaDetailView';
export function LibraryTab() {
interface LibraryTabProps {
onNavigateToSession: (sessionId: number) => void;
}
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const { media, loading, error } = useMediaLibrary();
const [search, setSearch] = useState('');
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
@@ -18,7 +22,7 @@ export function LibraryTab() {
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
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>;

View File

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

View File

@@ -1,16 +1,44 @@
import { useState, useEffect } from 'react';
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
import { getStatsClient } from '../../hooks/useStatsApi';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import type { MediaDetailData } from '../../types/stats';
interface MediaHeaderProps {
detail: NonNullable<MediaDetailData['detail']>;
initialKnownWordsSummary?: {
totalUniqueWords: number;
knownWordCount: number;
} | null;
}
export function MediaHeader({ detail }: MediaHeaderProps) {
const hitRate =
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalWordsSeen);
const [knownWordsSummary, setKnownWordsSummary] = useState<{
totalUniqueWords: number;
knownWordCount: number;
} | null>(initialKnownWordsSummary);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getMediaKnownWordsSummary(detail.videoId)
.then((data) => {
if (!cancelled) setKnownWordsSummary(data);
})
.catch(() => {
if (!cancelled) setKnownWordsSummary(null);
});
return () => {
cancelled = true;
};
}, [detail.videoId]);
return (
<div className="flex gap-4">
@@ -32,12 +60,37 @@ export function MediaHeader({ detail }: MediaHeaderProps) {
</div>
<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 className="text-ctp-peach font-medium">{formatPercent(hitRate)}</div>
<div className="text-xs text-ctp-overlay2">lookup rate</div>
<div className="text-ctp-lavender font-medium">
{formatNumber(detail.totalYomitanLookupCount)}
</div>
<div className="text-xs text-ctp-overlay2">Yomitan lookups</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 className="text-ctp-text font-medium">{detail.totalSessions}</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';
interface MediaSessionListProps {
sessions: SessionSummary[];
onDeleteSession: (session: SessionSummary) => void;
deletingSessionId?: number | null;
initialExpandedSessionId?: number | null;
onConsumeInitialExpandedSession?: () => void;
}
export function MediaSessionList({ sessions }: MediaSessionListProps) {
export function MediaSessionList({
sessions,
onDeleteSession,
deletingSessionId = null,
initialExpandedSessionId = null,
onConsumeInitialExpandedSession,
}: MediaSessionListProps) {
const [expandedId, setExpandedId] = useState<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) {
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">
<h3 className="text-sm font-semibold text-ctp-text">Session History</h3>
{sessions.map((s) => (
<div
key={s.sessionId}
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center justify-between"
>
<div className="min-w-0">
<div className="text-sm text-ctp-text">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
</div>
</div>
<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 key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={`media-session-details-${s.sessionId}`}
onToggle={() =>
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>
) : null}
</div>
))}
</div>

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { useOverview } from '../../hooks/useOverview';
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
import { HeroStats } from './HeroStats';
@@ -6,14 +7,113 @@ import { RecentSessions } from './RecentSessions';
import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { formatNumber } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
import { Tooltip } from '../layout/Tooltip';
import {
confirmSessionDelete,
confirmDayGroupDelete,
confirmAnimeGroupDelete,
} from '../../lib/delete-confirm';
import type { SessionSummary } from '../../types/stats';
interface OverviewTabProps {
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
}
export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
const { data, sessions, loading, error } = useOverview();
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
const { data, sessions, setSessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90);
const [deleteError, setDeleteError] = useState<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 (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 streakData = buildStreakCalendar(calendar);
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.totalSessions > 0;
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
return (
<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">
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
<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>
{showTrackedCardNote && (
<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.
</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">
<Tooltip text="Total immersion sessions recorded across all time">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Lifetime Sessions
</div>
<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>
</Tooltip>
<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">Episodes Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-teal">
{formatNumber(summary.episodesToday)}
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Hours</div>
<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">
{formatNumber(summary.allTimeHours)}
{summary.allTimeMinutes < 60
? `${summary.allTimeMinutes}m`
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
</div>
</div>
</Tooltip>
<Tooltip text="Number of distinct days with at least one session">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Days</div>
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
{formatNumber(summary.activeDays)}
</div>
</div>
</Tooltip>
<Tooltip text="Average active watch time per session in minutes">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Cards</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(summary.totalTrackedCards)}
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
<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>
</Tooltip>
<Tooltip text="Total unique episodes (videos) watched across all anime">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Lifetime Episodes
</div>
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</div>
</div>
</Tooltip>
<Tooltip text="Number of anime series fully completed">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Anime</div>
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)}
</div>
</div>
</Tooltip>
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<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>
</Tooltip>
<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>
</Tooltip>
<Tooltip text="Total word occurrences encountered in today's sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
{formatNumber(summary.todayWords)}
</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>
<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>
);
}

View File

@@ -6,11 +6,18 @@ import {
formatSessionDayLabel,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats';
interface RecentSessionsProps {
sessions: SessionSummary[];
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
deletingIds: Set<number>;
}
interface AnimeGroup {
@@ -52,10 +59,11 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
: `session-${session.sessionId}`;
const existing = map.get(key);
const displayWordCount = getSessionDisplayWordCount(session);
if (existing) {
existing.sessions.push(session);
existing.totalCards += session.cardsMined;
existing.totalWords += session.wordsSeen;
existing.totalWords += displayWordCount;
existing.totalActiveMs += session.activeWatchedMs;
} else {
map.set(key, {
@@ -65,7 +73,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
videoId: session.videoId,
sessions: [session],
totalCards: session.cardsMined,
totalWords: session.wordsSeen,
totalWords: displayWordCount,
totalActiveMs: session.activeWatchedMs,
});
}
@@ -111,16 +119,32 @@ function CoverThumbnail({
function SessionItem({
session,
onNavigateToMediaDetail,
onNavigateToSession,
onDelete,
deleteDisabled,
}: {
session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
}) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
return (
<div className="relative group">
<button
type="button"
onClick={() => onNavigateToSession(session.sessionId)}
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') {
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
return;
}
onNavigateToSession(navigationTarget.sessionId);
}}
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"
>
<CoverThumbnail
animeId={session.animeId}
@@ -145,27 +169,54 @@ function SessionItem({
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
{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({
group,
onNavigateToMediaDetail,
onNavigateToSession,
onDeleteSession,
onDeleteAnimeGroup,
deletingIds,
}: {
group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>;
}) {
const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
if (group.sessions.length === 1) {
const s = group.sessions[0]!;
return (
<SessionItem session={group.sessions[0]!} onNavigateToSession={onNavigateToSession} />
<SessionItem
session={s}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
/>
);
}
@@ -174,13 +225,14 @@ function AnimeGroupRow({
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
return (
<div>
<div className="group/anime">
<div className="relative">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={disclosureId}
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"
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}
@@ -214,14 +266,42 @@ function AnimeGroupRow({
{'\u25B8'}
</div>
</button>
{expanded && (
<div id={disclosureId} role="region" aria-label={`${displayTitle} sessions`} className="ml-6 mt-1 space-y-1">
{group.sessions.map((s) => (
<button
type="button"
key={s.sessionId}
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"
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 && (
<div
id={disclosureId}
role="region"
aria-label={`${displayTitle} sessions`}
className="ml-6 mt-1 space-y-1"
>
{group.sessions.map((s) => {
const navigationTarget = getSessionNavigationTarget(s);
return (
<div key={s.sessionId} className="relative group/nested">
<button
type="button"
onClick={() => {
if (navigationTarget.type === 'media-detail') {
onNavigateToMediaDetail(
navigationTarget.videoId,
navigationTarget.sessionId,
);
return;
}
onNavigateToSession(navigationTarget.sessionId);
}}
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"
>
<CoverThumbnail
animeId={s.animeId}
@@ -233,7 +313,8 @@ function AnimeGroupRow({
{s.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
@@ -245,20 +326,40 @@ function AnimeGroupRow({
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(s.wordsSeen)}
{formatNumber(getSessionDisplayWordCount(s))}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</button>
))}
<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>
)}
</div>
);
}
export function RecentSessions({ sessions, onNavigateToSession }: RecentSessionsProps) {
export function RecentSessions({
sessions,
onNavigateToMediaDetail,
onNavigateToSession,
onDeleteSession,
onDeleteDayGroup,
onDeleteAnimeGroup,
deletingIds,
}: RecentSessionsProps) {
if (sessions.length === 0) {
return (
<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 anyDeleting = deletingIds.size > 0;
return (
<div className="space-y-4">
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const animeGroups = groupSessionsByAnime(daySessions);
const groupDeleting = daySessions.some((s) => deletingIds.has(s.sessionId));
return (
<div key={dayLabel}>
<div key={dayLabel} className="group/day">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<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 className="space-y-2">
{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>

View File

@@ -1,6 +1,7 @@
import {
ComposedChart,
AreaChart,
Area,
LineChart,
Line,
XAxis,
YAxis,
@@ -8,15 +9,18 @@ import {
ResponsiveContainer,
ReferenceArea,
ReferenceLine,
CartesianGrid,
} from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions';
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme';
import { buildLookupRateDisplay, getYomitanLookupEvents } from '../../lib/yomitan-lookup';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { EventType } from '../../types/stats';
import type { SessionEvent } from '../../types/stats';
import type { SessionEvent, SessionSummary } from '../../types/stats';
interface SessionDetailProps {
sessionId: number;
cardsMined: number;
session: SessionSummary;
}
const tooltipStyle = {
@@ -35,6 +39,30 @@ function formatTime(ms: number): string {
});
}
/** Build a lookup: linesSeen → knownWordsSeen */
function buildKnownWordsLookup(
knownWordsTimeline: KnownWordsTimelinePoint[],
): Map<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 {
startMs: number;
endMs: number;
@@ -55,60 +83,161 @@ function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
return regions;
}
interface ChartPoint {
interface RatioChartPoint {
tsMs: number;
activity: number;
knownPct: number;
unknownPct: number;
knownWords: number;
unknownWords: number;
totalWords: number;
paused: boolean;
}
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
const { timeline, events, loading, error } = useSessionDetail(sessionId);
interface FallbackChartPoint {
tsMs: number;
totalWords: number;
}
type TimelineEntry = {
sampleMs: number;
linesSeen: number;
wordsSeen: number;
tokensSeen: number;
};
export function SessionDetail({ session }: SessionDetailProps) {
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
session.sessionId,
);
if (loading) return <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>;
const sorted = [...timeline].reverse();
const pauseRegions = buildPauseRegions(events);
const chartData: ChartPoint[] = sorted.map((t, i) => {
const prevWords = i > 0 ? sorted[i - 1]!.wordsSeen : 0;
const delta = Math.max(0, t.wordsSeen - prevWords);
const paused = pauseRegions.some((r) => t.sampleMs >= r.startMs && t.sampleMs <= r.endMs);
return {
tsMs: t.sampleMs,
activity: delta,
totalWords: t.wordsSeen,
paused,
};
});
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
const cardEvents = events.filter((e) => e.eventType === EventType.CARD_MINED);
const seekEvents = events.filter(
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
);
const yomitanLookupEvents = getYomitanLookupEvents(events);
const lookupRate = buildLookupRateDisplay(
session.yomitanLookupCount,
getSessionDisplayWordCount(session),
);
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length;
const pauseRegions = buildPauseRegions(events);
const maxActivity = Math.max(...chartData.map((d) => d.activity), 1);
const yMax = Math.ceil(maxActivity * 1.3);
const tsMin = chartData.length > 0 ? chartData[0]!.tsMs : 0;
const tsMax = chartData.length > 0 ? chartData[chartData.length - 1]!.tsMs : 0;
if (hasKnownWords) {
return (
<RatioView
sorted={sorted}
knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
/>
);
}
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
{chartData.length > 0 && (
<ResponsiveContainer width="100%" height={150}>
<ComposedChart data={chartData} barCategoryGap={0} barGap={0}>
<FallbackView
sorted={sorted}
cardEvents={cardEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
/>
);
}
/* ── Ratio View (primary design) ────────────────────────────────── */
function RatioView({
sorted,
knownWordsMap,
cardEvents,
yomitanLookupEvents,
pauseRegions,
pauseCount,
seekCount,
cardEventCount,
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,
});
}
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;
const finalTotal = chartData[chartData.length - 1]!.totalWords;
const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords }));
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
{/* ── Top: Percentage area chart ── */}
<ResponsiveContainer width="100%" height={130}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={`actGrad-${sessionId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.5} />
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.05} />
<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}
/>
<XAxis
dataKey="tsMs"
type="number"
@@ -120,42 +249,44 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
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"
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={30}
allowDecimals={false}
width={32}
/>
<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];
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="left"
yAxisId="pct"
x1={r.startMs}
x2={r.endMs}
y1={0}
y2={yMax}
y2={100}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
@@ -165,30 +296,17 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
/>
))}
{/* Seek markers */}
{seekEvents.map((e, i) => (
<ReferenceLine
key={`seek-${i}`}
yAxisId="left"
x={e.tsMs}
stroke="#91d7e3"
strokeWidth={1}
strokeDasharray="3 4"
strokeOpacity={0.5}
/>
))}
{/* Card mined markers */}
{/* Card mine markers */}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
yAxisId="left"
yAxisId="pct"
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '',
value: '\u26CF',
position: 'top',
fill: '#a6da95',
fontSize: 14,
@@ -197,20 +315,190 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
/>
))}
<Area
yAxisId="left"
dataKey="activity"
stroke="#c6a0f6"
{/* Yomitan lookup markers */}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
fill={`url(#actGrad-${sessionId})`}
name="New words"
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
))}
<Area
yAxisId="pct"
dataKey="knownPct"
stackId="ratio"
stroke="#a6da95"
strokeWidth={1.5}
fill={`url(#knownGrad-${session.sessionId})`}
name="Known"
type="monotone"
dot={false}
activeDot={{ r: 3, fill: '#c6a0f6', stroke: '#1e2030', strokeWidth: 1 }}
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>
</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
yAxisId="right"
dataKey="totalWords"
stroke="#8aadf4"
strokeWidth={1.5}
@@ -220,58 +508,99 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
type="monotone"
isAnimationActive={false}
/>
</ComposedChart>
</LineChart>
</ResponsiveContainer>
)}
<div className="flex flex-wrap items-center gap-4 text-[11px]">
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-2 rounded-sm"
style={{
background:
'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))',
}}
<StatsBar
hasKnownWords={false}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
/>
<span className="text-ctp-overlay2">New words</span>
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#8aadf4' }} />
<span className="text-ctp-overlay2">Total words</span>
</span>
{pauseCount > 0 && (
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-2 rounded-sm"
style={{
background: 'rgba(245,169,127,0.2)',
border: '1px solid rgba(245,169,127,0.5)',
}}
/>
<span className="text-ctp-overlay2">
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
</span>
</span>
)}
{seekCount > 0 && (
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-0.5 rounded"
style={{ background: '#91d7e3', opacity: 0.7 }}
/>
<span className="text-ctp-overlay2">
{seekCount} seek{seekCount !== 1 ? 's' : ''}
</span>
</span>
)}
<span className="flex items-center gap-1.5">
<span className="text-[12px]"></span>
<span className="text-ctp-green">
{Math.max(cardEventCount, cardsMined)} card
{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
</span>
</span>
</div>
</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>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import type { SessionSummary } from '../../types/stats';
interface SessionRowProps {
@@ -56,6 +57,8 @@ export function SessionRow({
onDelete,
deleteDisabled = false,
}: SessionRowProps) {
const displayWordCount = getSessionDisplayWordCount(session);
return (
<div className="relative group">
<button
@@ -88,7 +91,7 @@ export function SessionRow({
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
{formatNumber(displayWordCount)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>

View File

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

View File

@@ -2,113 +2,12 @@ import { useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart';
import { StackedTrendChart } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data';
import { localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function buildWatchTimeByDayOfWeek(sessions: SessionSummary[]): ChartPoint[] {
const totals = new Array(7).fill(0);
for (const s of sessions) {
const dow = new Date(s.startedAtMs).getDay();
totals[dow] += s.activeWatchedMs;
}
return DAY_NAMES.map((name, i) => ({ label: name, value: Math.round(totals[i] / 60_000) }));
}
function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
const totals = new Array(24).fill(0);
for (const s of sessions) {
const hour = new Date(s.startedAtMs).getHours();
totals[hour] += s.activeWatchedMs;
}
return totals.map((ms, i) => ({
label: `${String(i).padStart(2, '0')}:00`,
value: Math.round(ms / 60_000),
}));
}
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
const byAnime = new Map<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 }) {
return (
@@ -201,41 +100,34 @@ export function TrendsTab() {
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 (!data) return null;
const dashboard = buildTrendDashboard(data.rollups);
const watchByDow = buildWatchTimeByDayOfWeek(data.sessions);
const watchByHour = buildWatchTimeByHour(data.sessions);
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
epochDay: e.epochDay,
animeTitle: e.animeTitle,
value: e.totalActiveMin,
}));
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
const animeProgress = buildCumulativePerAnime(episodesPerAnime);
const cardsProgress = buildCumulativePerAnime(cardsPerAnime);
const wordsProgress = buildCumulativePerAnime(wordsPerAnime);
const animeTitles = buildAnimeVisibilityOptions([
episodesPerAnime,
watchTimePerAnime,
cardsPerAnime,
wordsPerAnime,
animeProgress,
cardsProgress,
wordsProgress,
data.animePerDay.episodes,
data.animePerDay.watchTime,
data.animePerDay.cards,
data.animePerDay.words,
data.animePerDay.lookups,
data.animeCumulative.episodes,
data.animeCumulative.cards,
data.animeCumulative.words,
data.animeCumulative.watchTime,
]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
const filteredEpisodesPerAnime = filterHiddenAnimeData(episodesPerAnime, activeHiddenAnime);
const filteredWatchTimePerAnime = filterHiddenAnimeData(watchTimePerAnime, activeHiddenAnime);
const filteredCardsPerAnime = filterHiddenAnimeData(cardsPerAnime, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(wordsPerAnime, activeHiddenAnime);
const filteredAnimeProgress = filterHiddenAnimeData(animeProgress, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(cardsProgress, activeHiddenAnime);
const filteredWordsProgress = filterHiddenAnimeData(wordsProgress, activeHiddenAnime);
const filteredEpisodesPerAnime = filterHiddenAnimeData(data.animePerDay.episodes, activeHiddenAnime);
const filteredWatchTimePerAnime = filterHiddenAnimeData(data.animePerDay.watchTime, activeHiddenAnime);
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
data.animePerDay.lookupsPerHundred,
activeHiddenAnime,
);
const filteredAnimeProgress = filterHiddenAnimeData(data.animeCumulative.episodes, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(data.animeCumulative.cards, activeHiddenAnime);
const filteredWordsProgress = filterHiddenAnimeData(data.animeCumulative.words, activeHiddenAnime);
const filteredWatchTimeProgress = filterHiddenAnimeData(data.animeCumulative.watchTime, activeHiddenAnime);
return (
<div className="space-y-4">
@@ -245,23 +137,27 @@ export function TrendsTab() {
onRangeChange={setRange}
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>
<TrendChart
title="Watch Time (min)"
data={dashboard.watchTime}
data={data.activity.watchTime}
color="#8aadf4"
type="bar"
/>
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
<TrendChart
title="Avg Session (min)"
data={dashboard.averageSessionMinutes}
color="#f5bde6"
type="line"
/>
<TrendChart title="Cards Mined" data={data.activity.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Period Trends</SectionHeader>
<TrendChart title="Watch Time (min)" data={data.progress.watchTime} color="#8aadf4" type="line" />
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" 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>
<AnimeVisibilityFilter
@@ -285,8 +181,11 @@ export function TrendsTab() {
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} />
<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>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
@@ -294,13 +193,13 @@ export function TrendsTab() {
<SectionHeader>Patterns</SectionHeader>
<TrendChart
title="Watch Time by Day of Week (min)"
data={watchByDow}
data={data.patterns.watchTimeByDayOfWeek}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Watch Time by Hour (min)"
data={watchByHour}
data={data.patterns.watchTimeByHour}
color="#c6a0f6"
type="bar"
/>

View File

@@ -65,6 +65,13 @@ export function VocabularyTab({
const summary = buildVocabularySummary(filteredWords, kanji);
let knownWordCount = 0;
if (knownWords.size > 0) {
for (const w of filteredWords) {
if (knownWords.has(w.headword)) knownWordCount++;
}
}
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
};
@@ -80,16 +87,23 @@ export function VocabularyTab({
return (
<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
label="Unique Words"
value={formatNumber(summary.uniqueWords)}
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
label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)}
color="text-ctp-green"
color="text-ctp-teal"
/>
<StatCard
label="New This Week"

View File

@@ -135,6 +135,10 @@ export function WordDetailPanel({
occ: VocabularyOccurrenceEntry,
mode: 'word' | 'sentence' | 'audio',
) => {
if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) {
return;
}
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try {
@@ -363,10 +367,16 @@ export function WordDetailPanel({
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
· session {occ.sessionId}
</span>
{occ.sourcePath &&
{(() => {
const canMine =
!!occ.sourcePath &&
occ.segmentStartMs != null &&
occ.segmentEndMs != null &&
(() => {
occ.segmentEndMs != null;
const unavailableReason = canMine
? null
: occ.sourcePath
? 'This line is missing segment timing.'
: 'This source has no local file path.';
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
@@ -375,38 +385,47 @@ export function WordDetailPanel({
<>
<button
type="button"
title={unavailableReason ?? 'Mine this word from video clip'}
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}
disabled={wordStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Word'}
</button>
<button
type="button"
title={unavailableReason ?? 'Mine this sentence from video clip'}
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"
disabled={sentenceStatus?.loading}
disabled={sentenceStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'sentence')}
>
{sentenceStatus?.loading
? 'Mining...'
: sentenceStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Sentence'}
</button>
<button
type="button"
title={unavailableReason ?? 'Mine this line as audio-only card'}
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"
disabled={audioStatus?.loading}
disabled={audioStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Audio'}
</button>
</>

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

View File

@@ -1,36 +1,12 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type {
DailyRollup,
MonthlyRollup,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
SessionSummary,
AnimeLibraryItem,
} from '../types/stats';
import type { TrendsDashboardData } from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | 'all';
export type GroupBy = 'day' | 'month';
export interface TrendsData {
rollups: DailyRollup[] | MonthlyRollup[];
episodesPerDay: EpisodesPerDay[];
newAnimePerDay: NewAnimePerDay[];
watchTimePerAnime: WatchTimePerAnime[];
sessions: SessionSummary[];
animeLibrary: AnimeLibraryItem[];
}
export function useTrends(range: TimeRange, groupBy: GroupBy) {
const [data, setData] = useState<TrendsData>({
rollups: [],
episodesPerDay: [],
newAnimePerDay: [],
watchTimePerAnime: [],
sessions: [],
animeLibrary: [],
});
const [data, setData] = useState<TrendsDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -38,51 +14,12 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
const limitMap: Record<TimeRange, number> = { '7d': 7, '30d': 30, '90d': 90, all: 365 };
const limit = limitMap[range];
const monthlyLimit = Math.max(1, Math.ceil(limit / 30));
const sessionsLimitMap: Record<TimeRange, number> = {
'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]) => {
getStatsClient()
.getTrendsDashboard(range, groupBy)
.then((nextData) => {
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,
});
},
)
setData(nextData);
})
.catch((err) => {
if (cancelled) return;
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;
}
});
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,
NewAnimePerDay,
WatchTimePerAnime,
TrendsDashboardData,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
@@ -73,6 +74,10 @@ export const apiClient = {
fetchJson<SessionTimelinePoint[]>(`/api/stats/sessions/${id}/timeline?limit=${limit}`),
getSessionEvents: (id: number, limit = 500) =>
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) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
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}`),
getWatchTimePerAnime: (limit = 90) =>
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) =>
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
getKanjiDetail: (kanjiId: number) =>
@@ -117,10 +126,27 @@ export const apiClient = {
deleteSession: async (sessionId: number): Promise<void> => {
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> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
},
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) =>
fetchJson<
Array<{

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
import { confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
test('confirmSessionDelete uses the shared session delete warning copy', () => {
const calls: string[] = [];
@@ -18,6 +18,38 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => {
}
});
test('confirmDayGroupDelete includes the day label and count in the warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmDayGroupDelete('Today', 3), true);
assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmDayGroupDelete uses singular for one session', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmDayGroupDelete('Yesterday', 1), true);
assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;

View File

@@ -2,6 +2,18 @@ export function confirmSessionDelete(): boolean {
return globalThis.confirm('Delete this session and all associated data?');
}
export function confirmDayGroupDelete(dayLabel: string, count: number): boolean {
return globalThis.confirm(
`Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`,
);
}
export function confirmAnimeGroupDelete(title: string, count: number): boolean {
return globalThis.confirm(
`Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`,
);
}
export function confirmEpisodeDelete(title: string): boolean {
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
}

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;
lookupCount: number;
lookupHits: number;
yomitanLookupCount: number;
}
export interface DailyRollup {
@@ -100,6 +101,10 @@ export interface OverviewData {
totalActiveMin: number;
activeDays: number;
totalCards?: number;
totalLookupCount: number;
totalLookupHits: number;
newWordsToday: number;
newWordsThisWeek: number;
};
}
@@ -125,6 +130,7 @@ export interface MediaDetailData {
totalLinesSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
} | null;
sessions: SessionSummary[];
rollups: DailyRollup[];
@@ -139,6 +145,7 @@ export const EventType = {
SEEK_BACKWARD: 6,
PAUSE_START: 7,
PAUSE_END: 8,
YOMITAN_LOOKUP: 9,
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -179,6 +186,7 @@ export interface AnimeDetailData {
totalLinesSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
episodeCount: number;
lastWatchedMs: number;
};
@@ -196,6 +204,8 @@ export interface AnimeEpisode {
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalWordsSeen: number;
totalYomitanLookupCount: number;
lastWatchedMs: number;
}
@@ -230,6 +240,56 @@ export interface WatchTimePerAnime {
totalActiveMin: number;
}
export interface TrendChartPoint {
label: string;
value: number;
}
export interface TrendPerAnimePoint {
epochDay: number;
animeTitle: string;
value: number;
}
export interface TrendsDashboardData {
activity: {
watchTime: TrendChartPoint[];
cards: TrendChartPoint[];
words: TrendChartPoint[];
sessions: TrendChartPoint[];
};
progress: {
watchTime: TrendChartPoint[];
sessions: TrendChartPoint[];
words: TrendChartPoint[];
newWords: TrendChartPoint[];
cards: TrendChartPoint[];
episodes: TrendChartPoint[];
lookups: TrendChartPoint[];
};
ratios: {
lookupsPerHundred: TrendChartPoint[];
};
animePerDay: {
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
animeCumulative: {
watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
};
patterns: {
watchTimeByDayOfWeek: TrendChartPoint[];
watchTimeByHour: TrendChartPoint[];
};
}
export interface WordDetailData {
detail: {
wordId: number;