mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
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:
@@ -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) => {
|
||||
activateTab(tabId);
|
||||
setSelectedAnimeId(null);
|
||||
if (tabId !== 'sessions') {
|
||||
setFocusedSessionId(null);
|
||||
}
|
||||
}, [activateTab]);
|
||||
const handleTabChange = useCallback(
|
||||
(tabId: TabId) => {
|
||||
activateTab(tabId);
|
||||
},
|
||||
[activateTab],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-ctp-base">
|
||||
@@ -64,86 +95,109 @@ export function App() {
|
||||
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto p-4">
|
||||
{mountedTabs.has('overview') ? (
|
||||
<section
|
||||
id="panel-overview"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-overview"
|
||||
hidden={activeTab !== 'overview'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<OverviewTab onNavigateToSession={navigateToSession} />
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('anime') ? (
|
||||
<section
|
||||
id="panel-anime"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-anime"
|
||||
hidden={activeTab !== 'anime'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<AnimeTab
|
||||
initialAnimeId={selectedAnimeId}
|
||||
onClearInitialAnime={() => setSelectedAnimeId(null)}
|
||||
onNavigateToWord={openWordDetail}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('trends') ? (
|
||||
<section
|
||||
id="panel-trends"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-trends"
|
||||
hidden={activeTab !== 'trends'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<TrendsTab />
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('vocabulary') ? (
|
||||
<section
|
||||
id="panel-vocabulary"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-vocabulary"
|
||||
hidden={activeTab !== 'vocabulary'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<VocabularyTab
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
onOpenWordDetail={openWordDetail}
|
||||
excluded={excluded}
|
||||
isExcluded={isExcluded}
|
||||
onRemoveExclusion={removeExclusion}
|
||||
onClearExclusions={clearAll}
|
||||
/>
|
||||
</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"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-sessions"
|
||||
hidden={activeTab !== 'sessions'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<SessionsTab
|
||||
initialSessionId={focusedSessionId}
|
||||
onClearInitialSession={() => setFocusedSessionId(null)}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{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"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-overview"
|
||||
hidden={activeTab !== 'overview'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<OverviewTab
|
||||
onNavigateToMediaDetail={navigateToOverviewMediaDetail}
|
||||
onNavigateToSession={navigateToSession}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('anime') ? (
|
||||
<section
|
||||
id="panel-anime"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-anime"
|
||||
hidden={activeTab !== 'anime'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<AnimeTab
|
||||
initialAnimeId={selectedAnimeId}
|
||||
onClearInitialAnime={() =>
|
||||
setViewState((prev) => ({ ...prev, selectedAnimeId: null }))
|
||||
}
|
||||
onNavigateToWord={openWordDetail}
|
||||
onOpenEpisodeDetail={navigateToEpisodeDetail}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('trends') ? (
|
||||
<section
|
||||
id="panel-trends"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-trends"
|
||||
hidden={activeTab !== 'trends'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<TrendsTab />
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('vocabulary') ? (
|
||||
<section
|
||||
id="panel-vocabulary"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-vocabulary"
|
||||
hidden={activeTab !== 'vocabulary'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<VocabularyTab
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
onOpenWordDetail={openWordDetail}
|
||||
excluded={excluded}
|
||||
isExcluded={isExcluded}
|
||||
onRemoveExclusion={removeExclusion}
|
||||
onClearExclusions={clearAll}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{mountedTabs.has('sessions') ? (
|
||||
<section
|
||||
id="panel-sessions"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-sessions"
|
||||
hidden={activeTab !== 'sessions'}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<SessionsTab
|
||||
initialSessionId={focusedSessionId}
|
||||
onClearInitialSession={() =>
|
||||
setViewState((prev) => ({ ...prev, focusedSessionId: null }))
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<WordDetailPanel
|
||||
wordId={globalWordId}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← Back to Anime
|
||||
← 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"
|
||||
/>
|
||||
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
|
||||
<StatCard
|
||||
label="Words"
|
||||
value={formatNumber(detail.totalWordsSeen)}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
<StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" />
|
||||
<StatCard label="Avg Session" value={formatDuration(avgSessionMs)} />
|
||||
</div>
|
||||
<EpisodeList episodes={episodes} />
|
||||
<AnimeOverviewStats
|
||||
detail={detail}
|
||||
knownWordsSummary={knownWordsSummary}
|
||||
/>
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
||||
/>
|
||||
<AnimeWatchChart animeId={animeId} />
|
||||
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
|
||||
{showAnilistSelector && (
|
||||
|
||||
133
stats/src/components/anime/AnimeOverviewStats.tsx
Normal file
133
stats/src/components/anime/AnimeOverviewStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,92 +71,119 @@ export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: Epi
|
||||
<th className="text-right py-2 pr-3 font-medium">Progress</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">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) => (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() =>
|
||||
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
|
||||
}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
{ep.durationMs > 0 ? (
|
||||
<span
|
||||
className={
|
||||
ep.totalActiveMs >= ep.durationMs * 0.85
|
||||
? 'text-ctp-green'
|
||||
: ep.totalActiveMs >= ep.durationMs * 0.5
|
||||
? 'text-ctp-peach'
|
||||
: 'text-ctp-overlay2'
|
||||
}
|
||||
>
|
||||
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ctp-overlay2">{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-blue">
|
||||
{formatDuration(ep.totalActiveMs)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-green">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</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">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleWatched(ep.videoId, ep.watched);
|
||||
}}
|
||||
className={`w-5 h-5 rounded border transition-colors ${
|
||||
ep.watched
|
||||
? 'bg-ctp-green border-ctp-green text-ctp-base'
|
||||
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
|
||||
}`}
|
||||
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
|
||||
>
|
||||
{'\u2713'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
|
||||
}}
|
||||
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
|
||||
title="Delete episode"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
|
||||
{sorted.map((ep, idx) => {
|
||||
const lookupRate = buildLookupRateDisplay(
|
||||
ep.totalYomitanLookupCount,
|
||||
ep.totalWordsSeen,
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() =>
|
||||
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
|
||||
}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
{ep.durationMs > 0 ? (
|
||||
<span
|
||||
className={
|
||||
ep.totalActiveMs >= ep.durationMs * 0.85
|
||||
? 'text-ctp-green'
|
||||
: ep.totalActiveMs >= ep.durationMs * 0.5
|
||||
? 'text-ctp-peach'
|
||||
: 'text-ctp-overlay2'
|
||||
}
|
||||
>
|
||||
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ctp-overlay2">{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-blue">
|
||||
{formatDuration(ep.totalActiveMs)}
|
||||
</td>
|
||||
<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-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) => {
|
||||
e.stopPropagation();
|
||||
void toggleWatched(ep.videoId, ep.watched);
|
||||
}}
|
||||
className={`w-5 h-5 rounded border transition-colors ${
|
||||
ep.watched
|
||||
? 'bg-ctp-green border-ctp-green text-ctp-base'
|
||||
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
|
||||
}`}
|
||||
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
|
||||
>
|
||||
{'\u2713'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
|
||||
}}
|
||||
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
|
||||
title="Delete episode"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
22
stats/src/components/layout/Tooltip.tsx
Normal file
22
stats/src/components/layout/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← Back to Library
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
Lifetime Sessions
|
||||
<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">Sessions</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</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">Watch Time</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
|
||||
{summary.allTimeMinutes < 60
|
||||
? `${summary.allTimeMinutes}m`
|
||||
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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)}
|
||||
</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">Active Days</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</div>
|
||||
</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="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
|
||||
{formatNumber(summary.allTimeHours)}
|
||||
</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">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>
|
||||
</div>
|
||||
<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="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</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">Episodes</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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)}
|
||||
</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">Anime</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
Lifetime Episodes
|
||||
</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>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</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>
|
||||
</div>
|
||||
<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="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,61 +119,104 @@ function CoverThumbnail({
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
onNavigateToMediaDetail,
|
||||
onNavigateToSession,
|
||||
onDelete,
|
||||
deleteDisabled,
|
||||
}: {
|
||||
session: SessionSummary;
|
||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
onDelete: () => void;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
const navigationTarget = getSessionNavigationTarget(session);
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={session.animeId}
|
||||
videoId={session.videoId}
|
||||
title={session.canonicalTitle ?? 'Unknown'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{session.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.cardsMined)}
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
videoId={session.videoId}
|
||||
title={session.canonicalTitle ?? 'Unknown'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{session.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.wordsSeen)}
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.cardsMined)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(displayWordCount)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleteDisabled}
|
||||
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Delete session"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimeGroupRow({
|
||||
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,91 +225,141 @@ function AnimeGroupRow({
|
||||
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={group.animeId}
|
||||
videoId={mostRecentSession.videoId}
|
||||
title={displayTitle}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalCards)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalWords)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
aria-hidden="true"
|
||||
<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 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
{'\u25B8'}
|
||||
</div>
|
||||
</button>
|
||||
<CoverThumbnail
|
||||
animeId={group.animeId}
|
||||
videoId={mostRecentSession.videoId}
|
||||
title={displayTitle}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalCards)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalWords)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{'\u25B8'}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteAnimeGroup(group)}
|
||||
disabled={groupDeleting}
|
||||
aria-label={`Delete all sessions for ${displayTitle}`}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/anime:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={`Delete all sessions for ${displayTitle}`}
|
||||
>
|
||||
{groupDeleting ? '\u2026' : '\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<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"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={s.animeId}
|
||||
videoId={s.videoId}
|
||||
title={s.canonicalTitle ?? 'Unknown'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
||||
{s.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.cardsMined)}
|
||||
<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}
|
||||
videoId={s.videoId}
|
||||
title={s.canonicalTitle ?? 'Unknown'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
||||
{s.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.wordsSeen)}
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.cardsMined)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(getSessionDisplayWordCount(s))}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</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>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
@@ -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,223 +83,524 @@ function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
|
||||
return regions;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
interface RatioChartPoint {
|
||||
tsMs: number;
|
||||
activity: number;
|
||||
knownPct: number;
|
||||
unknownPct: number;
|
||||
knownWords: number;
|
||||
unknownWords: number;
|
||||
totalWords: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
|
||||
const { timeline, events, loading, error } = useSessionDetail(sessionId);
|
||||
interface FallbackChartPoint {
|
||||
tsMs: number;
|
||||
totalWords: number;
|
||||
}
|
||||
|
||||
type TimelineEntry = {
|
||||
sampleMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
};
|
||||
|
||||
export function SessionDetail({ session }: SessionDetailProps) {
|
||||
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
if (loading) return <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}>
|
||||
<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>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="tsMs"
|
||||
type="number"
|
||||
domain={[tsMin, tsMax]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatTime}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={24}
|
||||
domain={[0, yMax]}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={formatTime}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'New words') return [`${value}`, 'New words'];
|
||||
if (name === 'Total words') return [`${value}`, 'Total words'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<FallbackView
|
||||
sorted={sorted}
|
||||
cardEvents={cardEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Pause shaded regions */}
|
||||
{pauseRegions.map((r, i) => (
|
||||
<ReferenceArea
|
||||
key={`pause-${i}`}
|
||||
yAxisId="left"
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
y1={0}
|
||||
y2={yMax}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
/* ── Ratio View (primary design) ────────────────────────────────── */
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
))}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
{/* Card mined markers */}
|
||||
{cardEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`card-${i}`}
|
||||
yAxisId="left"
|
||||
x={e.tsMs}
|
||||
stroke="#a6da95"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.8}
|
||||
label={{
|
||||
value: '⛏',
|
||||
position: 'top',
|
||||
fill: '#a6da95',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
if (chartData.length === 0) {
|
||||
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
|
||||
}
|
||||
|
||||
<Area
|
||||
yAxisId="left"
|
||||
dataKey="activity"
|
||||
stroke="#c6a0f6"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#actGrad-${sessionId})`}
|
||||
name="New words"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#c6a0f6', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
dataKey="totalWords"
|
||||
stroke="#8aadf4"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
name="Total words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
const tsMin = chartData[0]!.tsMs;
|
||||
const tsMax = chartData[chartData.length - 1]!.tsMs;
|
||||
const finalTotal = chartData[chartData.length - 1]!.totalWords;
|
||||
|
||||
<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))',
|
||||
}}
|
||||
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={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#a6da95" stopOpacity={0.15} />
|
||||
</linearGradient>
|
||||
<linearGradient id={`unknownGrad-${session.sessionId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.08} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid
|
||||
horizontal
|
||||
vertical={false}
|
||||
stroke="#494d64"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.4}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">New words</span>
|
||||
</span>
|
||||
<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)',
|
||||
|
||||
<XAxis
|
||||
dataKey="tsMs"
|
||||
type="number"
|
||||
domain={[tsMin, tsMax]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatTime}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="pct"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 50, 100]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={formatTime}
|
||||
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
|
||||
const d = props.payload;
|
||||
if (!d) return [_value, name];
|
||||
if (name === 'Known')
|
||||
return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known'];
|
||||
if (name === 'Unknown')
|
||||
return [
|
||||
`${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`,
|
||||
'Unknown',
|
||||
];
|
||||
return [_value, name];
|
||||
}}
|
||||
itemSorter={() => -1}
|
||||
/>
|
||||
|
||||
{/* Pause shaded regions */}
|
||||
{pauseRegions.map((r, i) => (
|
||||
<ReferenceArea
|
||||
key={`pause-${i}`}
|
||||
yAxisId="pct"
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
y1={0}
|
||||
y2={100}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Card mine markers */}
|
||||
{cardEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`card-${i}`}
|
||||
yAxisId="pct"
|
||||
x={e.tsMs}
|
||||
stroke="#a6da95"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.8}
|
||||
label={{
|
||||
value: '\u26CF',
|
||||
position: 'top',
|
||||
fill: '#a6da95',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</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 }}
|
||||
))}
|
||||
|
||||
{/* Yomitan lookup markers */}
|
||||
{yomitanLookupEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`yomitan-${i}`}
|
||||
yAxisId="pct"
|
||||
x={e.tsMs}
|
||||
stroke="#b7bdf8"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="2 3"
|
||||
strokeOpacity={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>
|
||||
))}
|
||||
|
||||
<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: '#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
|
||||
dataKey="totalWords"
|
||||
stroke="#8aadf4"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
name="Total words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<StatsBar
|
||||
hasKnownWords={false}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stats Bar ──────────────────────────────────────────────────── */
|
||||
|
||||
function StatsBar({
|
||||
hasKnownWords,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
session,
|
||||
lookupRate,
|
||||
}: {
|
||||
hasKnownWords: boolean;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
session: SessionSummary;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px] pt-1">
|
||||
{/* Group 1: Legend */}
|
||||
{hasKnownWords && (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ background: 'rgba(166,218,149,0.4)', border: '1px solid #a6da95' }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">Known</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ background: 'rgba(198,160,246,0.2)', border: '1px solid #c6a0f6' }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">Unknown</span>
|
||||
</span>
|
||||
<span className="text-ctp-surface2">|</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group 2: Playback stats */}
|
||||
{pauseCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-peach">{pauseCount}</span> pause
|
||||
{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{seekCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
|
||||
|
||||
{/* Group 3: Learning events */}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-0.5 rounded"
|
||||
style={{ background: '#b7bdf8', opacity: 0.8 }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{session.yomitanLookupCount} Yomitan lookup
|
||||
{session.yomitanLookupCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
{lookupRate && (
|
||||
<span className="text-ctp-overlay2">
|
||||
lookup rate: <span className="text-ctp-sapphire">{lookupRate.shortValue}</span>{' '}
|
||||
<span className="text-ctp-subtext0">({lookupRate.longValue})</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[12px]">{'\u26CF'}</span>
|
||||
<span className="text-ctp-green">
|
||||
{Math.max(cardEventCount, session.cardsMined)} card
|
||||
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface SessionRowProps {
|
||||
@@ -56,15 +57,17 @@ export function SessionRow({
|
||||
onDelete,
|
||||
deleteDisabled = false,
|
||||
}: SessionRowProps) {
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={detailsId}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={detailsId}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={session.animeId}
|
||||
videoId={session.videoId}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
@@ -358,60 +362,75 @@ export function WordDetailPanel({
|
||||
{formatNumber(occ.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||
<span>
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
|
||||
· session {occ.sessionId}
|
||||
</span>
|
||||
{occ.sourcePath &&
|
||||
occ.segmentStartMs != null &&
|
||||
occ.segmentEndMs != null &&
|
||||
(() => {
|
||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||
const wordStatus = mineStatus[`${baseKey}-word`];
|
||||
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
|
||||
const audioStatus = mineStatus[`${baseKey}-audio`];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={wordStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'word')}
|
||||
>
|
||||
{wordStatus?.loading
|
||||
? 'Mining...'
|
||||
: wordStatus?.success
|
||||
? 'Mined!'
|
||||
{(() => {
|
||||
const canMine =
|
||||
!!occ.sourcePath &&
|
||||
occ.segmentStartMs != 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`];
|
||||
const audioStatus = mineStatus[`${baseKey}-audio`];
|
||||
return (
|
||||
<>
|
||||
<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 || !!unavailableReason}
|
||||
onClick={() => void handleMine(occ, 'word')}
|
||||
>
|
||||
{wordStatus?.loading
|
||||
? 'Mining...'
|
||||
: wordStatus?.success
|
||||
? 'Mined!'
|
||||
: unavailableReason
|
||||
? 'Unavailable'
|
||||
: 'Mine Word'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'sentence')}
|
||||
>
|
||||
{sentenceStatus?.loading
|
||||
? 'Mining...'
|
||||
: sentenceStatus?.success
|
||||
? 'Mined!'
|
||||
</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 || !!unavailableReason}
|
||||
onClick={() => void handleMine(occ, 'sentence')}
|
||||
>
|
||||
{sentenceStatus?.loading
|
||||
? 'Mining...'
|
||||
: sentenceStatus?.success
|
||||
? 'Mined!'
|
||||
: unavailableReason
|
||||
? 'Unavailable'
|
||||
: 'Mine Sentence'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={audioStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'audio')}
|
||||
>
|
||||
{audioStatus?.loading
|
||||
? 'Mining...'
|
||||
: audioStatus?.success
|
||||
? 'Mined!'
|
||||
</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 || !!unavailableReason}
|
||||
onClick={() => void handleMine(occ, 'audio')}
|
||||
>
|
||||
{audioStatus?.loading
|
||||
? 'Mining...'
|
||||
: audioStatus?.success
|
||||
? 'Mined!'
|
||||
: unavailableReason
|
||||
? 'Unavailable'
|
||||
: 'Mine Audio'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||
|
||||
@@ -32,5 +32,5 @@ export function useOverview() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, sessions, loading, error };
|
||||
return { data, sessions, setSessions, loading, error };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]) => {
|
||||
if (cancelled) return;
|
||||
const now = new Date();
|
||||
const localMidnight = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const cutoffMs =
|
||||
range === 'all' ? null : localMidnight - (limitMap[range] - 1) * 86_400_000;
|
||||
const filteredSessions =
|
||||
cutoffMs == null ? sessions : sessions.filter((s) => s.startedAtMs >= cutoffMs);
|
||||
setData({
|
||||
rollups,
|
||||
episodesPerDay,
|
||||
newAnimePerDay,
|
||||
watchTimePerAnime,
|
||||
sessions: filteredSessions,
|
||||
animeLibrary,
|
||||
});
|
||||
},
|
||||
)
|
||||
getStatsClient()
|
||||
.getTrendsDashboard(range, groupBy)
|
||||
.then((nextData) => {
|
||||
if (cancelled) return;
|
||||
setData(nextData);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -35,6 +35,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
cardsMined: 2,
|
||||
lookupCount: 10,
|
||||
lookupHits: 8,
|
||||
yomitanLookupCount: 0,
|
||||
},
|
||||
];
|
||||
const rollups: DailyRollup[] = [
|
||||
@@ -56,7 +57,7 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
sessions,
|
||||
rollups,
|
||||
hints: {
|
||||
totalSessions: 1,
|
||||
totalSessions: 15,
|
||||
activeSessions: 0,
|
||||
episodesToday: 2,
|
||||
activeAnimeCount: 3,
|
||||
@@ -65,6 +66,10 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
totalActiveMin: 50,
|
||||
activeDays: 2,
|
||||
totalCards: 9,
|
||||
totalLookupCount: 100,
|
||||
totalLookupHits: 80,
|
||||
newWordsToday: 5,
|
||||
newWordsThisWeek: 20,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -74,15 +79,17 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
assert.equal(summary.episodesToday, 2);
|
||||
assert.equal(summary.activeAnimeCount, 3);
|
||||
assert.equal(summary.averageSessionMinutes, 50);
|
||||
assert.equal(summary.allTimeHours, 1);
|
||||
assert.equal(summary.allTimeMinutes, 50);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.totalSessions, 15);
|
||||
assert.equal(summary.lookupRate, 80);
|
||||
});
|
||||
|
||||
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
||||
const now = Date.UTC(2026, 2, 13, 12);
|
||||
const today = Math.floor(now / 86_400_000);
|
||||
const overview: OverviewData = {
|
||||
sessions: [
|
||||
const overview: OverviewData = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 2,
|
||||
canonicalTitle: 'B',
|
||||
@@ -99,6 +106,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
cardsMined: 10,
|
||||
lookupCount: 1,
|
||||
lookupHits: 1,
|
||||
yomitanLookupCount: 0,
|
||||
},
|
||||
],
|
||||
rollups: [
|
||||
@@ -117,7 +125,7 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
},
|
||||
],
|
||||
hints: {
|
||||
totalSessions: 999,
|
||||
totalSessions: 50,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
@@ -126,13 +134,16 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
totalActiveMin: 120,
|
||||
activeDays: 40,
|
||||
totalCards: 5,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildOverviewSummary(overview, now);
|
||||
assert.equal(summary.totalTrackedCards, 5);
|
||||
assert.equal(summary.totalSessions, 999);
|
||||
assert.equal(summary.allTimeHours, 2);
|
||||
assert.equal(summary.allTimeMinutes, 120);
|
||||
assert.equal(summary.activeDays, 40);
|
||||
});
|
||||
|
||||
@@ -150,6 +161,8 @@ test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
|
||||
pos2: null,
|
||||
pos3: null,
|
||||
frequency: 4,
|
||||
frequencyRank: null,
|
||||
animeCount: 1,
|
||||
firstSeen: nowSec - 2 * 86_400,
|
||||
lastSeen: nowSec - 1,
|
||||
},
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?`);
|
||||
}
|
||||
|
||||
39
stats/src/lib/media-session-list.test.tsx
Normal file
39
stats/src/lib/media-session-list.test.tsx
Normal 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/);
|
||||
});
|
||||
32
stats/src/lib/session-detail.test.tsx
Normal file
32
stats/src/lib/session-detail.test.tsx
Normal 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/);
|
||||
});
|
||||
8
stats/src/lib/session-word-count.ts
Normal file
8
stats/src/lib/session-word-count.ts
Normal 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;
|
||||
}
|
||||
103
stats/src/lib/stats-navigation.test.ts
Normal file
103
stats/src/lib/stats-navigation.test.ts
Normal 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);
|
||||
});
|
||||
139
stats/src/lib/stats-navigation.ts
Normal file
139
stats/src/lib/stats-navigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
stats/src/lib/stats-ui-navigation.test.tsx
Normal file
40
stats/src/lib/stats-ui-navigation.test.tsx
Normal 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/);
|
||||
});
|
||||
22
stats/src/lib/vocabulary-tab.test.ts
Normal file
22
stats/src/lib/vocabulary-tab.test.ts
Normal 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 ?? [], []);
|
||||
});
|
||||
171
stats/src/lib/yomitan-lookup.test.tsx
Normal file
171
stats/src/lib/yomitan-lookup.test.tsx
Normal 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</);
|
||||
});
|
||||
25
stats/src/lib/yomitan-lookup.ts
Normal file
25
stats/src/lib/yomitan-lookup.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user