feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

277
stats/src/App.tsx Normal file
View File

@@ -0,0 +1,277 @@
import { Suspense, lazy, useCallback, useState } from 'react';
import { TabBar } from './components/layout/TabBar';
import { OverviewTab } from './components/overview/OverviewTab';
import { useExcludedWords } from './hooks/useExcludedWords';
import type { TabId } from './components/layout/TabBar';
import {
closeMediaDetail,
createInitialStatsView,
navigateToAnime as navigateToAnimeState,
navigateToSession as navigateToSessionState,
openAnimeEpisodeDetail,
openOverviewMediaDetail,
openSessionsMediaDetail,
switchTab,
} from './lib/stats-navigation';
const AnimeTab = lazy(() =>
import('./components/anime/AnimeTab').then((module) => ({
default: module.AnimeTab,
})),
);
const TrendsTab = lazy(() =>
import('./components/trends/TrendsTab').then((module) => ({
default: module.TrendsTab,
})),
);
const VocabularyTab = lazy(() =>
import('./components/vocabulary/VocabularyTab').then((module) => ({
default: module.VocabularyTab,
})),
);
const SessionsTab = lazy(() =>
import('./components/sessions/SessionsTab').then((module) => ({
default: module.SessionsTab,
})),
);
const MediaDetailView = lazy(() =>
import('./components/library/MediaDetailView').then((module) => ({
default: module.MediaDetailView,
})),
);
const WordDetailPanel = lazy(() =>
import('./components/vocabulary/WordDetailPanel').then((module) => ({
default: module.WordDetailPanel,
})),
);
function LoadingSurface({ label, overlay = false }: { label: string; overlay?: boolean }) {
return (
<div
aria-busy="true"
className={
overlay
? 'fixed inset-x-4 bottom-4 z-50 rounded-xl border border-ctp-surface1 bg-ctp-mantle/95 p-4 text-sm text-ctp-overlay2 shadow-2xl backdrop-blur'
: 'rounded-xl border border-ctp-surface1 bg-ctp-surface0/70 p-4 text-sm text-ctp-overlay2'
}
>
{label}
</div>
);
}
export function App() {
const [viewState, setViewState] = useState(createInitialStatsView);
const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview']));
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) => {
setViewState((prev) => switchTab(prev, tabId));
setMountedTabs((prev) => {
if (prev.has(tabId)) return prev;
const next = new Set(prev);
next.add(tabId);
return next;
});
}, []);
const navigateToAnime = useCallback((animeId: number) => {
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) => {
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 navigateToSessionsMediaDetail = useCallback((videoId: number) => {
setViewState((prev) => openSessionsMediaDetail(prev, videoId));
}, []);
const openWordDetail = useCallback((wordId: number) => {
setGlobalWordId(wordId);
}, []);
const handleTabChange = useCallback(
(tabId: TabId) => {
activateTab(tabId);
},
[activateTab],
);
return (
<div className="min-h-screen flex flex-col bg-ctp-base">
<header className="px-4 pt-3 pb-0">
<button
type="button"
onClick={() => handleTabChange('overview')}
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
>
<img src="/favicon.png" alt="" className="h-6 object-contain" />
<h1 className="text-lg font-semibold text-ctp-text">SubMiner Stats</h1>
</button>
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
</header>
<main className="flex-1 overflow-y-auto p-4">
{mediaDetail ? (
<Suspense fallback={<LoadingSurface label="Loading media detail..." />}>
<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'
: mediaDetail.origin.type === 'sessions'
? 'Back to Sessions'
: 'Back to Library'
}
onNavigateToAnime={navigateToAnime}
/>
</Suspense>
) : (
<>
{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"
>
<Suspense fallback={<LoadingSurface label="Loading library..." />}>
<AnimeTab
initialAnimeId={selectedAnimeId}
onClearInitialAnime={() =>
setViewState((prev) => ({ ...prev, selectedAnimeId: null }))
}
onNavigateToWord={openWordDetail}
onOpenEpisodeDetail={navigateToEpisodeDetail}
/>
</Suspense>
</section>
) : null}
{mountedTabs.has('trends') ? (
<section
id="panel-trends"
role="tabpanel"
aria-labelledby="tab-trends"
hidden={activeTab !== 'trends'}
className="animate-fade-in"
>
<Suspense fallback={<LoadingSurface label="Loading trends..." />}>
<TrendsTab />
</Suspense>
</section>
) : null}
{mountedTabs.has('vocabulary') ? (
<section
id="panel-vocabulary"
role="tabpanel"
aria-labelledby="tab-vocabulary"
hidden={activeTab !== 'vocabulary'}
className="animate-fade-in"
>
<Suspense fallback={<LoadingSurface label="Loading vocabulary..." />}>
<VocabularyTab
onNavigateToAnime={navigateToAnime}
onOpenWordDetail={openWordDetail}
excluded={excluded}
isExcluded={isExcluded}
onRemoveExclusion={removeExclusion}
onClearExclusions={clearAll}
/>
</Suspense>
</section>
) : null}
{mountedTabs.has('sessions') ? (
<section
id="panel-sessions"
role="tabpanel"
aria-labelledby="tab-sessions"
hidden={activeTab !== 'sessions'}
className="animate-fade-in"
>
<Suspense fallback={<LoadingSurface label="Loading sessions..." />}>
<SessionsTab
initialSessionId={focusedSessionId}
onClearInitialSession={() =>
setViewState((prev) => ({ ...prev, focusedSessionId: null }))
}
onNavigateToMediaDetail={navigateToSessionsMediaDetail}
/>
</Suspense>
</section>
) : null}
</>
)}
</main>
{globalWordId !== null ? (
<Suspense fallback={<LoadingSurface label="Loading word detail..." overlay />}>
<WordDetailPanel
wordId={globalWordId}
onClose={() => setGlobalWordId(null)}
onSelectWord={openWordDetail}
onNavigateToAnime={navigateToAnime}
isExcluded={isExcluded}
onToggleExclusion={toggleExclusion}
/>
</Suspense>
) : null}
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useEffect, useRef } from 'react';
import { apiClient } from '../../lib/api-client';
interface AnilistMedia {
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
description: string | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}
interface AnilistSelectorProps {
animeId: number;
initialQuery: string;
onClose: () => void;
onLinked: () => void;
}
export function AnilistSelector({
animeId,
initialQuery,
onClose,
onLinked,
}: AnilistSelectorProps) {
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<AnilistMedia[]>([]);
const [loading, setLoading] = useState(false);
const [linking, setLinking] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
inputRef.current?.focus();
if (initialQuery) doSearch(initialQuery);
}, []);
const doSearch = async (q: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const data = await apiClient.searchAnilist(q.trim());
setResults(data);
} catch {
setResults([]);
}
setLoading(false);
};
const handleInput = (value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 400);
};
const handleSelect = async (media: AnilistMedia) => {
setLinking(media.id);
try {
await apiClient.reassignAnimeAnilist(animeId, {
anilistId: media.id,
titleRomaji: media.title?.romaji ?? null,
titleEnglish: media.title?.english ?? null,
titleNative: media.title?.native ?? null,
episodesTotal: media.episodes ?? null,
description: media.description ?? null,
coverUrl: media.coverImage?.large ?? media.coverImage?.medium ?? null,
});
onLinked();
} catch {
setLinking(null);
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Select AniList Entry</h3>
<button
type="button"
onClick={onClose}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
>
{'\u2715'}
</button>
</div>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleInput(e.target.value)}
placeholder="Search AniList..."
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
</div>
<div className="flex-1 overflow-y-auto p-2">
{loading && <div className="text-xs text-ctp-overlay2 p-3">Searching...</div>}
{!loading && results.length === 0 && query.trim() && (
<div className="text-xs text-ctp-overlay2 p-3">No results</div>
)}
{results.map((media) => (
<button
key={media.id}
type="button"
disabled={linking !== null}
onClick={() => void handleSelect(media)}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
>
{media.coverImage?.medium ? (
<img
src={media.coverImage.medium}
alt=""
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface1"
/>
) : (
<div className="w-10 h-14 rounded bg-ctp-surface1 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{media.title?.romaji ?? media.title?.english ?? 'Unknown'}
</div>
{media.title?.english && media.title.english !== media.title.romaji && (
<div className="text-xs text-ctp-subtext0 truncate">{media.title.english}</div>
)}
<div className="text-xs text-ctp-overlay2 mt-0.5">
{media.episodes ? `${media.episodes} eps` : 'Unknown eps'}
{media.seasonYear ? ` · ${media.season ?? ''} ${media.seasonYear}` : ''}
</div>
</div>
{linking === media.id ? (
<span className="text-xs text-ctp-blue shrink-0">Linking...</span>
) : (
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
)}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { AnimeCoverImage } from './AnimeCoverImage';
import { formatDuration, formatNumber } from '../../lib/formatters';
import type { AnimeLibraryItem } from '../../types/stats';
interface AnimeCardProps {
anime: AnimeLibraryItem;
onClick: () => void;
}
export function AnimeCard({ anime, onClick }: AnimeCardProps) {
return (
<button
type="button"
onClick={onClick}
className="group bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-blue/50 hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full"
>
<div className="overflow-hidden">
<AnimeCoverImage
animeId={anime.animeId}
title={anime.canonicalTitle}
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
/>
</div>
<div className="p-3">
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
<div className="text-xs text-ctp-overlay2 mt-1">
{anime.episodeCount} episode{anime.episodeCount !== 1 ? 's' : ''}
</div>
<div className="text-xs text-ctp-overlay2">
{formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,74 @@
import { Fragment, useState } from 'react';
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
import { CollapsibleSection } from './CollapsibleSection';
import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats';
interface AnimeCardsListProps {
episodes: AnimeEpisode[];
totalCards: number;
}
export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
if (totalCards === 0) {
return (
<CollapsibleSection title="Cards Mined (0)" defaultOpen={false}>
<p className="text-sm text-ctp-overlay2">No cards mined from this anime yet.</p>
</CollapsibleSection>
);
}
const withCards = episodes.filter((ep) => ep.totalCards > 0);
return (
<CollapsibleSection title={`Cards Mined (${formatNumber(totalCards)})`} defaultOpen={false}>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="w-6 py-2 pr-1 font-medium" />
<th className="text-left py-2 pr-3 font-medium">Episode</th>
<th className="text-right py-2 pr-3 font-medium">Cards</th>
<th className="text-right py-2 font-medium">Last Watched</th>
</tr>
</thead>
<tbody>
{withCards.map((ep) => (
<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"
>
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
{expandedVideoId === ep.videoId ? '▼' : '▶'}
</td>
<td className="py-2 pr-3 text-ctp-text truncate max-w-[300px]">
<span className="text-ctp-subtext0 mr-2">
{ep.episode != null ? `#${ep.episode}` : ''}
</span>
{ep.canonicalTitle}
</td>
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
{formatNumber(ep.totalCards)}
</td>
<td className="py-2 text-right text-ctp-overlay2">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
</td>
</tr>
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={4} className="py-2">
<EpisodeDetail videoId={ep.videoId} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</CollapsibleSection>
);
}

View File

@@ -0,0 +1,35 @@
import { useState } from 'react';
import { getStatsClient } from '../../hooks/useStatsApi';
interface AnimeCoverImageProps {
animeId: number;
title: string;
className?: string;
}
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
const src = getStatsClient().getAnimeCoverUrl(animeId);
return (
<img
src={src}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
);
}

View File

@@ -0,0 +1,186 @@
import { useState, useEffect } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi';
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';
interface AnimeDetailViewProps {
animeId: number;
onBack: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (videoId: number) => void;
}
type Range = 14 | 30 | 90;
function formatActiveMinutes(value: number | string) {
const minutes = Number(value);
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
}
function AnimeWatchChart({ animeId }: { animeId: number }) {
const [rollups, setRollups] = useState<DailyRollup[]>([]);
const [range, setRange] = useState<Range>(30);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getAnimeRollups(animeId, 90)
.then((data) => {
if (!cancelled) setRollups(data);
})
.catch(() => {
if (!cancelled) setRollups([]);
});
return () => {
cancelled = true;
};
}, [animeId]);
const byDay = new Map<number, number>();
for (const r of rollups) {
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
}
const chartData = Array.from(byDay.entries())
.sort(([a], [b]) => a - b)
.map(([day, mins]) => ({
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
minutes: Math.round(mins),
}))
.slice(-range);
const ranges: Range[] = [14, 30, 90];
if (chartData.length === 0) return null;
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
<div className="flex gap-1">
{ranges.map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-2 py-0.5 text-xs rounded ${
range === r
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{r}d
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}>
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
/>
<Tooltip
contentStyle={{
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
}}
labelStyle={{ color: CHART_THEME.tooltipLabel }}
formatter={formatActiveMinutes}
/>
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
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;
return (
<div className="space-y-4">
<button
type="button"
onClick={onBack}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
>
&larr; Back to Library
</button>
<AnimeHeader
detail={detail}
anilistEntries={anilistEntries ?? []}
onChangeAnilist={() => setShowAnilistSelector(true)}
/>
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList
episodes={episodes}
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
/>
<AnimeWatchChart animeId={animeId} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
{showAnilistSelector && (
<AnilistSelector
animeId={animeId}
initialQuery={detail.canonicalTitle}
onClose={() => setShowAnilistSelector(false)}
onLinked={() => {
setShowAnilistSelector(false);
reload();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
interface AnimeHeaderProps {
detail: AnimeDetailData['detail'];
anilistEntries: AnilistEntry[];
onChangeAnilist?: () => void;
}
function AnilistButton({ entry }: { entry: AnilistEntry }) {
const label =
entry.season != null
? `Season ${entry.season}`
: (entry.titleEnglish ?? entry.titleRomaji ?? 'AniList');
return (
<a
href={`https://anilist.co/anime/${entry.anilistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
>
{label}
<span className="text-[10px]">{'\u2197'}</span>
</a>
);
}
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
(t): t is string => t != null && t !== detail.canonicalTitle,
);
const uniqueAltTitles = [...new Set(altTitles)];
const hasMultipleEntries = anilistEntries.length > 1;
return (
<div className="flex gap-4">
<AnimeCoverImage
animeId={detail.animeId}
title={detail.canonicalTitle}
className="w-32 h-44 rounded-lg shrink-0"
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
{uniqueAltTitles.length > 0 && (
<div className="text-xs text-ctp-overlay2 mt-0.5 truncate">
{uniqueAltTitles.join(' · ')}
</div>
)}
<div className="text-sm text-ctp-subtext0 mt-2">
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{anilistEntries.length > 0 ? (
hasMultipleEntries ? (
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
) : (
<a
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
>
View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a>
)
) : detail.anilistId ? (
<a
href={`https://anilist.co/anime/${detail.anilistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
>
View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a>
) : null}
{onChangeAnilist && (
<button
type="button"
onClick={onChangeAnilist}
title="Search AniList and manually select the correct anime entry"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
>
{anilistEntries.length > 0 || detail.anilistId
? 'Change AniList Entry'
: 'Link to AniList'}
</button>
)}
</div>
{detail.description && (
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
{detail.description}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
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.totalTokensSeen);
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.totalTokensSeen)}
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-cards-mined"
tooltip="Anki cards created from subtitle lines in this anime"
/>
<Metric
label="Lookups"
value={formatNumber(detail.totalYomitanLookupCount)}
color="text-ctp-lavender"
tooltip="Total Yomitan dictionary lookups during sessions"
/>
{lookupRate ? (
<Metric
label="Lookup Rate"
value={lookupRate.shortValue}
color="text-ctp-sapphire"
tooltip="Yomitan lookups per 100 words seen"
/>
) : (
<Metric
label="Lookup Rate"
value="—"
color="text-ctp-overlay2"
tooltip="No lookups recorded yet"
/>
)}
{knownPct !== null ? (
<Metric
label="Known Words"
value={`${knownPct}%`}
color="text-ctp-green"
tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this anime`}
/>
) : (
<Metric
label="Known Words"
value="—"
color="text-ctp-overlay2"
tooltip="No word data available yet"
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { useState, useMemo, useEffect } from 'react';
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
import { formatDuration } from '../../lib/formatters';
import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
type CardSize = 'sm' | 'md' | 'lg';
const GRID_CLASSES: Record<CardSize, string> = {
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
};
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: 'lastWatched', label: 'Last Watched' },
{ key: 'watchTime', label: 'Watch Time' },
{ key: 'cards', label: 'Cards' },
{ key: 'episodes', label: 'Episodes' },
];
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
return [...list].sort((a, b) => {
switch (key) {
case 'lastWatched':
return b.lastWatchedMs - a.lastWatchedMs;
case 'watchTime':
return b.totalActiveMs - a.totalActiveMs;
case 'cards':
return b.totalCards - a.totalCards;
case 'episodes':
return b.episodeCount - a.episodeCount;
}
});
}
interface AnimeTabProps {
initialAnimeId?: number | null;
onClearInitialAnime?: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (animeId: number, videoId: number) => void;
}
export function AnimeTab({
initialAnimeId,
onClearInitialAnime,
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<CardSize>('md');
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
useEffect(() => {
if (initialAnimeId != null) {
setSelectedAnimeId(initialAnimeId);
onClearInitialAnime?.();
}
}, [initialAnimeId, onClearInitialAnime]);
const filtered = useMemo(() => {
const base = search.trim()
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
: anime;
return sortAnime(base, sortKey);
}, [anime, search, sortKey]);
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
if (selectedAnimeId !== null) {
return (
<AnimeDetailView
animeId={selectedAnimeId}
onBack={() => setSelectedAnimeId(null)}
onNavigateToWord={onNavigateToWord}
onOpenEpisodeDetail={
onOpenEpisodeDetail
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
/>
);
}
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>;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search anime..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.label}
</option>
))}
</select>
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
{(['sm', 'md', 'lg'] as const).map((size) => (
<button
key={size}
onClick={() => setCardSize(size)}
className={`px-2 py-1 rounded-md text-xs transition-colors ${
cardSize === size
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{size === 'sm' ? '▪' : size === 'md' ? '◼' : '⬛'}
</button>
))}
</div>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} anime · {formatDuration(totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
) : (
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
{filtered.map((item) => (
<AnimeCard
key={item.animeId}
anime={item}
onClick={() => setSelectedAnimeId(item.animeId)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from '../../hooks/useStatsApi';
import { formatNumber } from '../../lib/formatters';
import { CollapsibleSection } from './CollapsibleSection';
import type { AnimeWord } from '../../types/stats';
interface AnimeWordListProps {
animeId: number;
onNavigateToWord?: (wordId: number) => void;
}
export function AnimeWordList({ animeId, onNavigateToWord }: AnimeWordListProps) {
const [words, setWords] = useState<AnimeWord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
getStatsClient()
.getAnimeWords(animeId, 50)
.then((data) => {
if (!cancelled) setWords(data);
})
.catch(() => {
if (!cancelled) setWords([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [animeId]);
if (loading) return <div className="text-ctp-overlay2 text-sm p-4">Loading words...</div>;
if (words.length === 0) return null;
return (
<CollapsibleSection title={`Top Words (${words.length})`} defaultOpen={false}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{words.map((w) => (
<button
key={w.wordId}
type="button"
onClick={() => onNavigateToWord?.(w.wordId)}
className="bg-ctp-base border border-ctp-surface1 rounded-md p-2 hover:border-ctp-blue transition-colors cursor-pointer text-left"
>
<div className="text-sm font-medium text-ctp-text">{w.headword}</div>
{w.reading && w.reading !== w.headword && (
<div className="text-xs text-ctp-overlay2">{w.reading}</div>
)}
<div className="flex items-center gap-2 mt-1">
{w.partOfSpeech && (
<span className="text-[10px] px-1.5 py-0.5 bg-ctp-surface1 text-ctp-subtext0 rounded">
{w.partOfSpeech}
</span>
)}
<span className="text-xs text-ctp-mauve ml-auto">{formatNumber(w.frequency)}</span>
</div>
</button>
))}
</div>
</CollapsibleSection>
);
}

View File

@@ -0,0 +1,38 @@
import { useId, useState } from 'react';
interface CollapsibleSectionProps {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
export function CollapsibleSection({
title,
defaultOpen = true,
children,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
const contentId = useId();
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={contentId}
className="w-full flex items-center justify-between p-4 text-left"
>
<h3 className="text-sm font-semibold text-ctp-text">{title}</h3>
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">
{open ? '▲' : '▼'}
</span>
</button>
{open && (
<div id={contentId} className="px-4 pb-4">
{children}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect } from 'react';
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 {
videoId: number;
onSessionDeleted?: () => void;
}
interface NoteInfo {
noteId: number;
expression: string;
}
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
const [data, setData] = useState<EpisodeDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
useEffect(() => {
let cancelled = false;
setLoading(true);
getStatsClient()
.getEpisodeDetail(videoId)
.then((d) => {
if (cancelled) return;
setData(d);
const allNoteIds = d.cardEvents.flatMap((ev) => ev.noteIds);
if (allNoteIds.length > 0) {
getStatsClient()
.ankiNotesInfo(allNoteIds)
.then((notes) => {
if (cancelled) return;
const map = new Map<number, NoteInfo>();
for (const note of notes) {
const expr = note.preview?.word ?? '';
map.set(note.noteId, { noteId: note.noteId, expression: expr });
}
setNoteInfos(map);
})
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
}
})
.catch(() => {
if (!cancelled) setData(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [videoId]);
const handleDeleteSession = async (sessionId: number) => {
if (!confirmSessionDelete()) return;
await apiClient.deleteSession(sessionId);
setData((prev) => {
if (!prev) return prev;
return { ...prev, sessions: prev.sessions.filter((s) => s.sessionId !== sessionId) };
});
onSessionDeleted?.();
};
if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>;
if (!data)
return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
const { sessions, cardEvents } = data;
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
{sessions.length > 0 && (
<div className="p-3 border-b border-ctp-surface1">
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Sessions</h4>
<div className="space-y-1">
{sessions.map((s) => (
<div key={s.sessionId} className="flex items-center gap-3 text-xs group">
<span className="text-ctp-overlay2">
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
</span>
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
<span className="text-ctp-cards-mined">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-peach">
{formatNumber(getSessionDisplayWordCount(s))} words
</span>
<span className="text-ctp-green">{formatNumber(s.knownWordsSeen)} known words</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteSession(s.sessionId);
}}
className="ml-auto opacity-0 group-hover:opacity-100 text-ctp-red/70 hover:text-ctp-red transition-opacity text-[10px] px-1.5 py-0.5 rounded hover:bg-ctp-red/10"
title="Delete session"
>
Delete
</button>
</div>
))}
</div>
</div>
)}
{cardEvents.length > 0 && (
<div className="p-3 border-b border-ctp-surface1">
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
<div className="space-y-1.5">
{cardEvents.map((ev) => (
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
{ev.noteIds.length > 0 ? (
ev.noteIds.map((noteId) => {
const info = noteInfos.get(noteId);
return (
<div key={noteId} className="flex items-center gap-2 min-w-0 flex-1">
{info?.expression && (
<span className="text-ctp-text font-medium truncate">
{info.expression}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
getStatsClient().ankiBrowse(noteId);
}}
className="px-1.5 py-0.5 bg-ctp-surface1 text-ctp-blue rounded text-[10px] hover:bg-ctp-surface2 transition-colors cursor-pointer shrink-0 ml-auto"
>
Open in Anki
</button>
</div>
);
})
) : (
<span className="text-ctp-cards-mined">
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
</span>
)}
</div>
))}
</div>
</div>
)}
{sessions.length === 0 && cardEvents.length === 0 && (
<div className="p-3 text-xs text-ctp-overlay2">No detailed data available.</div>
)}
</div>
);
}

View File

@@ -0,0 +1,196 @@
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,
onOpenDetail,
}: EpisodeListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
const [episodes, setEpisodes] = useState(initialEpisodes);
if (episodes.length === 0) return null;
const sorted = [...episodes].sort((a, b) => {
if (a.episode != null && b.episode != null) return a.episode - b.episode;
if (a.episode != null) return -1;
if (b.episode != null) return 1;
return 0;
});
const toggleWatched = async (videoId: number, currentWatched: number) => {
const newWatched = currentWatched ? 0 : 1;
setEpisodes((prev) =>
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: newWatched } : ep)),
);
try {
await apiClient.setVideoWatched(videoId, newWatched === 1);
} catch {
setEpisodes((prev) =>
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: currentWatched } : ep)),
);
}
};
const handleDeleteEpisode = async (videoId: number, title: string) => {
if (!confirmEpisodeDelete(title)) return;
await apiClient.deleteVideo(videoId);
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
if (expandedVideoId === videoId) setExpandedVideoId(null);
onEpisodeDeleted?.();
};
const watchedCount = episodes.filter((ep) => ep.watched).length;
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Episodes</h3>
<span className="text-xs text-ctp-overlay2">
{watchedCount}/{episodes.length} watched
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="w-6 py-2 pr-1 font-medium" />
<th className="text-left py-2 pr-3 font-medium">#</th>
<th className="text-left py-2 pr-3 font-medium">Title</th>
<th className="text-right py-2 pr-3 font-medium">Progress</th>
<th className="text-right py-2 pr-3 font-medium">Watch Time</th>
<th className="text-right py-2 pr-3 font-medium">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-28 py-2 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((ep, idx) => {
const lookupRate = buildLookupRateDisplay(
ep.totalYomitanLookupCount,
ep.totalTokensSeen,
);
const progressPct =
ep.durationMs > 0 && ep.endedMediaMs != null
? Math.min(100, Math.round((ep.endedMediaMs / ep.durationMs) * 100))
: null;
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">
{progressPct != null ? (
<span
className={
progressPct >= 85
? 'text-ctp-green'
: progressPct >= 50
? 'text-ctp-peach'
: 'text-ctp-overlay2'
}
>
{progressPct}%
</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-cards-mined">
{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>
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={9} className="py-2">
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
interface StatCardProps {
label: string;
value: string;
subValue?: string;
color?: string;
trend?: { direction: 'up' | 'down' | 'flat'; text: string };
}
const COLOR_TO_BORDER: Record<string, string> = {
'text-ctp-blue': 'border-l-ctp-blue',
'text-ctp-green': 'border-l-ctp-green',
'text-ctp-mauve': 'border-l-ctp-mauve',
'text-ctp-peach': 'border-l-ctp-peach',
'text-ctp-teal': 'border-l-ctp-teal',
'text-ctp-lavender': 'border-l-ctp-lavender',
'text-ctp-red': 'border-l-ctp-red',
'text-ctp-yellow': 'border-l-ctp-yellow',
'text-ctp-sapphire': 'border-l-ctp-sapphire',
'text-ctp-sky': 'border-l-ctp-sky',
'text-ctp-flamingo': 'border-l-ctp-flamingo',
'text-ctp-maroon': 'border-l-ctp-maroon',
'text-ctp-pink': 'border-l-ctp-pink',
'text-ctp-text': 'border-l-ctp-surface2',
};
export function StatCard({
label,
value,
subValue,
color = 'text-ctp-text',
trend,
}: StatCardProps) {
const borderClass = COLOR_TO_BORDER[color] ?? 'border-l-ctp-surface2';
return (
<div
className={`bg-ctp-surface0 border border-ctp-surface1 border-l-[3px] ${borderClass} rounded-lg p-4 text-center`}
>
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>{value}</div>
<div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div>
{subValue && <div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>}
{trend && (
<div
className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}
>
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'}{' '}
{trend.text}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useRef, type KeyboardEvent } from 'react';
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
interface Tab {
id: TabId;
label: string;
}
const TABS: Tab[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'anime', label: 'Library' },
{ id: 'trends', label: 'Trends' },
{ id: 'vocabulary', label: 'Vocabulary' },
{ id: 'sessions', label: 'Sessions' },
];
interface TabBarProps {
activeTab: TabId;
onTabChange: (tabId: TabId) => void;
}
export function TabBar({ activeTab, onTabChange }: TabBarProps) {
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const activateAtIndex = (index: number) => {
const tab = TABS[index];
if (!tab) return;
tabRefs.current[index]?.focus();
onTabChange(tab.id);
};
const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
activateAtIndex((index + 1) % TABS.length);
return;
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
activateAtIndex((index - 1 + TABS.length) % TABS.length);
return;
}
if (event.key === 'Home') {
event.preventDefault();
activateAtIndex(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
activateAtIndex(TABS.length - 1);
}
};
return (
<nav
className="flex border-b border-ctp-surface1"
role="tablist"
aria-label="Stats tabs"
aria-orientation="horizontal"
>
{TABS.map((tab, index) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
ref={(element) => {
tabRefs.current[index] = element;
}}
type="button"
role="tab"
aria-controls={`panel-${tab.id}`}
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onTabChange(tab.id)}
onKeyDown={(event) => onTabKeyDown(event, index)}
className={`px-4 py-2.5 text-sm font-medium transition-colors
${
activeTab === tab.id
? 'text-ctp-text border-b-2 border-ctp-lavender'
: 'text-ctp-subtext0 hover:text-ctp-subtext1'
}`}
>
{tab.label}
</button>
))}
</nav>
);
}

View File

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

View File

@@ -0,0 +1,32 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
interface CoverImageProps {
videoId: number;
title: string;
className?: string;
}
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
if (failed) {
return (
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);
}
return (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
alt={title}
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>
);
}

View File

@@ -0,0 +1,67 @@
import { useState, useMemo } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration } from '../../lib/formatters';
import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView';
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);
const filtered = useMemo(() => {
if (!search.trim()) return media;
const q = search.toLowerCase();
return media.filter((m) => m.canonicalTitle.toLowerCase().includes(q));
}, [media, search]);
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
if (selectedVideoId !== null) {
return (
<MediaDetailView
videoId={selectedVideoId}
onBack={() => setSelectedVideoId(null)}
onNavigateToSession={onNavigateToSession}
/>
);
}
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>;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search titles..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filtered.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber } from '../../lib/formatters';
import type { MediaLibraryItem } from '../../types/stats';
interface MediaCardProps {
item: MediaLibraryItem;
onClick: () => void;
}
export function MediaCard({ item, onClick }: MediaCardProps) {
return (
<button
type="button"
onClick={onClick}
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-surface2 transition-colors text-left w-full"
>
<CoverImage
videoId={item.videoId}
title={item.canonicalTitle}
className="w-full aspect-[3/4] rounded-t-lg"
/>
<div className="p-3">
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
<div className="text-xs text-ctp-overlay2 mt-1">
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
</div>
<div className="text-xs text-ctp-overlay2">
{item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,105 @@
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 { MediaSessionList } from './MediaSessionList';
import type { SessionSummary } from '../../types/stats';
interface MediaDetailViewProps {
videoId: number;
initialExpandedSessionId?: number | null;
onConsumeInitialExpandedSession?: () => void;
onBack: () => void;
backLabel?: string;
onNavigateToAnime?: (animeId: number) => void;
}
export function MediaDetailView({
videoId,
initialExpandedSessionId = null,
onConsumeInitialExpandedSession,
onBack,
backLabel = 'Back to Library',
onNavigateToAnime,
}: 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 animeId = data.detail.animeId;
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),
totalTokensSeen: 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">
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
>
&larr; {backLabel}
</button>
{onNavigateToAnime != null && animeId != null ? (
<button
type="button"
onClick={() => onNavigateToAnime(animeId)}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
>
View Anime &rarr;
</button>
) : null}
</div>
<MediaHeader detail={detail} />
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList
sessions={sessions}
onDeleteSession={handleDeleteSession}
deletingSessionId={deletingSessionId}
initialExpandedSessionId={initialExpandedSessionId}
onConsumeInitialExpandedSession={onConsumeInitialExpandedSession}
/>
</div>
);
}

View File

@@ -0,0 +1,113 @@
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, 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.totalTokensSeen);
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">
<CoverImage
videoId={detail.videoId}
title={detail.canonicalTitle}
className="w-32 h-44 rounded-lg shrink-0"
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
<div className="grid grid-cols-2 gap-2 mt-3 text-sm">
<div>
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>
<div className="text-xs text-ctp-overlay2">total watch time</div>
</div>
<div>
<div className="text-ctp-cards-mined font-medium">
{formatNumber(detail.totalCards)}
</div>
<div className="text-xs text-ctp-overlay2">cards mined</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalTokensSeen)}</div>
<div className="text-xs text-ctp-overlay2">word occurrences</div>
</div>
<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 word match rate</div>
</div>
)}
<div>
<div className="text-ctp-text font-medium">{detail.totalSessions}</div>
<div className="text-xs text-ctp-overlay2">sessions</div>
</div>
<div>
<div className="text-ctp-text font-medium">{formatDuration(avgSessionMs)}</div>
<div className="text-xs text-ctp-overlay2">avg session</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
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,
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>;
}
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-ctp-text">Session History</h3>
{sessions.map((s) => (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={`media-session-details-${s.sessionId}`}
onToggle={() =>
setExpandedId((current) => (current === s.sessionId ? null : s.sessionId))
}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
/>
{expandedId === s.sessionId ? (
<div id={`media-session-details-${s.sessionId}`}>
<SessionDetail session={s} />
</div>
) : null}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
import { CHART_THEME } from '../../lib/chart-theme';
import type { DailyRollup } from '../../types/stats';
interface MediaWatchChartProps {
rollups: DailyRollup[];
}
type Range = 14 | 30 | 90;
function formatActiveMinutes(value: number | string) {
const minutes = Number(value);
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
}
export function MediaWatchChart({ rollups }: MediaWatchChartProps) {
const [range, setRange] = useState<Range>(30);
const byDay = new Map<number, number>();
for (const r of rollups) {
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
}
const chartData = Array.from(byDay.entries())
.sort(([a], [b]) => a - b)
.map(([day, mins]) => ({
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
minutes: Math.round(mins),
}))
.slice(-range);
const ranges: Range[] = [14, 30, 90];
if (chartData.length === 0) {
return null;
}
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
<div className="flex gap-1">
{ranges.map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-2 py-0.5 text-xs rounded ${
range === r
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{r}d
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}>
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
/>
<Tooltip
contentStyle={{
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
}}
labelStyle={{ color: CHART_THEME.tooltipLabel }}
formatter={formatActiveMinutes}
/>
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { StatCard } from '../layout/StatCard';
import { formatDuration, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
import type { OverviewSummary } from '../../lib/dashboard-data';
import type { SessionSummary } from '../../types/stats';
interface HeroStatsProps {
summary: OverviewSummary;
sessions: SessionSummary[];
}
export function HeroStats({ summary, sessions }: HeroStatsProps) {
const today = todayLocalDay();
const sessionsToday = sessions.filter((s) => localDayFromMs(s.startedAtMs) === today).length;
return (
<div className="grid grid-cols-2 xl:grid-cols-6 gap-3">
<StatCard
label="Watch Time Today"
value={formatDuration(summary.todayActiveMs)}
color="text-ctp-blue"
/>
<StatCard
label="Cards Mined Today"
value={formatNumber(summary.todayCards)}
color="text-ctp-cards-mined"
/>
<StatCard
label="Sessions Today"
value={formatNumber(sessionsToday)}
color="text-ctp-lavender"
/>
<StatCard
label="Episodes Today"
value={formatNumber(summary.episodesToday)}
color="text-ctp-teal"
/>
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
<StatCard
label="Active Anime"
value={formatNumber(summary.activeAnimeCount)}
color="text-ctp-mauve"
/>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { useState, useEffect } from 'react';
import { useOverview } from '../../hooks/useOverview';
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
import { HeroStats } from './HeroStats';
import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
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({ 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>;
if (!data) return null;
const summary = buildOverviewSummary(data);
const streakData = buildStreakCalendar(calendar);
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
return (
<div className="space-y-4">
<HeroStats summary={summary} sessions={sessions} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TrendChart
title="Last 14 Days Watch Time (min)"
data={summary.recentWatchTime}
color="#8aadf4"
type="bar"
/>
{!calLoading && <StreakCalendar data={streakData} />}
</div>
<TrackingSnapshot
summary={summary}
showTrackedCardNote={showTrackedCardNote}
knownWordsSummary={knownWordsSummary}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<RecentSessions
sessions={sessions}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={handleDeleteSession}
onDeleteDayGroup={handleDeleteDayGroup}
onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds}
/>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { todayLocalDay } from '../../lib/formatters';
import type { DailyRollup } from '../../types/stats';
interface QuickStatsProps {
rollups: DailyRollup[];
}
export function QuickStats({ rollups }: QuickStatsProps) {
const daysWithActivity = new Set(
rollups.filter((r) => r.totalActiveMin > 0).map((r) => r.rollupDayOrMonth),
);
const today = todayLocalDay();
const streakStart = daysWithActivity.has(today) ? today : today - 1;
let streak = 0;
for (let d = streakStart; daysWithActivity.has(d); d--) {
streak++;
}
const weekStart = today - 6;
const weekRollups = rollups.filter((r) => r.rollupDayOrMonth >= weekStart);
const weekMinutes = weekRollups.reduce((sum, r) => sum + r.totalActiveMin, 0);
const weekCards = weekRollups.reduce((sum, r) => sum + r.totalCards, 0);
const avgMinPerDay = Math.round(weekMinutes / 7);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Quick Stats</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-ctp-subtext0">Streak</span>
<span className="text-ctp-peach font-medium">
{streak} day{streak !== 1 ? 's' : ''}
</span>
</div>
<div className="flex justify-between">
<span className="text-ctp-subtext0">Avg/day this week</span>
<span className="text-ctp-text">{avgMinPerDay}m</span>
</div>
<div className="flex justify-between">
<span className="text-ctp-subtext0">Cards this week</span>
<span className="text-ctp-cards-mined font-medium">{weekCards}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,433 @@
import { useState } from 'react';
import {
formatDuration,
formatRelativeDate,
formatNumber,
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 {
key: string;
animeId: number | null;
animeTitle: string | null;
videoId: number | null;
sessions: SessionSummary[];
totalCards: number;
totalWords: number;
totalActiveMs: number;
totalKnownWords: number;
}
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
for (const session of sessions) {
const dayLabel = formatSessionDayLabel(session.startedAtMs);
const group = groups.get(dayLabel);
if (group) {
group.push(session);
} else {
groups.set(dayLabel, [session]);
}
}
return groups;
}
function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
const map = new Map<string, AnimeGroup>();
for (const session of sessions) {
const key =
session.animeId != null
? `anime-${session.animeId}`
: session.videoId != null
? `video-${session.videoId}`
: `session-${session.sessionId}`;
const existing = map.get(key);
const displayWordCount = getSessionDisplayWordCount(session);
if (existing) {
existing.sessions.push(session);
existing.totalCards += session.cardsMined;
existing.totalWords += displayWordCount;
existing.totalActiveMs += session.activeWatchedMs;
existing.totalKnownWords += session.knownWordsSeen;
} else {
map.set(key, {
key,
animeId: session.animeId,
animeTitle: session.animeTitle,
videoId: session.videoId,
sessions: [session],
totalCards: session.cardsMined,
totalWords: displayWordCount,
totalActiveMs: session.activeWatchedMs,
totalKnownWords: session.knownWordsSeen,
});
}
}
return Array.from(map.values());
}
function CoverThumbnail({
animeId,
videoId,
title,
}: {
animeId: number | null;
videoId: number | null;
title: string;
}) {
const fallbackChar = title.charAt(0) || '?';
const [isFallback, setIsFallback] = useState(false);
if ((!animeId && !videoId) || isFallback) {
return (
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
{fallbackChar}
</div>
);
}
const src =
animeId != null
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
return (
<img
src={src}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setIsFallback(true)}
/>
);
}
function SessionItem({
session,
onNavigateToMediaDetail,
onNavigateToSession,
onDelete,
deleteDisabled,
}: {
session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
}) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
return (
<div className="relative group">
<button
type="button"
onClick={() => {
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-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-cards-mined 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>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.knownWordsSeen)}
</div>
<div className="text-ctp-overlay2">known 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={s}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
/>
);
}
const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media';
const mostRecentSession = group.sessions[0]!;
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
return (
<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"
>
<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-cards-mined 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-green font-medium font-mono tabular-nums">
{formatNumber(group.totalKnownWords)}
</div>
<div className="text-ctp-overlay2">known 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) => {
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="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-cards-mined 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-green font-medium font-mono tabular-nums">
{formatNumber(s.knownWordsSeen)}
</div>
<div className="text-ctp-overlay2">known words</div>
</div>
</div>
</button>
<button
type="button"
onClick={() => onDeleteSession(s)}
disabled={deletingIds.has(s.sessionId)}
aria-label={`Delete session ${s.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/nested:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
export function RecentSessions({
sessions,
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">
<div className="text-sm text-ctp-overlay2">No sessions yet</div>
</div>
);
}
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} 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}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds}
/>
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import type { StreakCalendarPoint } from '../../lib/dashboard-data';
interface StreakCalendarProps {
data: StreakCalendarPoint[];
}
function intensityClass(value: number): string {
if (value === 0) return 'bg-ctp-surface0';
if (value <= 30) return 'bg-ctp-green/30';
if (value <= 60) return 'bg-ctp-green/60';
return 'bg-ctp-green';
}
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
export function StreakCalendar({ data }: StreakCalendarProps) {
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const lookup = new Map(data.map((d) => [d.date, d.value]));
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - 89);
const startDow = (startDate.getDay() + 6) % 7;
const cells: Array<{ date: string; value: number; row: number; col: number }> = [];
let col = 0;
let row = startDow;
const cursor = new Date(startDate);
while (cursor <= endDate) {
const dateStr = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`;
cells.push({ date: dateStr, value: lookup.get(dateStr) ?? 0, row, col });
row += 1;
if (row >= 7) {
row = 0;
col += 1;
}
cursor.setDate(cursor.getDate() + 1);
}
const totalCols = col + (row > 0 ? 1 : 0);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Activity (90 days)</h3>
<div className="relative flex gap-1">
<div className="flex flex-col gap-1 text-[10px] text-ctp-overlay2 pr-1 shrink-0">
{DAY_LABELS.map((label, i) => (
<div key={i} className="h-3 flex items-center leading-none">
{label}
</div>
))}
</div>
<div
className="grid gap-[3px]"
style={{
gridTemplateColumns: `repeat(${totalCols}, 12px)`,
gridTemplateRows: 'repeat(7, 12px)',
}}
>
{cells.map((cell) => (
<div
key={cell.date}
className={`w-3 h-3 rounded-sm ${intensityClass(cell.value)} cursor-default`}
style={{ gridRow: cell.row + 1, gridColumn: cell.col + 1 }}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setTooltip({
x: rect.left + rect.width / 2,
y: rect.top - 4,
text: `${cell.date}: ${Math.round(cell.value * 100) / 100}m`,
});
}}
onMouseLeave={() => setTooltip(null)}
/>
))}
</div>
{tooltip && (
<div
className="fixed z-50 px-2 py-1 text-xs bg-ctp-crust text-ctp-text rounded shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full"
style={{ left: tooltip.x, top: tooltip.y }}
>
{tooltip.text}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { TrackingSnapshot } from './TrackingSnapshot';
import type { OverviewSummary } from '../../lib/dashboard-data';
const summary: OverviewSummary = {
todayActiveMs: 0,
todayCards: 0,
streakDays: 0,
allTimeMinutes: 120,
totalTrackedCards: 9,
episodesToday: 0,
activeAnimeCount: 0,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
averageSessionMinutes: 40,
activeDays: 12,
totalSessions: 15,
lookupRate: {
shortValue: '2.3 / 100 words',
longValue: '2.3 lookups per 100 words',
},
todayTokens: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
recentWatchTime: [],
};
test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', () => {
const markup = renderToStaticMarkup(
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.3 \/ 100 words/);
assert.match(markup, /Lifetime Yomitan lookups normalized by total words seen/);
});
test('TrackingSnapshot labels new words as unique headwords', () => {
const markup = renderToStaticMarkup(
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
);
assert.match(markup, /Unique headwords seen for the first time today/);
assert.match(markup, /Unique headwords seen for the first time this week/);
});

View File

@@ -0,0 +1,149 @@
import type { OverviewSummary } from '../../lib/dashboard-data';
import { formatNumber } from '../../lib/formatters';
import { Tooltip } from '../layout/Tooltip';
interface KnownWordsSummary {
totalUniqueWords: number;
knownWordCount: number;
}
interface TrackingSnapshotProps {
summary: OverviewSummary;
showTrackedCardNote?: boolean;
knownWordsSummary: KnownWordsSummary | null;
}
export function TrackingSnapshot({
summary,
showTrackedCardNote = false,
knownWordsSummary,
}: TrackingSnapshotProps) {
const knownWordPercent =
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">
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
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">
No lifetime card totals in the summary table yet. New cards mined after this fix will
appear here.
</div>
)}
<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>
</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>
</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>
</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>
</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>
</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>
</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-cards-mined">
{formatNumber(summary.totalTrackedCards)}
</div>
</div>
</Tooltip>
<Tooltip text="Lifetime Yomitan lookups normalized by total words seen">
<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?.shortValue ?? '—'}
</div>
</div>
</Tooltip>
<Tooltip text="Total word occurrences encountered in today's sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
{formatNumber(summary.todayTokens)}
</div>
</div>
</Tooltip>
<Tooltip text="Unique headwords 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 headwords 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>
{knownWordPercent != null ? (
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
) : null}
</div>
</div>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
import { CHART_THEME } from '../../lib/chart-theme';
import type { DailyRollup } from '../../types/stats';
interface WatchTimeChartProps {
rollups: DailyRollup[];
}
type Range = 14 | 30 | 90;
function formatActiveMinutes(value: number | string, _name?: string, _payload?: unknown) {
const minutes = Number(value);
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
}
export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
const [range, setRange] = useState<Range>(14);
const byDay = new Map<number, number>();
for (const r of rollups) {
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
}
const chartData = Array.from(byDay.entries())
.sort(([dayA], [dayB]) => dayA - dayB)
.map(([day, mins]) => ({
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
minutes: Math.round(mins),
}))
.slice(-range);
const ranges: Range[] = [14, 30, 90];
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
{ranges.map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
range === r
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{r}d
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData}>
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
/>
<Tooltip
contentStyle={{
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
}}
labelStyle={{ color: CHART_THEME.tooltipLabel }}
formatter={formatActiveMinutes}
/>
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,827 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
AreaChart,
Area,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceArea,
ReferenceLine,
CartesianGrid,
Customized,
} from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions';
import { getStatsClient } from '../../hooks/useStatsApi';
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme';
import {
buildSessionChartEvents,
collectPendingSessionEventNoteIds,
getSessionEventCardRequest,
mergeSessionEventNoteInfos,
resolveActiveSessionMarkerKey,
type SessionChartMarker,
type SessionEventNoteInfo,
type SessionChartPlotArea,
} from '../../lib/session-events';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { EventType } from '../../types/stats';
import type { SessionEvent, SessionSummary } from '../../types/stats';
import { SessionEventOverlay } from './SessionEventOverlay';
interface SessionDetailProps {
session: SessionSummary;
}
const tooltipStyle = {
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 11,
};
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/** 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 RatioChartPoint {
tsMs: number;
knownWords: number;
unknownWords: number;
totalWords: number;
}
interface FallbackChartPoint {
tsMs: number;
totalWords: number;
}
type TimelineEntry = {
sampleMs: number;
linesSeen: number;
tokensSeen: number;
};
function SessionChartOffsetProbe({
offset,
onPlotAreaChange,
}: {
offset?: { left?: number; width?: number };
onPlotAreaChange: (plotArea: SessionChartPlotArea) => void;
}) {
useEffect(() => {
if (!offset) return;
const { left, width } = offset;
if (typeof left !== 'number' || !Number.isFinite(left)) return;
if (typeof width !== 'number' || !Number.isFinite(width)) return;
onPlotAreaChange({ left, width });
}, [offset?.left, offset?.width, onPlotAreaChange]);
return null;
}
export function SessionDetail({ session }: SessionDetailProps) {
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
session.sessionId,
);
const [hoveredMarkerKey, setHoveredMarkerKey] = useState<string | null>(null);
const [pinnedMarkerKey, setPinnedMarkerKey] = useState<string | null>(null);
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set());
const pendingNoteIdsRef = useRef<Set<number>>(new Set());
const sorted = [...timeline].reverse();
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
buildSessionChartEvents(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 activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
const activeMarker = useMemo<SessionChartMarker | null>(
() => markers.find((marker) => marker.key === activeMarkerKey) ?? null,
[markers, activeMarkerKey],
);
const activeCardRequest = useMemo(
() => getSessionEventCardRequest(activeMarker),
[activeMarkerKey, markers],
);
useEffect(() => {
if (!activeCardRequest.requestKey || activeCardRequest.noteIds.length === 0) {
return;
}
const missingNoteIds = collectPendingSessionEventNoteIds(
activeCardRequest.noteIds,
noteInfos,
pendingNoteIdsRef.current,
);
if (missingNoteIds.length === 0) {
return;
}
for (const noteId of missingNoteIds) {
pendingNoteIdsRef.current.add(noteId);
}
let cancelled = false;
setLoadingNoteIds((prev) => {
const next = new Set(prev);
for (const noteId of missingNoteIds) {
next.add(noteId);
}
return next;
});
getStatsClient()
.ankiNotesInfo(missingNoteIds)
.then((notes) => {
if (cancelled) return;
setNoteInfos((prev) => {
const next = new Map(prev);
for (const [noteId, info] of mergeSessionEventNoteInfos(missingNoteIds, notes)) {
next.set(noteId, info);
}
return next;
});
})
.catch((err) => {
if (!cancelled) {
console.warn('Failed to fetch session event Anki note info:', err);
}
})
.finally(() => {
if (cancelled) return;
for (const noteId of missingNoteIds) {
pendingNoteIdsRef.current.delete(noteId);
}
setLoadingNoteIds((prev) => {
const next = new Set(prev);
for (const noteId of missingNoteIds) {
next.delete(noteId);
}
return next;
});
});
return () => {
cancelled = true;
for (const noteId of missingNoteIds) {
pendingNoteIdsRef.current.delete(noteId);
}
setLoadingNoteIds((prev) => {
const next = new Set(prev);
for (const noteId of missingNoteIds) {
next.delete(noteId);
}
return next;
});
};
}, [activeCardRequest.requestKey, noteInfos]);
const handleOpenNote = (noteId: number) => {
void getStatsClient().ankiBrowse(noteId);
};
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
if (hasKnownWords) {
return (
<RatioView
sorted={sorted}
knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
hoveredMarkerKey={hoveredMarkerKey}
onHoveredMarkerChange={setHoveredMarkerKey}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={setPinnedMarkerKey}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
/>
);
}
return (
<FallbackView
sorted={sorted}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
hoveredMarkerKey={hoveredMarkerKey}
onHoveredMarkerChange={setHoveredMarkerKey}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={setPinnedMarkerKey}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
/>
);
}
/* ── Ratio View (primary design) ────────────────────────────────── */
function RatioView({
sorted,
knownWordsMap,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
hoveredMarkerKey,
onHoveredMarkerChange,
pinnedMarkerKey,
onPinnedMarkerChange,
noteInfos,
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
}: {
sorted: TimelineEntry[];
knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
hoveredMarkerKey: string | null;
onHoveredMarkerChange: (markerKey: string | null) => void;
pinnedMarkerKey: string | null;
onPinnedMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
}) {
const [plotArea, setPlotArea] = useState<SessionChartPlotArea | null>(null);
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;
chartData.push({
tsMs: t.sampleMs,
knownWords,
unknownWords,
totalWords,
});
}
if (chartData.length === 0) {
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
}
const tsMin = chartData[0]!.tsMs;
const tsMax = chartData[chartData.length - 1]!.tsMs;
const finalTotal = chartData[chartData.length - 1]!.totalWords;
const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords }));
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
{/* ── Top: Percentage area chart ── */}
<div className="relative">
<ResponsiveContainer width="100%" height={130}>
<AreaChart data={chartData}>
<Customized
component={
<SessionChartOffsetProbe
onPlotAreaChange={(nextPlotArea) => {
setPlotArea((prevPlotArea) =>
prevPlotArea &&
prevPlotArea.left === nextPlotArea.left &&
prevPlotArea.width === nextPlotArea.width
? prevPlotArea
: nextPlotArea,
);
}}
/>
}
/>
<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}
/>
<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, finalTotal]}
allowDataOverflow
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
tickFormatter={(v: number) => `${v.toLocaleString()}`}
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 words') {
const knownPct = d.totalWords === 0 ? 0 : (d.knownWords / d.totalWords) * 100;
return [`${d.knownWords.toLocaleString()} (${knownPct.toFixed(1)}%)`, name];
}
if (name === 'Unknown words') return [d.unknownWords.toLocaleString(), name];
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={finalTotal}
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}
/>
))}
{seekEvents.map((e, i) => {
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
return (
<ReferenceLine
key={`seek-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke={stroke}
strokeWidth={1.5}
strokeOpacity={0.75}
strokeDasharray="4 3"
/>
);
})}
{/* 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}
/>
))}
<Area
yAxisId="pct"
dataKey="knownWords"
stackId="ratio"
stroke="#a6da95"
strokeWidth={1.5}
fill={`url(#knownGrad-${session.sessionId})`}
name="Known words"
type="monotone"
dot={false}
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
isAnimationActive={false}
/>
<Area
yAxisId="pct"
dataKey="unknownWords"
stackId="ratio"
stroke="#c6a0f6"
strokeWidth={0}
fill={`url(#unknownGrad-${session.sessionId})`}
name="Unknown words"
type="monotone"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
<SessionEventOverlay
markers={markers}
tsMin={tsMin}
tsMax={tsMax}
plotArea={plotArea}
hoveredMarkerKey={hoveredMarkerKey}
onHoveredMarkerChange={onHoveredMarkerChange}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={onPinnedMarkerChange}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={onOpenNote}
/>
</div>
{/* ── Bottom: Token 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,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
hoveredMarkerKey,
onHoveredMarkerChange,
pinnedMarkerKey,
onPinnedMarkerChange,
noteInfos,
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
}: {
sorted: TimelineEntry[];
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
hoveredMarkerKey: string | null;
onHoveredMarkerChange: (markerKey: string | null) => void;
pinnedMarkerKey: string | null;
onPinnedMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
}) {
const [plotArea, setPlotArea] = useState<SessionChartPlotArea | null>(null);
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">
<div className="relative">
<ResponsiveContainer width="100%" height={130}>
<LineChart data={chartData}>
<Customized
component={
<SessionChartOffsetProbe
onPlotAreaChange={(nextPlotArea) => {
setPlotArea((prevPlotArea) =>
prevPlotArea &&
prevPlotArea.left === nextPlotArea.left &&
prevPlotArea.width === nextPlotArea.width
? prevPlotArea
: nextPlotArea,
);
}}
/>
}
/>
<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}
/>
))}
{seekEvents.map((e, i) => {
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
return (
<ReferenceLine
key={`seek-${i}`}
x={e.tsMs}
stroke={stroke}
strokeWidth={1.5}
strokeOpacity={0.75}
strokeDasharray="4 3"
/>
);
})}
{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>
<SessionEventOverlay
markers={markers}
tsMin={tsMin}
tsMax={tsMax}
plotArea={plotArea}
hoveredMarkerKey={hoveredMarkerKey}
onHoveredMarkerChange={onHoveredMarkerChange}
pinnedMarkerKey={pinnedMarkerKey}
onPinnedMarkerChange={onPinnedMarkerChange}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={onOpenNote}
/>
</div>
<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-cards-mined">
{Math.max(cardEventCount, session.cardsMined)} card
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
</span>
</span>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useRef, type FocusEvent, type MouseEvent } from 'react';
import {
projectSessionMarkerLeftPx,
resolveActiveSessionMarkerKey,
togglePinnedSessionMarkerKey,
type SessionChartMarker,
type SessionEventNoteInfo,
type SessionChartPlotArea,
} from '../../lib/session-events';
import { SessionEventPopover } from './SessionEventPopover';
interface SessionEventOverlayProps {
markers: SessionChartMarker[];
tsMin: number;
tsMax: number;
plotArea: SessionChartPlotArea | null;
hoveredMarkerKey: string | null;
onHoveredMarkerChange: (markerKey: string | null) => void;
pinnedMarkerKey: string | null;
onPinnedMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
}
function toPercent(tsMs: number, tsMin: number, tsMax: number): number {
if (tsMax <= tsMin) return 50;
const ratio = ((tsMs - tsMin) / (tsMax - tsMin)) * 100;
return Math.max(0, Math.min(100, ratio));
}
function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) {
case 'pause':
return '||';
case 'seek':
return marker.direction === 'backward' ? '<<' : '>>';
case 'card':
return '\u26CF';
}
}
function markerColors(marker: SessionChartMarker): { border: string; bg: string; text: string } {
switch (marker.kind) {
case 'pause':
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
case 'seek':
return marker.direction === 'backward'
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
case 'card':
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
}
}
function popupAlignment(percent: number): string {
if (percent <= 15) return 'left-0 translate-x-0';
if (percent >= 85) return 'right-0 translate-x-0';
return 'left-1/2 -translate-x-1/2';
}
function handleWrapperBlur(
event: FocusEvent<HTMLDivElement>,
onHoveredMarkerChange: (markerKey: string | null) => void,
pinnedMarkerKey: string | null,
markerKey: string,
): void {
if (pinnedMarkerKey === markerKey) return;
const nextFocused = event.relatedTarget;
if (nextFocused instanceof Node && event.currentTarget.contains(nextFocused)) {
return;
}
onHoveredMarkerChange(null);
}
function handleWrapperMouseLeave(
event: MouseEvent<HTMLDivElement>,
onHoveredMarkerChange: (markerKey: string | null) => void,
pinnedMarkerKey: string | null,
markerKey: string,
): void {
if (pinnedMarkerKey === markerKey) return;
const nextHovered = event.relatedTarget;
if (nextHovered instanceof Node && event.currentTarget.contains(nextHovered)) {
return;
}
onHoveredMarkerChange(null);
}
export function SessionEventOverlay({
markers,
tsMin,
tsMax,
plotArea,
hoveredMarkerKey,
onHoveredMarkerChange,
pinnedMarkerKey,
onPinnedMarkerChange,
noteInfos,
loadingNoteIds,
onOpenNote,
}: SessionEventOverlayProps) {
if (markers.length === 0) return null;
const rootRef = useRef<HTMLDivElement>(null);
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
useEffect(() => {
if (!pinnedMarkerKey) return;
function handleDocumentPointerDown(event: PointerEvent): void {
if (rootRef.current?.contains(event.target as Node)) {
return;
}
onPinnedMarkerChange(null);
onHoveredMarkerChange(null);
}
function handleDocumentKeyDown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return;
onPinnedMarkerChange(null);
onHoveredMarkerChange(null);
}
document.addEventListener('pointerdown', handleDocumentPointerDown);
document.addEventListener('keydown', handleDocumentKeyDown);
return () => {
document.removeEventListener('pointerdown', handleDocumentPointerDown);
document.removeEventListener('keydown', handleDocumentKeyDown);
};
}, [pinnedMarkerKey, onHoveredMarkerChange, onPinnedMarkerChange]);
return (
<div ref={rootRef} className="pointer-events-none absolute inset-0 z-30 overflow-visible">
{markers.map((marker) => {
const percent = toPercent(marker.anchorTsMs, tsMin, tsMax);
const left = plotArea
? `${projectSessionMarkerLeftPx({
anchorTsMs: marker.anchorTsMs,
tsMin,
tsMax,
plotLeftPx: plotArea.left,
plotWidthPx: plotArea.width,
})}px`
: `${percent}%`;
const colors = markerColors(marker);
const isActive = marker.key === activeMarkerKey;
const isPinned = marker.key === pinnedMarkerKey;
const loading =
marker.kind === 'card' && marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
return (
<div
key={marker.key}
className="pointer-events-auto absolute top-0 -translate-x-1/2 pt-1"
style={{ left }}
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
onMouseLeave={(event) =>
handleWrapperMouseLeave(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key)
}
onFocusCapture={() => onHoveredMarkerChange(marker.key)}
onBlurCapture={(event) =>
handleWrapperBlur(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key)
}
>
<div className="relative flex flex-col items-center">
<button
type="button"
aria-label={`Show ${marker.kind} event details`}
aria-pressed={isPinned}
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm"
style={{
borderColor: colors.border,
background: colors.bg,
color: colors.text,
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onHoveredMarkerChange(marker.key);
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
}}
>
{markerLabel(marker)}
</button>
{isActive ? (
<div
className={`pointer-events-auto absolute top-5 z-50 pt-2 ${popupAlignment(percent)}`}
onMouseDownCapture={() => {
if (!isPinned) {
onPinnedMarkerChange(marker.key);
}
}}
>
<SessionEventPopover
marker={marker}
noteInfos={noteInfos}
loading={loading}
pinned={isPinned}
onTogglePinned={() =>
onPinnedMarkerChange(
togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key),
)
}
onClose={() => {
onPinnedMarkerChange(null);
onHoveredMarkerChange(null);
}}
onOpenNote={onOpenNote}
/>
</div>
) : null}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import type { SessionChartMarker } from '../../lib/session-events';
import { SessionEventPopover } from './SessionEventPopover';
test('SessionEventPopover renders formatted card-mine details with fetched note info', () => {
const marker: SessionChartMarker = {
key: 'card-6000',
kind: 'card',
anchorTsMs: 6_000,
eventTsMs: 6_000,
noteIds: [11, 22],
cardsDelta: 2,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={
new Map([
[11, { noteId: 11, expression: '冒険者', context: '駆け出しの冒険者だ', meaning: null }],
[22, { noteId: 22, expression: '呪い', context: null, meaning: 'curse' }],
])
}
loading={false}
pinned={false}
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Card mined/);
assert.match(markup, /\+2 cards/);
assert.match(markup, /冒険者/);
assert.match(markup, /呪い/);
assert.match(markup, /駆け出しの冒険者だ/);
assert.match(markup, /curse/);
assert.match(markup, /Pin/);
assert.match(markup, /Open in Anki/);
});
test('SessionEventPopover renders seek metadata compactly', () => {
const marker: SessionChartMarker = {
key: 'seek-3000',
kind: 'seek',
anchorTsMs: 3_000,
eventTsMs: 3_000,
direction: 'backward',
fromMs: 5_000,
toMs: 1_500,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={new Map()}
loading={false}
pinned={false}
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Seek backward/);
assert.match(markup, /5\.0s/);
assert.match(markup, /1\.5s/);
assert.match(markup, /3\.5s/);
});
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
const marker: SessionChartMarker = {
key: 'card-9000',
kind: 'card',
anchorTsMs: 9_000,
eventTsMs: 9_000,
noteIds: [91],
cardsDelta: 1,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={new Map()}
loading={false}
pinned={true}
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Pinned/);
assert.match(markup, /Preview unavailable from AnkiConnect/);
assert.doesNotMatch(markup, /No readable note fields returned/);
});
test('SessionEventPopover hides preview-unavailable fallback while note info is still loading', () => {
const marker: SessionChartMarker = {
key: 'card-177',
kind: 'card',
anchorTsMs: 9_000,
eventTsMs: 9_000,
noteIds: [177],
cardsDelta: 1,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={new Map()}
loading
pinned
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Loading Anki note info/);
assert.doesNotMatch(markup, /Preview unavailable/);
});
test('SessionEventPopover keeps the loading state clean until note preview data arrives', () => {
const marker: SessionChartMarker = {
key: 'card-9001',
kind: 'card',
anchorTsMs: 9_001,
eventTsMs: 9_001,
noteIds: [1773808840964],
cardsDelta: 1,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={new Map()}
loading={true}
pinned={true}
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Loading Anki note info/);
assert.doesNotMatch(markup, /Preview unavailable/);
});

View File

@@ -0,0 +1,161 @@
import {
formatEventSeconds,
type SessionChartMarker,
type SessionEventNoteInfo,
} from '../../lib/session-events';
interface SessionEventPopoverProps {
marker: SessionChartMarker;
noteInfos: Map<number, SessionEventNoteInfo>;
loading: boolean;
pinned: boolean;
onTogglePinned: () => void;
onClose: () => void;
onOpenNote: (noteId: number) => void;
}
function formatEventTime(tsMs: number): string {
return new Date(tsMs).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
export function SessionEventPopover({
marker,
noteInfos,
loading,
pinned,
onTogglePinned,
onClose,
onOpenNote,
}: SessionEventPopoverProps) {
const seekDurationLabel =
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
: null;
return (
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
<div className="mb-2 flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-ctp-text">
{marker.kind === 'pause' && 'Paused'}
{marker.kind === 'seek' && `Seek ${marker.direction}`}
{marker.kind === 'card' && 'Card mined'}
</div>
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
</div>
<div className="flex items-center gap-1.5">
{pinned ? (
<span className="rounded-full border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-blue">
Pinned
</span>
) : null}
<button
type="button"
onClick={onTogglePinned}
className="rounded-md border border-ctp-surface2 px-1.5 py-0.5 text-[10px] text-ctp-overlay1 transition-colors hover:bg-ctp-surface1 hover:text-ctp-text"
>
{pinned ? 'Unpin' : 'Pin'}
</button>
{pinned ? (
<button
type="button"
aria-label="Close event popup"
onClick={onClose}
className="rounded-md border border-ctp-surface2 px-1.5 py-0.5 text-[10px] text-ctp-overlay1 transition-colors hover:bg-ctp-surface1 hover:text-ctp-text"
>
×
</button>
) : null}
<div className="text-sm">
{marker.kind === 'pause' && '||'}
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
{marker.kind === 'card' && '\u26CF'}
</div>
</div>
</div>
{marker.kind === 'pause' && (
<div className="text-xs text-ctp-subtext0">
Duration: <span className="text-ctp-peach">{formatEventSeconds(marker.durationMs)}</span>
</div>
)}
{marker.kind === 'seek' && (
<div className="space-y-1 text-xs text-ctp-subtext0">
<div>
From{' '}
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
</div>
<div>
Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span>
</div>
</div>
)}
{marker.kind === 'card' && (
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
</div>
{loading ? (
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
) : null}
<div className="space-y-1.5">
{marker.noteIds.length > 0 ? (
marker.noteIds.map((noteId) => {
const info = noteInfos.get(noteId);
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
const showUnavailableFallback = !loading && !hasPreview;
return (
<div
key={noteId}
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
Note {noteId}
</div>
{showUnavailableFallback ? (
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
) : null}
</div>
{info?.expression ? (
<div className="mb-1 text-sm font-medium text-ctp-text">
{info.expression}
</div>
) : null}
{info?.context ? (
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
) : null}
{info?.meaning ? (
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
) : null}
{showUnavailableFallback ? (
<div className="mb-2 text-xs text-ctp-overlay1">
Preview unavailable from AnkiConnect.
</div>
) : null}
<button
type="button"
onClick={() => onOpenNote(noteId)}
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
>
Open in Anki
</button>
</div>
);
})
) : (
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
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 {
session: SessionSummary;
isExpanded: boolean;
detailsId: string;
onToggle: () => void;
onDelete: () => void;
deleteDisabled?: boolean;
onNavigateToMediaDetail?: (videoId: number) => void;
}
function CoverThumbnail({
animeId,
videoId,
title,
}: {
animeId: number | null;
videoId: number | null;
title: string;
}) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
if ((!animeId && !videoId) || failed) {
return (
<div className="w-10 h-14 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-sm font-bold shrink-0">
{fallbackChar}
</div>
);
}
const src =
animeId != null
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
return (
<img
src={src}
alt=""
loading="lazy"
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2"
onError={() => setFailed(true)}
/>
);
}
export function SessionRow({
session,
isExpanded,
detailsId,
onToggle,
onDelete,
deleteDisabled = false,
onNavigateToMediaDetail,
}: SessionRowProps) {
const displayWordCount = getSessionDisplayWordCount(session);
const knownWordsSeen = session.knownWordsSeen;
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"
>
<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-cards-mined 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>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(knownWordsSeen)}
</div>
<div className="text-ctp-overlay2">known words</div>
</div>
</div>
<div
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
>
{'\u25B8'}
</div>
</button>
{onNavigateToMediaDetail != null && session.videoId != null ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNavigateToMediaDetail(session.videoId!);
}}
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
title="View anime overview"
>
{'\u2197'}
</button>
) : null}
<button
type="button"
onClick={onDelete}
disabled={deleteDisabled}
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-3 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>
);
}

View File

@@ -0,0 +1,154 @@
import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { formatSessionDayLabel } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
for (const session of sessions) {
const dayLabel = formatSessionDayLabel(session.startedAtMs);
const group = groups.get(dayLabel);
if (group) {
group.push(session);
} else {
groups.set(dayLabel, [session]);
}
}
return groups;
}
interface SessionsTabProps {
initialSessionId?: number | null;
onClearInitialSession?: () => void;
onNavigateToMediaDetail?: (videoId: number) => void;
}
export function SessionsTab({
initialSessionId,
onClearInitialSession,
onNavigateToMediaDetail,
}: SessionsTabProps = {}) {
const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
useEffect(() => {
setVisibleSessions(sessions);
}, [sessions]);
useEffect(() => {
if (initialSessionId != null && sessions.length > 0) {
let canceled = false;
setExpandedId(initialSessionId);
onClearInitialSession?.();
const frame = requestAnimationFrame(() => {
if (canceled) return;
const el = document.getElementById(`session-details-${initialSessionId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Session row itself if detail hasn't rendered yet
const row = document.querySelector(
`[aria-controls="session-details-${initialSessionId}"]`,
);
row?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
return () => {
canceled = true;
cancelAnimationFrame(frame);
};
}
}, [initialSessionId, sessions, onClearInitialSession]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return visibleSessions;
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
}, [visibleSessions, search]);
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return;
setDeleteError(null);
setDeletingSessionId(session.sessionId);
try {
await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
setExpandedId((prev) => (prev === session.sessionId ? null : prev));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
}
};
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>;
return (
<div className="space-y-4">
<input
type="search"
aria-label="Search sessions by title"
placeholder="Search by title..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
<div key={dayLabel}>
<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" />
</div>
<div className="space-y-2">
{daySessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-ctp-overlay2 text-sm">
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
interface DateRangeSelectorProps {
range: TimeRange;
groupBy: GroupBy;
onRangeChange: (r: TimeRange) => void;
onGroupByChange: (g: GroupBy) => void;
}
function SegmentedControl<T extends string>({
label,
options,
value,
onChange,
formatLabel,
}: {
label: string;
options: T[];
value: T;
onChange: (v: T) => void;
formatLabel?: (v: T) => string;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1">{label}</span>
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
{options.map((opt) => (
<button
key={opt}
onClick={() => onChange(opt)}
aria-pressed={value === opt}
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
value === opt
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{formatLabel ? formatLabel(opt) : opt}
</button>
))}
</div>
</div>
);
}
export function DateRangeSelector({
range,
groupBy,
onRangeChange,
onGroupByChange,
}: DateRangeSelectorProps) {
return (
<div className="flex items-center gap-4 text-sm">
<SegmentedControl
label="Range"
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
value={range}
onChange={onRangeChange}
formatLabel={(r) => (r === 'all' ? 'All' : r)}
/>
<SegmentedControl
label="Group by"
options={['day', 'month'] as GroupBy[]}
value={groupBy}
onChange={onGroupByChange}
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
/>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
export interface PerAnimeDataPoint {
epochDay: number;
animeTitle: string;
value: number;
}
interface StackedTrendChartProps {
title: string;
data: PerAnimeDataPoint[];
colorPalette?: string[];
}
const DEFAULT_LINE_COLORS = [
'#8aadf4',
'#c6a0f6',
'#a6da95',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
function buildLineData(raw: PerAnimeDataPoint[]) {
const totalByAnime = new Map<string, number>();
for (const entry of raw) {
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
}
const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]);
const topTitles = sorted.slice(0, 7).map(([title]) => title);
const topSet = new Set(topTitles);
const byDay = new Map<number, Record<string, number>>();
for (const entry of raw) {
if (!topSet.has(entry.animeTitle)) continue;
const row = byDay.get(entry.epochDay) ?? {};
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
byDay.set(entry.epochDay, row);
}
const points = [...byDay.entries()]
.sort(([a], [b]) => a - b)
.map(([epochDay, values]) => {
const row: Record<string, string | number> = {
label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
};
for (const title of topTitles) {
row[title] = values[title] ?? 0;
}
return row;
});
return { points, seriesKeys: topTitles };
}
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data);
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
const tooltipStyle = {
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
if (points.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<div className="text-xs text-ctp-overlay2">No data</div>
</div>
);
}
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={points}>
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} />
{seriesKeys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={0.15}
strokeWidth={1.5}
connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
{seriesKeys.map((key, i) => (
<span
key={key}
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
title={key}
>
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: colors[i % colors.length] }}
/>
<span className="truncate">{key}</span>
</span>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import {
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
interface TrendChartProps {
title: string;
data: Array<{ label: string; value: number }>;
color: string;
type: 'bar' | 'line';
formatter?: (value: number) => string;
onBarClick?: (label: string) => void;
}
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
const tooltipStyle = {
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
<ResponsiveContainer width="100%" height={120}>
{type === 'bar' ? (
<BarChart data={data}>
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
<Bar
dataKey="value"
fill={color}
radius={[2, 2, 0, 0]}
cursor={onBarClick ? 'pointer' : undefined}
onClick={
onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined
}
/>
</BarChart>
) : (
<LineChart data={data}>
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: '#a5adcb' }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
</LineChart>
)}
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
<h3 className="text-ctp-subtext0 text-xs font-semibold uppercase tracking-widest shrink-0">
{children}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
);
}
interface AnimeVisibilityFilterProps {
animeTitles: string[];
hiddenAnime: ReadonlySet<string>;
onShowAll: () => void;
onHideAll: () => void;
onToggleAnime: (title: string) => void;
}
function AnimeVisibilityFilter({
animeTitles,
hiddenAnime,
onShowAll,
onHideAll,
onToggleAnime,
}: AnimeVisibilityFilterProps) {
if (animeTitles.length === 0) {
return null;
}
return (
<div className="col-span-full -mt-1 mb-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
Anime Visibility
</h4>
<p className="mt-1 text-xs text-ctp-overlay1">
Shared across all anime trend charts. Default: show everything.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onShowAll}
>
All
</button>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-peach hover:text-ctp-peach"
onClick={onHideAll}
>
None
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{animeTitles.map((title) => {
const isVisible = !hiddenAnime.has(title);
return (
<button
key={title}
type="button"
aria-pressed={isVisible}
className={`max-w-full rounded-full border px-3 py-1 text-xs transition ${
isVisible
? 'border-ctp-blue/60 bg-ctp-blue/12 text-ctp-blue'
: 'border-ctp-surface2 bg-transparent text-ctp-subtext0'
}`}
onClick={() => onToggleAnime(title)}
title={title}
>
<span className="block truncate">{title}</span>
</button>
);
})}
</div>
</div>
);
}
export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
const { data, loading, error } = useTrends(range, groupBy);
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [
cardsMinedColor,
'#8aadf4',
'#c6a0f6',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
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 animeTitles = buildAnimeVisibilityOptions([
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(
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">
<DateRangeSelector
range={range}
groupBy={groupBy}
onRangeChange={setRange}
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={data.activity.watchTime}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Cards Mined"
data={data.activity.cards}
color={cardsMinedColor}
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={cardsMinedColor}
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
animeTitles={animeTitles}
hiddenAnime={activeHiddenAnime}
onShowAll={() => setHiddenAnime(new Set())}
onHideAll={() => setHiddenAnime(new Set(animeTitles))}
onToggleAnime={(title) =>
setHiddenAnime((current) => {
const next = new Set(current);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
return next;
})
}
/>
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart
title="Cards Mined per Anime"
data={filteredCardsPerAnime}
colorPalette={cardsMinedStackedColors}
/>
<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}
colorPalette={cardsMinedStackedColors}
/>
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader>
<TrendChart
title="Watch Time by Day of Week (min)"
data={data.patterns.watchTimeByDayOfWeek}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Watch Time by Hour (min)"
data={data.patterns.watchTimeByHour}
color="#c6a0f6"
type="bar"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { PerAnimeDataPoint } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 6 },
{ epochDay: 1, animeTitle: 'Trapped in a Dating Sim', value: 20 },
];
test('buildAnimeVisibilityOptions sorts anime by combined contribution', () => {
const titles = buildAnimeVisibilityOptions([
SAMPLE_POINTS,
[
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 8 },
{ epochDay: 1, animeTitle: 'KonoSuba', value: 1 },
],
]);
assert.deepEqual(titles, ['Trapped in a Dating Sim', 'KonoSuba', 'Little Witch Academia']);
});
test('filterHiddenAnimeData removes globally hidden anime from chart data', () => {
const filtered = filterHiddenAnimeData(SAMPLE_POINTS, new Set(['KonoSuba']));
assert.equal(
filtered.some((point) => point.animeTitle === 'KonoSuba'),
false,
);
assert.equal(filtered.length, 2);
});
test('pruneHiddenAnime drops titles that are no longer available', () => {
const hidden = pruneHiddenAnime(new Set(['KonoSuba', 'Ghost in the Shell']), [
'KonoSuba',
'Little Witch Academia',
]);
assert.deepEqual([...hidden], ['KonoSuba']);
});

View File

@@ -0,0 +1,32 @@
import type { PerAnimeDataPoint } from './StackedTrendChart';
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
const totals = new Map<string, number>();
for (const dataset of datasets) {
for (const point of dataset) {
totals.set(point.animeTitle, (totals.get(point.animeTitle) ?? 0) + point.value);
}
}
return [...totals.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([title]) => title);
}
export function filterHiddenAnimeData(
data: PerAnimeDataPoint[],
hiddenAnime: ReadonlySet<string>,
): PerAnimeDataPoint[] {
if (hiddenAnime.size === 0) {
return data;
}
return data.filter((point) => !hiddenAnime.has(point.animeTitle));
}
export function pruneHiddenAnime(
hiddenAnime: ReadonlySet<string>,
availableAnime: readonly string[],
): Set<string> {
const availableSet = new Set(availableAnime);
return new Set([...hiddenAnime].filter((title) => availableSet.has(title)));
}

View File

@@ -0,0 +1,168 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyEntry } from '../../types/stats';
interface CrossAnimeWordsTableProps {
words: VocabularyEntry[];
knownWords: Set<string>;
onSelectWord?: (word: VocabularyEntry) => void;
}
const PAGE_SIZE = 25;
export function CrossAnimeWordsTable({
words,
knownWords,
onSelectWord,
}: CrossAnimeWordsTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.animeCount >= 2);
if (hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !knownWords.has(w.headword) && !knownWords.has(w.word));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (
w.frequencyRank != null &&
(existing.frequencyRank == null || w.frequencyRank < existing.frequencyRank)
) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) existing.reading = w.reading;
if (!existing.partOfSpeech && w.partOfSpeech) existing.partOfSpeech = w.partOfSpeech;
}
}
return [...byHeadword.values()].sort((a, b) => {
if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount;
return b.frequency - a.frequency;
});
}, [words, knownWords, hideKnown, hasKnownData]);
const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2);
if (!hasMultiAnimeWords) return null;
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
>
<span
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
>
{'\u25B6'}
</span>
Words In Multiple Anime
</button>
<div className="flex items-center gap-3">
{hasKnownData && (
<button
type="button"
onClick={() => {
setHideKnown(!hideKnown);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Known
</button>
)}
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown
? 'All multi-anime words are already known!'
: 'No words found across multiple anime.'}
</div>
) : (
<>
<div className="overflow-x-auto mt-3">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium">Reading</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 pr-3 font-medium w-16">Anime</th>
<th className="text-right py-2 font-medium w-16">Seen</th>
</tr>
</thead>
<tbody>
{paged.map((w) => (
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 pr-3 text-right font-mono tabular-nums text-ctp-green text-xs">
{w.animeCount}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Prev
</button>
<span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import type { ExcludedWord } from '../../hooks/useExcludedWords';
interface ExclusionManagerProps {
excluded: ExcludedWord[];
onRemove: (w: ExcludedWord) => void;
onClearAll: () => void;
onClose: () => void;
}
export function ExclusionManager({
excluded,
onRemove,
onClearAll,
onClose,
}: ExclusionManagerProps) {
return (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close exclusion manager"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<div className="absolute inset-x-0 top-1/2 mx-auto max-w-lg -translate-y-1/2 rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex items-center justify-between border-b border-ctp-surface1 px-5 py-4">
<h2 className="text-sm font-semibold text-ctp-text">
Excluded Words
<span className="ml-2 text-ctp-overlay1 font-normal">({excluded.length})</span>
</h2>
<div className="flex items-center gap-2">
{excluded.length > 0 && (
<button
type="button"
className="rounded-md border border-ctp-red/30 px-3 py-1.5 text-xs font-medium text-ctp-red transition hover:bg-ctp-red/10"
onClick={onClearAll}
>
Clear All
</button>
)}
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
</div>
<div className="max-h-80 overflow-y-auto px-5 py-3">
{excluded.length === 0 ? (
<div className="py-6 text-center text-sm text-ctp-overlay2">
No excluded words yet. Use the Exclude button on a word's detail panel to hide it from
stats.
</div>
) : (
<div className="space-y-1.5">
{excluded.map((w) => (
<div
key={`${w.headword}\0${w.word}\0${w.reading}`}
className="flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2"
>
<div className="min-w-0">
<span className="text-sm font-medium text-ctp-text">{w.headword}</span>
{w.reading && w.reading !== w.headword && (
<span className="ml-2 text-xs text-ctp-subtext0">{w.reading}</span>
)}
</div>
<button
type="button"
className="shrink-0 rounded-md border border-ctp-surface2 px-2 py-1 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={() => onRemove(w)}
>
Restore
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react';
import { PosBadge } from './pos-helpers';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyEntry } from '../../types/stats';
interface FrequencyRankTableProps {
words: VocabularyEntry[];
knownWords: Set<string>;
onSelectWord?: (word: VocabularyEntry) => void;
}
const PAGE_SIZE = 25;
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
const isWordKnown = (w: VocabularyEntry): boolean => {
return knownWords.has(w.headword) || knownWords.has(w.word);
};
const ranked = useMemo(() => {
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
if (hideKnown && hasKnownData) {
filtered = filtered.filter((w) => !isWordKnown(w));
}
const byHeadword = new Map<string, VocabularyEntry>();
for (const w of filtered) {
const existing = byHeadword.get(w.headword);
if (!existing) {
byHeadword.set(w.headword, { ...w });
} else {
existing.frequency += w.frequency;
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
if (w.frequencyRank! < existing.frequencyRank!) {
existing.frequencyRank = w.frequencyRank;
}
if (!existing.reading && w.reading) {
existing.reading = w.reading;
}
if (!existing.partOfSpeech && w.partOfSpeech) {
existing.partOfSpeech = w.partOfSpeech;
}
}
}
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
}, [words, knownWords, hideKnown, hasKnownData]);
if (words.every((w) => w.frequencyRank == null)) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3>
<div className="text-xs text-ctp-overlay2">
No frequency rank data available. Run the frequency backfill script or install a frequency
dictionary.
</div>
</div>
);
}
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
>
<span
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
>
{'\u25B6'}
</span>
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
</button>
<div className="flex items-center gap-3">
{hasKnownData && (
<button
type="button"
onClick={() => {
setHideKnown(!hideKnown);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
hideKnown
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Known
</button>
)}
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
</div>
) : (
<>
<div className="overflow-x-auto mt-3">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
<th className="text-left py-2 pr-3 font-medium">Word</th>
<th className="text-left py-2 pr-3 font-medium">Reading</th>
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
<th className="text-right py-2 font-medium w-20">Seen</th>
</tr>
</thead>
<tbody>
{paged.map((w) => (
<tr
key={w.wordId}
onClick={() => onSelectWord?.(w)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
#{w.frequencyRank!.toLocaleString()}
</td>
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Prev
</button>
<span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { KanjiEntry } from '../../types/stats';
interface KanjiBreakdownProps {
kanji: KanjiEntry[];
selectedKanjiId?: number | null;
onSelectKanji?: (entry: KanjiEntry) => void;
}
export function KanjiBreakdown({
kanji,
selectedKanjiId = null,
onSelectKanji,
}: KanjiBreakdownProps) {
if (kanji.length === 0) return null;
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1);
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
<div className="flex flex-wrap gap-1">
{kanji.map((k) => {
const ratio = k.frequency / maxFreq;
const opacity = Math.max(0.3, ratio);
return (
<button
type="button"
key={k.kanji}
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
selectedKanjiId === k.kanjiId
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
: 'hover:bg-ctp-surface1/80'
}`}
style={{ opacity }}
title={`${k.kanji} — seen ${k.frequency}x`}
aria-label={`${k.kanji} — seen ${k.frequency} times`}
onClick={() => onSelectKanji?.(k)}
>
{k.kanji}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
import { useRef, useState, useEffect } from 'react';
import { useKanjiDetail } from '../../hooks/useKanjiDetail';
import { apiClient } from '../../lib/api-client';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import type { VocabularyOccurrenceEntry } from '../../types/stats';
const OCCURRENCES_PAGE_SIZE = 50;
interface KanjiDetailPanelProps {
kanjiId: number | null;
onClose: () => void;
onSelectWord?: (wordId: number) => void;
onNavigateToAnime?: (animeId: number) => void;
}
function formatSegment(ms: number | null): string {
if (ms == null || !Number.isFinite(ms)) return '--:--';
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export function KanjiDetailPanel({
kanjiId,
onClose,
onSelectWord,
onNavigateToAnime,
}: KanjiDetailPanelProps) {
const { data, loading, error } = useKanjiDetail(kanjiId);
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
const [occLoading, setOccLoading] = useState(false);
const [occLoadingMore, setOccLoadingMore] = useState(false);
const [occError, setOccError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [occLoaded, setOccLoaded] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
setOccurrences([]);
setOccLoaded(false);
setOccLoading(false);
setOccLoadingMore(false);
setOccError(null);
setHasMore(false);
requestIdRef.current++;
}, [kanjiId]);
if (kanjiId === null) return null;
const loadOccurrences = async (kanji: string, offset: number, append: boolean) => {
const reqId = ++requestIdRef.current;
if (append) {
setOccLoadingMore(true);
} else {
setOccLoading(true);
setOccError(null);
}
try {
const rows = await apiClient.getKanjiOccurrences(kanji, OCCURRENCES_PAGE_SIZE, offset);
if (reqId !== requestIdRef.current) return;
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
} catch (err) {
if (reqId !== requestIdRef.current) return;
setOccError(err instanceof Error ? err.message : String(err));
if (!append) {
setOccurrences([]);
setHasMore(false);
}
} finally {
if (reqId !== requestIdRef.current) return;
setOccLoading(false);
setOccLoadingMore(false);
setOccLoaded(true);
}
};
const handleShowOccurrences = () => {
if (!data) return;
void loadOccurrences(data.detail.kanji, 0, false);
};
const handleLoadMore = () => {
if (!data || occLoadingMore || !hasMore) return;
void loadOccurrences(data.detail.kanji, occurrences.length, true);
};
return (
<div className="fixed inset-0 z-40">
<button
type="button"
aria-label="Close kanji detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
Kanji Detail
</div>
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
{data && (
<>
<h2 className="mt-1 text-5xl font-semibold text-ctp-teal">{data.detail.kanji}</h2>
<div className="mt-2 text-sm text-ctp-subtext0">
{formatNumber(data.detail.frequency)} total occurrences
</div>
</>
)}
</div>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{data && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-lg font-bold text-ctp-teal">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>
</div>
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
</h3>
<div className="space-y-1.5">
{data.animeAppearances.map((a) => (
<button
key={a.animeId}
type="button"
onClick={() => {
onClose();
onNavigateToAnime?.(a.animeId);
}}
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-teal hover:ring-1 hover:ring-ctp-teal text-left"
>
<span className="truncate text-ctp-text">{a.animeTitle}</span>
<span className="ml-2 shrink-0 rounded-full bg-ctp-teal/10 px-2 py-0.5 text-[11px] font-medium text-ctp-teal">
{formatNumber(a.occurrenceCount)}
</span>
</button>
))}
</div>
</section>
)}
{data.words.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Words Using This Kanji
</h3>
<div className="flex flex-wrap gap-1.5">
{data.words.map((w) => (
<button
key={w.wordId}
type="button"
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
onClick={() => onSelectWord?.(w.wordId)}
>
{w.headword}
<span className="opacity-60">({formatNumber(w.frequency)})</span>
</button>
))}
</div>
</section>
)}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && (
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal"
onClick={handleShowOccurrences}
>
Load example lines
</button>
)}
{occLoading && (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
)}
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
{occLoaded && !occLoading && occurrences.length === 0 && (
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
)}
{occurrences.length > 0 && (
<div className="space-y-3">
{occurrences.map((occ, idx) => (
<article
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{occ.animeTitle ?? occ.videoTitle}
</div>
<div className="truncate text-xs text-ctp-subtext0">
{occ.videoTitle} · line {occ.lineIndex}
</div>
</div>
<div className="rounded-full bg-ctp-teal/10 px-2 py-1 text-[11px] font-medium text-ctp-teal">
{formatNumber(occ.occurrenceCount)} in line
</div>
</div>
<div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} ·
session {occ.sessionId}
</div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occ.text}
</p>
</article>
))}
</div>
)}
</section>
</>
)}
</div>
{occLoaded && !occLoading && !occError && hasMore && (
<div className="border-t border-ctp-surface1 px-4 py-4">
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal disabled:cursor-not-allowed disabled:opacity-60"
onClick={handleLoadMore}
disabled={occLoadingMore}
>
{occLoadingMore ? 'Loading more...' : 'Load more'}
</button>
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import type { KanjiEntry, VocabularyEntry, VocabularyOccurrenceEntry } from '../../types/stats';
import { formatNumber } from '../../lib/formatters';
type VocabularyDrawerTarget =
| {
kind: 'word';
entry: VocabularyEntry;
}
| {
kind: 'kanji';
entry: KanjiEntry;
};
interface VocabularyOccurrencesDrawerProps {
target: VocabularyDrawerTarget | null;
occurrences: VocabularyOccurrenceEntry[];
loading: boolean;
loadingMore: boolean;
error: string | null;
hasMore: boolean;
onClose: () => void;
onLoadMore: () => void;
}
function formatSegment(ms: number | null): string {
if (ms == null || !Number.isFinite(ms)) return '--:--';
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function renderTitle(target: VocabularyDrawerTarget): string {
return target.kind === 'word' ? target.entry.headword : target.entry.kanji;
}
function renderSubtitle(target: VocabularyDrawerTarget): string {
if (target.kind === 'word') {
return target.entry.reading || target.entry.word;
}
return `${formatNumber(target.entry.frequency)} seen`;
}
function renderFrequency(target: VocabularyDrawerTarget): string {
return `${formatNumber(target.entry.frequency)} total`;
}
export function VocabularyOccurrencesDrawer({
target,
occurrences,
loading,
loadingMore,
error,
hasMore,
onClose,
onLoadMore,
}: VocabularyOccurrencesDrawerProps) {
if (!target) return null;
return (
<div className="fixed inset-0 z-40">
<button
type="button"
aria-label="Close occurrence drawer"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
{target.kind === 'word' ? 'Word Occurrences' : 'Kanji Occurrences'}
</div>
<h2 className="mt-1 truncate text-2xl font-semibold text-ctp-text">
{renderTitle(target)}
</h2>
<div className="mt-1 text-sm text-ctp-subtext0">{renderSubtitle(target)}</div>
<div className="mt-2 text-xs text-ctp-overlay1">
{renderFrequency(target)} · {formatNumber(occurrences.length)} loaded
</div>
</div>
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{loading ? (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
) : null}
{!loading && error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
{!loading && !error && occurrences.length === 0 ? (
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
) : null}
{!loading && !error ? (
<div className="space-y-3">
{occurrences.map((occurrence, index) => (
<article
key={`${occurrence.sessionId}-${occurrence.lineIndex}-${occurrence.segmentStartMs ?? index}`}
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{occurrence.animeTitle ?? occurrence.videoTitle}
</div>
<div className="truncate text-xs text-ctp-subtext0">
{occurrence.videoTitle} · line {occurrence.lineIndex}
</div>
</div>
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
{formatNumber(occurrence.occurrenceCount)} in line
</div>
</div>
<div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occurrence.segmentStartMs)}-
{formatSegment(occurrence.segmentEndMs)} · session {occurrence.sessionId}
</div>
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{occurrence.text}
</p>
</article>
))}
</div>
) : null}
</div>
{!loading && !error && hasMore ? (
<div className="border-t border-ctp-surface1 px-4 py-4">
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
onClick={onLoadMore}
disabled={loadingMore}
>
{loadingMore ? 'Loading more...' : 'Load more'}
</button>
</div>
) : null}
</div>
</aside>
</div>
);
}
export type { VocabularyDrawerTarget };

View File

@@ -0,0 +1,211 @@
import { useState, useMemo } from 'react';
import { useVocabulary } from '../../hooks/useVocabulary';
import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList';
import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel';
import { ExclusionManager } from './ExclusionManager';
import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable';
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { ExcludedWord } from '../../hooks/useExcludedWords';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
interface VocabularyTabProps {
onNavigateToAnime?: (animeId: number) => void;
onOpenWordDetail?: (wordId: number) => void;
excluded: ExcludedWord[];
isExcluded: (w: { headword: string; word: string; reading: string }) => boolean;
onRemoveExclusion: (w: ExcludedWord) => void;
onClearExclusions: () => void;
}
function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞';
}
export function VocabularyTab({
onNavigateToAnime,
onOpenWordDetail,
excluded,
isExcluded,
onRemoveExclusion,
onClearExclusions,
}: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false);
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
const filteredWords = useMemo(() => {
let result = words;
if (hideNames) result = result.filter((w) => !isProperNoun(w));
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
return result;
}, [words, hideNames, excluded, isExcluded]);
const summary = useMemo(
() => buildVocabularySummary(filteredWords, kanji),
[filteredWords, kanji],
);
const knownWordCount = useMemo(() => {
if (knownWords.size === 0) return 0;
let count = 0;
for (const w of filteredWords) {
if (knownWords.has(w.headword)) count += 1;
}
return count;
}, [filteredWords, knownWords]);
if (loading) {
return (
<div className="text-ctp-overlay2 p-4" role="status" aria-live="polite">
Loading...
</div>
);
}
if (error) {
return (
<div className="text-ctp-red p-4" role="alert" aria-live="assertive">
Error: {error}
</div>
);
}
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
};
const handleBarClick = (headword: string): void => {
const match = filteredWords.find((w) => w.headword === headword);
if (match) onOpenWordDetail?.(match.wordId);
};
const openKanjiDetail = (entry: KanjiEntry): void => {
setSelectedKanjiId(entry.kanjiId);
};
return (
<div className="space-y-4">
<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-teal"
/>
<StatCard
label="New This Week"
value={`+${formatNumber(summary.newThisWeek)}`}
color="text-ctp-mauve"
/>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search words..."
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{hasNames && (
<button
type="button"
onClick={() => setHideNames(!hideNames)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
hideNames
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Names
</button>
)}
<button
type="button"
onClick={() => setShowExclusionManager(true)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
excluded.length > 0
? 'bg-ctp-surface2 text-ctp-text border-ctp-red/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Exclusions{excluded.length > 0 && ` (${excluded.length})`}
</button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<TrendChart
title="Top Repeated Words"
data={summary.topWords}
color="#8aadf4"
type="bar"
onBarClick={handleBarClick}
/>
<TrendChart
title="New Words by Day"
data={summary.newWordsTimeline}
color="#c6a0f6"
type="line"
/>
</div>
<FrequencyRankTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<CrossAnimeWordsTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<WordList
words={filteredWords}
selectedKey={null}
onSelectWord={handleSelectWord}
search={search}
/>
<KanjiBreakdown
kanji={kanji}
selectedKanjiId={selectedKanjiId}
onSelectKanji={openKanjiDetail}
/>
<KanjiDetailPanel
kanjiId={selectedKanjiId}
onClose={() => setSelectedKanjiId(null)}
onSelectWord={onOpenWordDetail}
onNavigateToAnime={onNavigateToAnime}
/>
{showExclusionManager && (
<ExclusionManager
excluded={excluded}
onRemove={onRemoveExclusion}
onClearAll={onClearExclusions}
onClose={() => setShowExclusionManager(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,471 @@
import { useRef, useState, useEffect } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { fullReading } from '../../lib/reading-utils';
import type { VocabularyOccurrenceEntry } from '../../types/stats';
import { PosBadge } from './pos-helpers';
const INITIAL_PAGE_SIZE = 5;
const LOAD_MORE_SIZE = 10;
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
interface WordDetailPanelProps {
wordId: number | null;
onClose: () => void;
onSelectWord?: (wordId: number) => void;
onNavigateToAnime?: (animeId: number) => void;
isExcluded?: (w: { headword: string; word: string; reading: string }) => boolean;
onToggleExclusion?: (w: { headword: string; word: string; reading: string }) => void;
}
function highlightWord(text: string, words: string[]): React.ReactNode {
const needles = words.filter(Boolean);
if (needles.length === 0) return text;
const escaped = needles.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(${escaped.join('|')})`, 'g');
const parts = text.split(pattern);
const needleSet = new Set(needles);
return parts.map((part, i) =>
needleSet.has(part) ? (
<mark
key={i}
className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2"
>
{part}
</mark>
) : (
part
),
);
}
function formatSegment(ms: number | null): string {
if (ms == null || !Number.isFinite(ms)) return '--:--';
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export function WordDetailPanel({
wordId,
onClose,
onSelectWord,
onNavigateToAnime,
isExcluded,
onToggleExclusion,
}: WordDetailPanelProps) {
const { data, loading, error } = useWordDetail(wordId);
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
const [occLoading, setOccLoading] = useState(false);
const [occLoadingMore, setOccLoadingMore] = useState(false);
const [occError, setOccError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [occLoaded, setOccLoaded] = useState(false);
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
const requestIdRef = useRef(0);
useEffect(() => {
setOccurrences([]);
setOccLoaded(false);
setOccLoading(false);
setOccLoadingMore(false);
setOccError(null);
setHasMore(false);
setMineStatus({});
requestIdRef.current++;
}, [wordId]);
if (wordId === null) return null;
const loadOccurrences = async (
detail: NonNullable<typeof data>['detail'],
offset: number,
limit: number,
append: boolean,
) => {
const reqId = ++requestIdRef.current;
if (append) {
setOccLoadingMore(true);
} else {
setOccLoading(true);
setOccError(null);
}
try {
const rows = await apiClient.getWordOccurrences(
detail.headword,
detail.word,
detail.reading,
limit,
offset,
);
if (reqId !== requestIdRef.current) return;
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
setHasMore(rows.length === limit);
} catch (err) {
if (reqId !== requestIdRef.current) return;
setOccError(err instanceof Error ? err.message : String(err));
if (!append) {
setOccurrences([]);
setHasMore(false);
}
} finally {
if (reqId !== requestIdRef.current) return;
setOccLoading(false);
setOccLoadingMore(false);
setOccLoaded(true);
}
};
const handleShowOccurrences = () => {
if (!data) return;
void loadOccurrences(data.detail, 0, INITIAL_PAGE_SIZE, false);
};
const handleLoadMore = () => {
if (!data || occLoadingMore || !hasMore) return;
void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true);
};
const handleMine = async (
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 {
const result = await apiClient.mineCard({
sourcePath: occ.sourcePath!,
startMs: occ.segmentStartMs!,
endMs: occ.segmentEndMs!,
sentence: occ.text,
word: data!.detail.headword,
secondaryText: occ.secondaryText,
videoTitle: occ.videoTitle,
mode,
});
if (result.error) {
setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } }));
} else {
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
const label =
mode === 'audio'
? 'Audio card'
: mode === 'word'
? data!.detail.headword
: occ.text.slice(0, 30);
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
Notification.requestPermission().then((p) => {
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });
});
}
}
} catch (err) {
setMineStatus((prev) => ({
...prev,
[key]: { error: err instanceof Error ? err.message : String(err) },
}));
}
};
return (
<div className="fixed inset-0 z-40">
<button
type="button"
aria-label="Close word detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose}
/>
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
Word Detail
</div>
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
{data && (
<>
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">
{data.detail.headword}
</h2>
<div className="mt-1 text-sm text-ctp-subtext0">
{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos1}
</span>
)}
{data.detail.pos2 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos2}
</span>
)}
{data.detail.pos3 && (
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos3}
</span>
)}
</div>
</>
)}
</div>
<div className="flex items-center gap-2">
{data && onToggleExclusion && (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition ${
isExcluded?.(data.detail)
? 'border-ctp-red/50 bg-ctp-red/10 text-ctp-red hover:bg-ctp-red/20'
: 'border-ctp-surface2 text-ctp-subtext0 hover:border-ctp-red hover:text-ctp-red'
}`}
onClick={() => onToggleExclusion(data.detail)}
>
{isExcluded?.(data.detail) ? 'Excluded' : 'Exclude'}
</button>
)}
<button
type="button"
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={onClose}
>
Close
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{data && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-lg font-bold text-ctp-blue">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>
</div>
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Anime Appearances
</h3>
<div className="space-y-1.5">
{data.animeAppearances.map((a) => (
<button
key={a.animeId}
type="button"
onClick={() => {
onClose();
onNavigateToAnime?.(a.animeId);
}}
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-blue hover:ring-1 hover:ring-ctp-blue text-left"
>
<span className="truncate text-ctp-text">{a.animeTitle}</span>
<span className="ml-2 shrink-0 rounded-full bg-ctp-blue/10 px-2 py-0.5 text-[11px] font-medium text-ctp-blue">
{formatNumber(a.occurrenceCount)}
</span>
</button>
))}
</div>
</section>
)}
{data.similarWords.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Similar Words
</h3>
<div className="flex flex-wrap gap-1.5">
{data.similarWords.map((sw) => (
<button
key={sw.wordId}
type="button"
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
onClick={() => onSelectWord?.(sw.wordId)}
>
{sw.headword}
<span className="opacity-60">({formatNumber(sw.frequency)})</span>
</button>
))}
</div>
</section>
)}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && (
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
onClick={handleShowOccurrences}
>
Load example lines
</button>
)}
{occLoading && (
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
)}
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
{occLoaded && !occLoading && occurrences.length === 0 && (
<div className="text-sm text-ctp-overlay2">
No example lines tracked yet. Lines are stored for sessions recorded after the
subtitle tracking update.
</div>
)}
{occurrences.length > 0 && (
<div className="space-y-3">
{occurrences.map((occ, idx) => (
<article
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{occ.animeTitle ?? occ.videoTitle}
</div>
<div className="truncate text-xs text-ctp-subtext0">
{occ.videoTitle} · line {occ.lineIndex}
</div>
</div>
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
{formatNumber(occ.occurrenceCount)} in line
</div>
</div>
<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>
{(() => {
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"
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"
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>
</>
);
})()}
</div>
{(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const errors = ['word', 'sentence', 'audio']
.map((m) => mineStatus[`${baseKey}-${m}`]?.error)
.filter(Boolean);
return errors.length > 0 ? (
<div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div>
) : null;
})()}
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}
</p>
</article>
))}
{hasMore && (
<button
type="button"
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
onClick={handleLoadMore}
disabled={occLoadingMore}
>
{occLoadingMore ? 'Loading more...' : 'Load more'}
</button>
)}
</div>
)}
</section>
</>
)}
</div>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useMemo, useState } from 'react';
import type { VocabularyEntry } from '../../types/stats';
import { PosBadge } from './pos-helpers';
interface WordListProps {
words: VocabularyEntry[];
selectedKey?: string | null;
onSelectWord?: (word: VocabularyEntry) => void;
search?: string;
}
type SortKey = 'frequency' | 'lastSeen' | 'firstSeen';
function toWordKey(word: VocabularyEntry): string {
return `${word.headword}\u0000${word.word}\u0000${word.reading}`;
}
const PAGE_SIZE = 100;
export function WordList({ words, selectedKey = null, onSelectWord, search = '' }: WordListProps) {
const [sortBy, setSortBy] = useState<SortKey>('frequency');
const [page, setPage] = useState(0);
const titleBySort: Record<SortKey, string> = {
frequency: 'Most Seen Words',
lastSeen: 'Recently Seen Words',
firstSeen: 'First Seen Words',
};
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return words;
return words.filter(
(w) =>
w.headword.toLowerCase().includes(needle) ||
w.word.toLowerCase().includes(needle) ||
w.reading.toLowerCase().includes(needle),
);
}, [words, search]);
const sorted = useMemo(() => {
const copy = [...filtered];
if (sortBy === 'frequency') copy.sort((a, b) => b.frequency - a.frequency);
else if (sortBy === 'lastSeen') copy.sort((a, b) => b.lastSeen - a.lastSeen);
else copy.sort((a, b) => b.firstSeen - a.firstSeen);
return copy;
}, [filtered, sortBy]);
const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
const paged = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const maxFreq = words.reduce((max, word) => Math.max(max, word.frequency), 1);
const getFrequencyColor = (freq: number) => {
const ratio = freq / maxFreq;
if (ratio > 0.5) return 'text-ctp-blue bg-ctp-blue/10';
if (ratio > 0.2) return 'text-ctp-green bg-ctp-green/10';
return 'text-ctp-mauve bg-ctp-mauve/10';
};
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">
{titleBySort[sortBy]}
{search && (
<span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>
)}
</h3>
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value as SortKey);
setPage(0);
}}
className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1"
>
<option value="frequency">Frequency</option>
<option value="lastSeen">Last Seen</option>
<option value="firstSeen">First Seen</option>
</select>
</div>
<div className="flex flex-wrap gap-1.5">
{paged.map((w) => (
<button
type="button"
key={toWordKey(w)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${getFrequencyColor(
w.frequency,
)} ${
selectedKey === toWordKey(w)
? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0'
: 'hover:ring-1 hover:ring-ctp-surface2'
}`}
title={`${w.word} (${w.reading}) — seen ${w.frequency}x`}
onClick={() => onSelectWord?.(w)}
>
{w.headword}
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
<span className="opacity-60">({w.frequency})</span>
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-3">
<button
type="button"
disabled={page === 0}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
<span className="text-xs text-ctp-overlay1">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</div>
);
}
export { toWordKey };

View File

@@ -0,0 +1,38 @@
import type { VocabularyEntry } from '../../types/stats';
const POS_COLORS: Record<string, string> = {
noun: 'bg-ctp-blue/15 text-ctp-blue',
verb: 'bg-ctp-green/15 text-ctp-green',
adjective: 'bg-ctp-mauve/15 text-ctp-mauve',
adverb: 'bg-ctp-peach/15 text-ctp-peach',
particle: 'bg-ctp-overlay0/15 text-ctp-overlay0',
auxiliary_verb: 'bg-ctp-overlay0/15 text-ctp-overlay0',
conjunction: 'bg-ctp-overlay0/15 text-ctp-overlay0',
prenominal: 'bg-ctp-yellow/15 text-ctp-yellow',
suffix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
prefix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
interjection: 'bg-ctp-rosewater/15 text-ctp-rosewater',
};
const DEFAULT_POS_COLOR = 'bg-ctp-surface1 text-ctp-subtext0';
export function posColor(pos: string): string {
return POS_COLORS[pos] ?? DEFAULT_POS_COLOR;
}
export function PosBadge({ pos }: { pos: string }) {
return (
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}>
{pos.replace(/_/g, ' ')}
</span>
);
}
const PARTICLE_POS = new Set(['particle', 'auxiliary_verb', 'conjunction']);
export function isFilterable(entry: VocabularyEntry): boolean {
if (PARTICLE_POS.has(entry.partOfSpeech ?? '')) return true;
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword))
return true;
return false;
}

View File

@@ -0,0 +1,45 @@
import { useState, useEffect, useCallback } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeDetailData } from '../types/stats';
export function useAnimeDetail(animeId: number | null) {
const [data, setData] = useState<AnimeDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
if (animeId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getAnimeDetail(animeId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [animeId, reloadKey]);
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
return { data, loading, error, reload };
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setAnime(data);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { anime, loading, error };
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useSyncExternalStore } from 'react';
export interface ExcludedWord {
headword: string;
word: string;
reading: string;
}
const STORAGE_KEY = 'subminer-excluded-words';
function toKey(w: ExcludedWord): string {
return `${w.headword}\0${w.word}\0${w.reading}`;
}
let cached: ExcludedWord[] | null = null;
let cachedKeys: Set<string> | null = null;
const listeners = new Set<() => void>();
function load(): ExcludedWord[] {
if (cached) return cached;
try {
const raw = localStorage.getItem(STORAGE_KEY);
cached = raw ? JSON.parse(raw) : [];
} catch {
cached = [];
}
return cached!;
}
function getKeySet(): Set<string> {
if (cachedKeys) return cachedKeys;
cachedKeys = new Set(load().map(toKey));
return cachedKeys;
}
function persist(words: ExcludedWord[]) {
cached = words;
cachedKeys = new Set(words.map(toKey));
localStorage.setItem(STORAGE_KEY, JSON.stringify(words));
for (const fn of listeners) fn();
}
function getSnapshot(): ExcludedWord[] {
return load();
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
export function useExcludedWords() {
const excluded = useSyncExternalStore(subscribe, getSnapshot);
const isExcluded = useCallback(
(w: { headword: string; word: string; reading: string }) => getKeySet().has(toKey(w)),
[excluded],
);
const toggleExclusion = useCallback((w: ExcludedWord) => {
const key = toKey(w);
const current = load();
if (getKeySet().has(key)) {
persist(current.filter((e) => toKey(e) !== key));
} else {
persist([...current, w]);
}
}, []);
const removeExclusion = useCallback((w: ExcludedWord) => {
persist(load().filter((e) => toKey(e) !== toKey(w)));
}, []);
const clearAll = useCallback(() => persist([]), []);
return { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll };
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { KanjiDetailData } from '../types/stats';
export function useKanjiDetail(kanjiId: number | null) {
const [data, setData] = useState<KanjiDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (kanjiId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getKanjiDetail(kanjiId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [kanjiId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { MediaDetailData } from '../types/stats';
export function useMediaDetail(videoId: number | null) {
const [data, setData] = useState<MediaDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (videoId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getMediaDetail(videoId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [videoId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { MediaLibraryItem } from '../types/stats';
export function useMediaLibrary() {
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStatsClient()
.getMediaLibrary()
.then((rows) => {
if (cancelled) return;
setMedia(rows);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { media, loading, error };
}

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { OverviewData, SessionSummary } from '../types/stats';
export function useOverview() {
const [data, setData] = useState<OverviewData | null>(null);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
Promise.all([client.getOverview(), client.getSessions(50)])
.then(([overview, allSessions]) => {
if (cancelled) return;
setData(overview);
setSessions(allSessions);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { data, sessions, setSessions, loading, error };
}

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { toErrorMessage } from './useSessions';
const USE_SESSIONS_PATH = fileURLToPath(new URL('./useSessions.ts', import.meta.url));
test('toErrorMessage normalizes Error and non-Error rejections', () => {
assert.equal(toErrorMessage(new Error('network down')), 'network down');
assert.equal(toErrorMessage('bad gateway'), 'bad gateway');
assert.equal(toErrorMessage(503), '503');
});
test('useSessions and useSessionDetail route catch handlers through toErrorMessage', () => {
const source = fs.readFileSync(USE_SESSIONS_PATH, 'utf8');
const matches = source.match(/setError\(toErrorMessage\(err\)\)/g);
assert.equal(matches?.length, 2);
});

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import { SESSION_CHART_EVENT_TYPES } from '../lib/session-events';
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
export function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function useSessions(limit = 50) {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
client
.getSessions(limit)
.then((nextSessions) => {
if (cancelled) return;
setSessions(nextSessions);
})
.catch((err) => {
if (cancelled) return;
setError(toErrorMessage(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [limit]);
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);
useEffect(() => {
let cancelled = false;
setError(null);
if (sessionId == null) {
setTimeline([]);
setEvents([]);
setKnownWordsTimeline([]);
setLoading(false);
return () => {
cancelled = true;
};
}
setLoading(true);
setTimeline([]);
setEvents([]);
setKnownWordsTimeline([]);
const client = getStatsClient();
Promise.all([
client.getSessionTimeline(sessionId),
client.getSessionEvents(sessionId, 500, [...SESSION_CHART_EVENT_TYPES]),
client.getSessionKnownWordsTimeline(sessionId),
])
.then(([nextTimeline, nextEvents, nextKnownWords]) => {
if (cancelled) return;
setTimeline(nextTimeline);
setEvents(nextEvents);
setKnownWordsTimeline(nextKnownWords);
})
.catch((err) => {
if (cancelled) return;
setError(toErrorMessage(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [sessionId]);
return { timeline, events, knownWordsTimeline, loading, error };
}

View File

@@ -0,0 +1,7 @@
import { apiClient } from '../lib/api-client';
export type StatsClient = typeof apiClient;
export function getStatsClient(): StatsClient {
return apiClient;
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { StreakCalendarDay } from '../types/stats';
export function useStreakCalendar(days = 90) {
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getStreakCalendar(days)
.then((data) => {
if (!cancelled) setCalendar(data);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [days]);
return { calendar, loading, error };
}

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { TrendsDashboardData } from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | 'all';
export type GroupBy = 'day' | 'month';
export function useTrends(range: TimeRange, groupBy: GroupBy) {
const [data, setData] = useState<TrendsDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStatsClient()
.getTrendsDashboard(range, groupBy)
.then((nextData) => {
if (cancelled) return;
setData(nextData);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [range, groupBy]);
return { data, loading, error };
}

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult, knownResult]) => {
if (cancelled) return;
const errors: string[] = [];
if (wordsResult.status === 'fulfilled') {
setWords(wordsResult.value);
} else {
errors.push(wordsResult.reason.message);
}
if (kanjiResult.status === 'fulfilled') {
setKanji(kanjiResult.value);
} else {
errors.push(kanjiResult.reason.message);
}
if (knownResult.status === 'fulfilled') {
setKnownWords(new Set(knownResult.value));
}
if (errors.length > 0) {
setError(errors.join('; '));
}
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { words, kanji, knownWords, loading, error };
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { WordDetailData } from '../types/stats';
export function useWordDetail(wordId: number | null) {
const [data, setData] = useState<WordDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (wordId === null) {
setData(null);
setLoading(false);
setError(null);
return () => {
cancelled = true;
};
}
setLoading(true);
setError(null);
getStatsClient()
.getWordDetail(wordId)
.then((next) => {
if (cancelled) return;
setData(next);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [wordId]);
return { data, loading, error };
}

View File

@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'http:',
origin: 'http://127.0.0.1:6123',
search: '',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1',
});
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(null, { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.deleteSession(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession throws when the stats API delete request fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response('boom', {
status: 500,
statusText: 'Internal Server Error',
})) as typeof globalThis.fetch;
try {
await assert.rejects(() => apiClient.deleteSession(7), /Stats API error: 500 boom/);
} finally {
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;
}
});
test('getSessionEvents can request only specific event types', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getSessionEvents(42, 120, [4, 5, 6, 7, 8, 9]);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sessions/42/events?limit=120&types=4%2C5%2C6%2C7%2C8%2C9`,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('getSessionTimeline requests full session history when limit is omitted', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getSessionTimeline(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42/timeline`);
} finally {
globalThis.fetch = originalFetch;
}
});

220
stats/src/lib/api-client.ts Normal file
View File

@@ -0,0 +1,220 @@
import type {
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
TrendsDashboardData,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
StatsAnkiNoteInfo,
} from '../types/stats';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
const resolvedLocation =
location ??
(typeof window === 'undefined'
? { protocol: 'file:', origin: 'null', search: '' }
: window.location);
const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
if (queryApiBase) {
return queryApiBase;
}
return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
}
export const BASE_URL = resolveStatsBaseUrl();
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE_URL}${path}`, init);
if (!res.ok) {
let body = '';
try {
body = (await res.text()).trim();
} catch {
body = '';
}
throw new Error(
body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`,
);
}
return res;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetchResponse(path);
return res.json() as Promise<T>;
}
export const apiClient = {
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
getDailyRollups: (limit = 60) =>
fetchJson<DailyRollup[]>(`/api/stats/daily-rollups?limit=${limit}`),
getMonthlyRollups: (limit = 24) =>
fetchJson<MonthlyRollup[]>(`/api/stats/monthly-rollups?limit=${limit}`),
getSessions: (limit = 50) => fetchJson<SessionSummary[]>(`/api/stats/sessions?limit=${limit}`),
getSessionTimeline: (id: number, limit?: number) =>
fetchJson<SessionTimelinePoint[]>(
limit === undefined
? `/api/stats/sessions/${id}/timeline`
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
),
getSessionEvents: (id: number, limit = 500, eventTypes?: number[]) => {
const params = new URLSearchParams({ limit: String(limit) });
if (eventTypes && eventTypes.length > 0) {
params.set('types', eventTypes.join(','));
}
return fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?${params.toString()}`);
},
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) =>
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
),
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
),
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) =>
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
getStreakCalendar: (days = 90) =>
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
getEpisodesPerDay: (limit = 90) =>
fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
getNewAnimePerDay: (limit = 90) =>
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) =>
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),
getEpisodeDetail: (videoId: number) =>
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched }),
});
},
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<{
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
>(`/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
reassignAnimeAnilist: async (
animeId: number,
info: {
anilistId: number;
titleRomaji?: string | null;
titleEnglish?: string | null;
titleNative?: string | null;
episodesTotal?: number | null;
description?: string | null;
coverUrl?: string | null;
},
): Promise<void> => {
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info),
});
},
mineCard: async (params: {
sourcePath: string;
startMs: number;
endMs: number;
sentence: string;
word: string;
secondaryText?: string | null;
videoTitle: string;
mode: 'word' | 'sentence' | 'audio';
}): Promise<{ noteId?: number; error?: string; errors?: string[] }> => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
return res.json();
},
ankiBrowse: async (noteId: number): Promise<void> => {
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
},
ankiNotesInfo: async (noteIds: number[]): Promise<StatsAnkiNoteInfo[]> => {
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds }),
});
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
return res.json();
},
};

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const APP_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../App.tsx');
test('App lazy-loads non-overview tabs and detail surfaces behind Suspense boundaries', () => {
const source = fs.readFileSync(APP_PATH, 'utf8');
assert.match(source, /\bSuspense\b/, 'expected Suspense boundary in App');
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
assert.doesNotMatch(source, /import \{ AnimeTab \} from '\.\/components\/anime\/AnimeTab';/);
assert.doesNotMatch(source, /import \{ TrendsTab \} from '\.\/components\/trends\/TrendsTab';/);
assert.doesNotMatch(
source,
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
);
assert.doesNotMatch(
source,
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
);
assert.doesNotMatch(
source,
/import \{ MediaDetailView \} from '\.\/components\/library\/MediaDetailView';/,
);
assert.doesNotMatch(
source,
/import \{ WordDetailPanel \} from '\.\/components\/vocabulary\/WordDetailPanel';/,
);
});

View File

@@ -0,0 +1,8 @@
export const CHART_THEME = {
tick: '#a5adcb',
tooltipBg: '#363a4f',
tooltipBorder: '#494d64',
tooltipText: '#cad3f5',
tooltipLabel: '#b8c0e0',
barFill: '#8aadf4',
} as const;

View File

@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type {
DailyRollup,
OverviewData,
SessionSummary,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import {
buildOverviewSummary,
buildStreakCalendar,
buildTrendDashboard,
buildVocabularySummary,
} from './dashboard-data';
test('buildOverviewSummary aggregates tracked totals and recent windows', () => {
const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000);
const sessions: SessionSummary[] = [
{
sessionId: 1,
canonicalTitle: 'A',
videoId: 1,
animeId: null,
animeTitle: null,
startedAtMs: now - 3_600_000,
endedAtMs: now - 1_800_000,
totalWatchedMs: 3_600_000,
activeWatchedMs: 3_000_000,
linesSeen: 20,
tokensSeen: 80,
cardsMined: 2,
lookupCount: 10,
lookupHits: 8,
yomitanLookupCount: 0,
knownWordsSeen: 10,
knownWordRate: 12.5,
},
];
const rollups: DailyRollup[] = [
{
rollupDayOrMonth: today,
videoId: 1,
totalSessions: 1,
totalActiveMin: 50,
totalLinesSeen: 20,
totalTokensSeen: 80,
totalCards: 2,
cardsPerHour: 2.4,
tokensPerMin: 2,
lookupHitRate: 0.8,
},
];
const overview: OverviewData = {
sessions,
rollups,
hints: {
totalSessions: 15,
activeSessions: 0,
episodesToday: 2,
activeAnimeCount: 3,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
totalActiveMin: 50,
activeDays: 2,
totalCards: 9,
totalLookupCount: 100,
totalLookupHits: 80,
totalTokensSeen: 1000,
totalYomitanLookupCount: 23,
newWordsToday: 5,
newWordsThisWeek: 20,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.todayCards, 2);
assert.equal(summary.totalTrackedCards, 9);
assert.equal(summary.episodesToday, 2);
assert.equal(summary.activeAnimeCount, 3);
assert.equal(summary.averageSessionMinutes, 50);
assert.equal(summary.allTimeMinutes, 50);
assert.equal(summary.activeDays, 2);
assert.equal(summary.totalSessions, 15);
assert.deepEqual(summary.lookupRate, {
shortValue: '2.3 / 100 words',
longValue: '2.3 lookups per 100 words',
});
});
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: [
{
sessionId: 2,
canonicalTitle: 'B',
videoId: 2,
animeId: null,
animeTitle: null,
startedAtMs: now - 60_000,
endedAtMs: now,
totalWatchedMs: 60_000,
activeWatchedMs: 60_000,
linesSeen: 10,
tokensSeen: 10,
cardsMined: 10,
lookupCount: 1,
lookupHits: 1,
yomitanLookupCount: 0,
knownWordsSeen: 2,
knownWordRate: 20,
},
],
rollups: [
{
rollupDayOrMonth: today,
videoId: 2,
totalSessions: 1,
totalActiveMin: 1,
totalLinesSeen: 10,
totalTokensSeen: 10,
totalCards: 10,
cardsPerHour: 600,
tokensPerMin: 10,
lookupHitRate: 1,
},
],
hints: {
totalSessions: 50,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalActiveMin: 120,
activeDays: 40,
totalCards: 5,
totalLookupCount: 0,
totalLookupHits: 0,
totalTokensSeen: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},
};
const summary = buildOverviewSummary(overview, now);
assert.equal(summary.totalTrackedCards, 5);
assert.equal(summary.allTimeMinutes, 120);
assert.equal(summary.activeDays, 40);
assert.equal(summary.lookupRate, null);
});
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
const now = Date.UTC(2026, 2, 13, 12);
const nowSec = now / 1000;
const words: VocabularyEntry[] = [
{
wordId: 1,
headword: '猫',
word: '猫',
reading: 'ねこ',
partOfSpeech: null,
pos1: null,
pos2: null,
pos3: null,
frequency: 4,
frequencyRank: null,
animeCount: 1,
firstSeen: nowSec - 2 * 86_400,
lastSeen: nowSec - 1,
},
];
const summary = buildVocabularySummary(words, [], now);
assert.equal(summary.newThisWeek, 1);
});
test('buildTrendDashboard derives dense chart series', () => {
const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000);
const rollups: DailyRollup[] = [
{
rollupDayOrMonth: today - 1,
videoId: 1,
totalSessions: 2,
totalActiveMin: 60,
totalLinesSeen: 30,
totalTokensSeen: 100,
totalCards: 3,
cardsPerHour: 3,
tokensPerMin: 2,
lookupHitRate: 0.5,
},
{
rollupDayOrMonth: today,
videoId: 1,
totalSessions: 1,
totalActiveMin: 30,
totalLinesSeen: 10,
totalTokensSeen: 30,
totalCards: 1,
cardsPerHour: 2,
tokensPerMin: 1.33,
lookupHitRate: 0.75,
},
];
const dashboard = buildTrendDashboard(rollups);
assert.equal(dashboard.watchTime.length, 2);
assert.equal(dashboard.words[1]?.value, 30);
assert.equal(dashboard.sessions[0]?.value, 2);
});
test('buildStreakCalendar converts epoch days to YYYY-MM-DD dates', () => {
const days: StreakCalendarDay[] = [
{ epochDay: 20525, totalActiveMin: 45 },
{ epochDay: 20526, totalActiveMin: 0 },
{ epochDay: 20527, totalActiveMin: 30 },
];
const points = buildStreakCalendar(days);
assert.equal(points.length, 3);
assert.match(points[0]!.date, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(points[0]!.value, 45);
assert.equal(points[1]!.value, 0);
assert.equal(points[2]!.value, 30);
});

View File

@@ -0,0 +1,272 @@
import type {
DailyRollup,
KanjiEntry,
OverviewData,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import { epochDayToDate, epochMsFromDbTimestamp, localDayFromMs } from './formatters';
import { buildLookupRateDisplay, type LookupRateDisplay } from './yomitan-lookup';
export interface ChartPoint {
label: string;
value: number;
}
export interface OverviewSummary {
todayActiveMs: number;
todayCards: number;
streakDays: number;
allTimeMinutes: number;
totalTrackedCards: number;
episodesToday: number;
activeAnimeCount: number;
totalEpisodesWatched: number;
totalAnimeCompleted: number;
averageSessionMinutes: number;
activeDays: number;
totalSessions: number;
lookupRate: LookupRateDisplay | null;
todayTokens: number;
newWordsToday: number;
newWordsThisWeek: number;
recentWatchTime: ChartPoint[];
}
export interface TrendDashboard {
watchTime: ChartPoint[];
cards: ChartPoint[];
words: ChartPoint[];
sessions: ChartPoint[];
cardsPerHour: ChartPoint[];
lookupHitRate: ChartPoint[];
averageSessionMinutes: ChartPoint[];
}
export interface VocabularySummary {
uniqueWords: number;
uniqueKanji: number;
newThisWeek: number;
topWords: ChartPoint[];
newWordsTimeline: ChartPoint[];
recentDiscoveries: VocabularyEntry[];
}
function normalizeDbTimestampSeconds(ts: number): number {
return Math.floor(epochMsFromDbTimestamp(ts) / 1000);
}
function makeRollupLabel(value: number): string {
if (value > 100_000) {
const year = Math.floor(value / 100);
const month = value % 100;
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, {
month: 'short',
year: '2-digit',
});
}
return epochDayToDate(value).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
function sumBy<T>(values: T[], select: (value: T) => number): number {
return values.reduce((sum, value) => sum + select(value), 0);
}
function buildAggregatedDailyRows(rollups: DailyRollup[]) {
const byKey = new Map<
number,
{
activeMin: number;
cards: number;
words: number;
sessions: number;
lookupHitRateSum: number;
lookupWeight: number;
}
>();
for (const rollup of rollups) {
const existing = byKey.get(rollup.rollupDayOrMonth) ?? {
activeMin: 0,
cards: 0,
words: 0,
sessions: 0,
lookupHitRateSum: 0,
lookupWeight: 0,
};
existing.activeMin += rollup.totalActiveMin;
existing.cards += rollup.totalCards;
existing.words += rollup.totalTokensSeen;
existing.sessions += rollup.totalSessions;
if (rollup.lookupHitRate != null) {
const weight = Math.max(rollup.totalSessions, 1);
existing.lookupHitRateSum += rollup.lookupHitRate * weight;
existing.lookupWeight += weight;
}
byKey.set(rollup.rollupDayOrMonth, existing);
}
return Array.from(byKey.entries())
.sort(([left], [right]) => left - right)
.map(([key, value]) => ({
key,
label: makeRollupLabel(key),
activeMin: Math.round(value.activeMin),
cards: value.cards,
words: value.words,
sessions: value.sessions,
cardsPerHour: value.activeMin > 0 ? +((value.cards * 60) / value.activeMin).toFixed(1) : 0,
averageSessionMinutes:
value.sessions > 0 ? +(value.activeMin / value.sessions).toFixed(1) : 0,
lookupHitRate:
value.lookupWeight > 0
? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100)
: 0,
}));
}
export function buildOverviewSummary(
overview: OverviewData,
nowMs: number = Date.now(),
): OverviewSummary {
const today = localDayFromMs(nowMs);
const aggregated = buildAggregatedDailyRows(overview.rollups);
const todayRow = aggregated.find((row) => row.key === today);
const daysWithActivity = new Set(
aggregated.filter((row) => row.activeMin > 0).map((row) => row.key),
);
const sessionCards = sumBy(overview.sessions, (session) => session.cardsMined);
const rollupCards = sumBy(aggregated, (row) => row.cards);
const lifetimeCards = overview.hints.totalCards ?? Math.max(sessionCards, rollupCards);
const totalActiveMin = overview.hints.totalActiveMin ?? sumBy(aggregated, (row) => row.activeMin);
let streakDays = 0;
const streakStart = daysWithActivity.has(today) ? today : today - 1;
for (let day = streakStart; daysWithActivity.has(day); day -= 1) {
streakDays += 1;
}
const todaySessions = overview.sessions.filter(
(session) => localDayFromMs(session.startedAtMs) === today,
);
const todayActiveFromSessions = sumBy(todaySessions, (session) => session.activeWatchedMs);
const todayActiveFromRollup = (todayRow?.activeMin ?? 0) * 60_000;
return {
todayActiveMs: Math.max(todayActiveFromRollup, todayActiveFromSessions),
todayCards: Math.max(
todayRow?.cards ?? 0,
sumBy(todaySessions, (session) => session.cardsMined),
),
streakDays,
allTimeMinutes: Math.max(0, Math.round(totalActiveMin)),
totalTrackedCards: lifetimeCards,
episodesToday: overview.hints.episodesToday ?? 0,
activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
totalEpisodesWatched: overview.hints.totalEpisodesWatched ?? 0,
totalAnimeCompleted: overview.hints.totalAnimeCompleted ?? 0,
averageSessionMinutes:
overview.sessions.length > 0
? Math.round(
sumBy(overview.sessions, (session) => session.activeWatchedMs) /
overview.sessions.length /
60_000,
)
: 0,
activeDays: overview.hints.activeDays ?? daysWithActivity.size,
totalSessions: overview.hints.totalSessions ?? overview.sessions.length,
lookupRate: buildLookupRateDisplay(
overview.hints.totalYomitanLookupCount,
overview.hints.totalTokensSeen,
),
todayTokens: Math.max(
todayRow?.words ?? 0,
sumBy(todaySessions, (session) => session.tokensSeen),
),
newWordsToday: overview.hints.newWordsToday ?? 0,
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,
recentWatchTime: aggregated
.slice(-14)
.map((row) => ({ label: row.label, value: row.activeMin })),
};
}
export function buildTrendDashboard(rollups: DailyRollup[]): TrendDashboard {
const aggregated = buildAggregatedDailyRows(rollups);
return {
watchTime: aggregated.map((row) => ({ label: row.label, value: row.activeMin })),
cards: aggregated.map((row) => ({ label: row.label, value: row.cards })),
words: aggregated.map((row) => ({ label: row.label, value: row.words })),
sessions: aggregated.map((row) => ({ label: row.label, value: row.sessions })),
cardsPerHour: aggregated.map((row) => ({ label: row.label, value: row.cardsPerHour })),
lookupHitRate: aggregated.map((row) => ({ label: row.label, value: row.lookupHitRate })),
averageSessionMinutes: aggregated.map((row) => ({
label: row.label,
value: row.averageSessionMinutes,
})),
};
}
export function buildVocabularySummary(
words: VocabularyEntry[],
kanji: KanjiEntry[],
nowMs: number = Date.now(),
): VocabularySummary {
const weekAgoSec = nowMs / 1000 - 7 * 86_400;
const byDay = new Map<number, number>();
for (const word of words) {
const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen);
const day = Math.floor(firstSeenSec / 86_400);
byDay.set(day, (byDay.get(day) ?? 0) + 1);
}
return {
uniqueWords: words.length,
uniqueKanji: kanji.length,
newThisWeek: words.filter((word) => {
const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen);
return firstSeenSec >= weekAgoSec;
}).length,
topWords: [...words]
.sort((left, right) => right.frequency - left.frequency)
.slice(0, 12)
.map((word) => ({ label: word.headword, value: word.frequency })),
newWordsTimeline: Array.from(byDay.entries())
.sort(([left], [right]) => left - right)
.slice(-14)
.map(([day, count]) => ({
label: makeRollupLabel(day),
value: count,
})),
recentDiscoveries: [...words]
.sort((left, right) => {
const leftFirst = normalizeDbTimestampSeconds(left.firstSeen);
const rightFirst = normalizeDbTimestampSeconds(right.firstSeen);
return rightFirst - leftFirst;
})
.slice(0, 8),
};
}
export interface StreakCalendarPoint {
date: string;
value: number;
}
export function buildStreakCalendar(days: StreakCalendarDay[]): StreakCalendarPoint[] {
return days.map((d) => {
const dt = epochDayToDate(d.epochDay);
const y = dt.getUTCFullYear();
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
const day = String(dt.getUTCDate()).padStart(2, '0');
return { date: `${y}-${m}-${day}`, value: d.totalActiveMin };
});
}

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
confirmDayGroupDelete,
confirmEpisodeDelete,
confirmSessionDelete,
} from './delete-confirm';
test('confirmSessionDelete uses the shared session delete 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(confirmSessionDelete(), true);
assert.deepEqual(calls, ['Delete this session and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
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;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return false;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmEpisodeDelete('Episode 4'), false);
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
} finally {
globalThis.confirm = originalConfirm;
}
});

View File

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

View File

@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
test('formatRelativeDate: future timestamps return "just now"', () => {
assert.equal(formatRelativeDate(Date.now() + 60_000), 'just now');
});
test('formatRelativeDate: 0ms ago returns "just now"', () => {
assert.equal(formatRelativeDate(Date.now()), 'just now');
});
test('formatRelativeDate: 30s ago returns "just now"', () => {
assert.equal(formatRelativeDate(Date.now() - 30_000), 'just now');
});
test('formatRelativeDate: 5 minutes ago returns "5m ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 5 * 60_000), '5m ago');
});
test('formatRelativeDate: 59 minutes ago returns "59m ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 59 * 60_000), '59m ago');
});
test('formatRelativeDate: 2 hours ago returns "2h ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago');
});
test('formatRelativeDate: same calendar day can return "23h ago"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 23, 30, 0).getTime();
const sameDayMorning = new Date(2026, 2, 16, 0, 30, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(sameDayMorning), '23h ago');
} finally {
Date.now = realNow;
}
});
test('formatRelativeDate: two calendar days ago returns "2d ago"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 12, 0, 0).getTime();
const twoDaysAgo = new Date(2026, 2, 14, 0, 0, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(twoDaysAgo), '2d ago');
} finally {
Date.now = realNow;
}
});
test('formatRelativeDate: 5 days ago returns "5d ago"', () => {
assert.equal(formatRelativeDate(Date.now() - 5 * 86_400_000), '5d ago');
});
test('formatRelativeDate: 10 days ago returns locale date string', () => {
const ts = Date.now() - 10 * 86_400_000;
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString());
});
test('formatRelativeDate: prior calendar day under 24h returns "Yesterday"', () => {
const realNow = Date.now;
const now = new Date(2026, 2, 16, 0, 30, 0).getTime();
const previousDayLate = new Date(2026, 2, 15, 23, 45, 0).getTime();
Date.now = () => now;
try {
assert.equal(formatRelativeDate(previousDayLate), 'Yesterday');
} finally {
Date.now = realNow;
}
});
test('epochMsFromDbTimestamp converts seconds to ms', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000), 1_700_000_000_000);
});
test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
});
test('formatSessionDayLabel formats today and yesterday', () => {
const now = Date.now();
const oneDayMs = 24 * 60 * 60_000;
assert.equal(formatSessionDayLabel(now), 'Today');
assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday');
});
test('formatSessionDayLabel includes year for past-year dates', () => {
const now = new Date();
const sameDayLastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()).getTime();
const label = formatSessionDayLabel(sameDayLastYear);
const year = new Date(sameDayLastYear).getFullYear();
assert.ok(label.includes(String(year)));
const withoutYear = new Date(sameDayLastYear).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
});
assert.notEqual(label, withoutYear);
});

View File

@@ -0,0 +1,75 @@
export function formatDuration(ms: number): string {
const totalMin = Math.round(ms / 60_000);
if (totalMin < 60) return `${totalMin}m`;
const hours = Math.floor(totalMin / 60);
const mins = totalMin % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
export function formatNumber(n: number): string {
return n.toLocaleString();
}
export function formatPercent(ratio: number | null): string {
if (ratio == null) return '\u2014';
return `${Math.round(ratio * 100)}%`;
}
export function formatRelativeDate(ms: number): string {
const now = Date.now();
const diffMs = now - ms;
if (diffMs <= 0) return 'just now';
const nowDay = localDayFromMs(now);
const sessionDay = localDayFromMs(ms);
const dayDiff = nowDay - sessionDay;
if (dayDiff <= 0) {
if (diffMs < 60_000) return 'just now';
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHours = Math.floor(diffMs / 3_600_000);
return `${diffHours}h ago`;
}
if (dayDiff === 1) return 'Yesterday';
if (dayDiff < 7) return `${dayDiff}d ago`;
return new Date(ms).toLocaleDateString();
}
export function epochDayToDate(epochDay: number): Date {
return new Date(epochDay * 86_400_000);
}
export function localDayFromMs(ms: number): number {
const d = new Date(ms);
const localMidnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
return Math.floor(localMidnight / 86_400_000);
}
export function todayLocalDay(): number {
return localDayFromMs(Date.now());
}
// Immersion tracker stores word/kanji first_seen/last_seen as epoch seconds.
// Older fixtures or callers may still pass ms, so normalize defensively.
export function epochMsFromDbTimestamp(ts: number): number {
if (!Number.isFinite(ts)) return 0;
return ts < 10_000_000_000 ? Math.round(ts * 1000) : Math.round(ts);
}
export function formatSessionDayLabel(sessionStartedAtMs: number): string {
const today = todayLocalDay();
const day = localDayFromMs(sessionStartedAtMs);
if (day === today) return 'Today';
if (day === today - 1) return 'Yesterday';
const date = new Date(sessionStartedAtMs);
const includeYear = date.getFullYear() !== new Date().getFullYear();
return date.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
});
}

109
stats/src/lib/ipc-client.ts Normal file
View File

@@ -0,0 +1,109 @@
import type {
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
StatsAnkiNoteInfo,
} from '../types/stats';
interface StatsElectronAPI {
stats: {
getOverview: () => Promise<OverviewData>;
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
getSessions: (limit?: number) => Promise<SessionSummary[]>;
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
getSessionEvents: (id: number, limit?: number) => Promise<SessionEvent[]>;
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
getWordOccurrences: (
headword: string,
word: string,
reading: string,
limit?: number,
offset?: number,
) => Promise<VocabularyOccurrenceEntry[]>;
getKanji: (limit?: number) => Promise<KanjiEntry[]>;
getKanjiOccurrences: (
kanji: string,
limit?: number,
offset?: number,
) => Promise<VocabularyOccurrenceEntry[]>;
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
getAnimeCoverUrl: (animeId: number) => string;
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
getWordDetail: (wordId: number) => Promise<WordDetailData>;
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
hideOverlay: () => void;
};
}
declare global {
interface Window {
electronAPI?: StatsElectronAPI;
}
}
function getIpc(): StatsElectronAPI['stats'] {
const api = window.electronAPI?.stats;
if (!api) throw new Error('Electron IPC not available');
return api;
}
export const ipcClient = {
getOverview: () => getIpc().getOverview(),
getDailyRollups: (limit = 60) => getIpc().getDailyRollups(limit),
getMonthlyRollups: (limit = 24) => getIpc().getMonthlyRollups(limit),
getSessions: (limit = 50) => getIpc().getSessions(limit),
getSessionTimeline: (id: number, limit?: number) => getIpc().getSessionTimeline(id, limit),
getSessionEvents: (id: number, limit = 500) => getIpc().getSessionEvents(id, limit),
getVocabulary: (limit = 100) => getIpc().getVocabulary(limit),
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
getIpc().getWordOccurrences(headword, word, reading, limit, offset),
getKanji: (limit = 100) => getIpc().getKanji(limit),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
getIpc().getKanjiOccurrences(kanji, limit, offset),
getMediaLibrary: () => getIpc().getMediaLibrary(),
getMediaDetail: (videoId: number) => getIpc().getMediaDetail(videoId),
getAnimeLibrary: () => getIpc().getAnimeLibrary(),
getAnimeDetail: (animeId: number) => getIpc().getAnimeDetail(animeId),
getAnimeWords: (animeId: number, limit = 50) => getIpc().getAnimeWords(animeId, limit),
getAnimeRollups: (animeId: number, limit = 90) => getIpc().getAnimeRollups(animeId, limit),
getAnimeCoverUrl: (animeId: number) => getIpc().getAnimeCoverUrl(animeId),
getStreakCalendar: (days = 90) => getIpc().getStreakCalendar(days),
getEpisodesPerDay: (limit = 90) => getIpc().getEpisodesPerDay(limit),
getNewAnimePerDay: (limit = 90) => getIpc().getNewAnimePerDay(limit),
getWatchTimePerAnime: (limit = 90) => getIpc().getWatchTimePerAnime(limit),
getWordDetail: (wordId: number) => getIpc().getWordDetail(wordId),
getKanjiDetail: (kanjiId: number) => getIpc().getKanjiDetail(kanjiId),
getEpisodeDetail: (videoId: number) => getIpc().getEpisodeDetail(videoId),
ankiBrowse: (noteId: number) => getIpc().ankiBrowse(noteId),
ankiNotesInfo: (noteIds: number[]) => getIpc().ankiNotesInfo(noteIds),
};

View File

@@ -0,0 +1,40 @@
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,
tokensSeen: 24,
cardsMined: 2,
lookupCount: 3,
lookupHits: 2,
yomitanLookupCount: 1,
knownWordsSeen: 6,
knownWordRate: 25,
},
]}
onDeleteSession={() => {}}
initialExpandedSessionId={7}
/>,
);
assert.match(markup, /Session History/);
assert.match(markup, /aria-expanded="true"/);
assert.match(markup, /Delete session Episode 7/);
assert.match(markup, /words/);
assert.match(markup, /No word data for this session/);
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { fullReading } from './reading-utils';
describe('fullReading', () => {
it('prefixes leading hiragana from headword', () => {
// お前 with reading まえ → おまえ
expect(fullReading('お前', 'まえ')).toBe('おまえ');
});
it('handles katakana stored readings', () => {
// お前 with katakana reading マエ → おまえ
expect(fullReading('お前', 'マエ')).toBe('おまえ');
});
it('returns stored reading when it already includes leading kana', () => {
// Reading already correct
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
});
it('handles trailing hiragana', () => {
// 隠す with reading かくす — す is trailing hiragana
expect(fullReading('隠す', 'かくす')).toBe('かくす');
});
it('handles pure kanji headwords', () => {
expect(fullReading('様', 'さま')).toBe('さま');
});
it('returns empty for empty reading', () => {
expect(fullReading('前', '')).toBe('');
});
it('returns empty for empty headword', () => {
expect(fullReading('', 'まえ')).toBe('まえ');
});
it('handles all-kana headword', () => {
// Headword is already all hiragana
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
});
it('handles mixed leading and trailing kana', () => {
// お気に入り: お=leading, に入り=trailing around 気
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
});
it('handles katakana in headword', () => {
// カズマ様 — leading katakana + kanji
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
});
});

View File

@@ -0,0 +1,73 @@
function isHiragana(ch: string): boolean {
const code = ch.charCodeAt(0);
return code >= 0x3040 && code <= 0x309f;
}
function isKatakana(ch: string): boolean {
const code = ch.charCodeAt(0);
return code >= 0x30a0 && code <= 0x30ff;
}
function katakanaToHiragana(text: string): string {
let result = '';
for (const ch of text) {
const code = ch.charCodeAt(0);
if (code >= 0x30a1 && code <= 0x30f6) {
result += String.fromCharCode(code - 0x60);
} else {
result += ch;
}
}
return result;
}
/**
* Reconstruct the full word reading from the surface form and the stored
* (possibly partial) reading.
*
* MeCab/Yomitan sometimes stores only the kanji portion's reading. For example,
* お前 (surface) with reading まえ — the stored reading covers only 前, missing
* the leading お. This function walks through the surface form: hiragana/katakana
* characters pass through as-is (converted to hiragana), and the remaining kanji
* portion is filled in from the stored reading.
*/
export function fullReading(headword: string, storedReading: string): string {
if (!storedReading || !headword) return storedReading || '';
const reading = katakanaToHiragana(storedReading);
const leadingKana: string[] = [];
const trailingKana: string[] = [];
const chars = [...headword];
let i = 0;
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
leadingKana.push(katakanaToHiragana(chars[i]));
i++;
}
if (i === chars.length) {
return reading;
}
let j = chars.length - 1;
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
trailingKana.unshift(katakanaToHiragana(chars[j]));
j--;
}
// Strip matching trailing kana from the stored reading to get the core kanji reading
let coreReading = reading;
const trailStr = trailingKana.join('');
if (trailStr && coreReading.endsWith(trailStr)) {
coreReading = coreReading.slice(0, -trailStr.length);
}
// Strip matching leading kana from the stored reading if it already includes them
const leadStr = leadingKana.join('');
if (leadStr && coreReading.startsWith(leadStr)) {
return reading;
}
return leadStr + coreReading + trailStr;
}

View File

@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { SessionDetail, getKnownPctAxisMax } from '../components/sessions/SessionDetail';
import { buildSessionChartEvents } from './session-events';
import { EventType } from '../types/stats';
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,
tokensSeen: 24,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
}}
/>,
);
assert.match(markup, /No word data/);
assert.doesNotMatch(markup, /New words/);
});
test('buildSessionChartEvents keeps only chart-relevant events and pairs pause ranges', () => {
const chartEvents = buildSessionChartEvents([
{ eventType: EventType.SUBTITLE_LINE, tsMs: 1_000, payload: '{"line":"ignored"}' },
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
{ eventType: EventType.SEEK_FORWARD, tsMs: 3_000, payload: null },
{ eventType: EventType.PAUSE_END, tsMs: 4_000, payload: null },
{ eventType: EventType.CARD_MINED, tsMs: 5_000, payload: '{"cardsMined":1}' },
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 6_000, payload: null },
{ eventType: EventType.SEEK_BACKWARD, tsMs: 7_000, payload: null },
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
]);
assert.deepEqual(
chartEvents.seekEvents.map((event) => event.eventType),
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
);
assert.deepEqual(
chartEvents.cardEvents.map((event) => event.tsMs),
[5_000],
);
assert.deepEqual(
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
[6_000],
);
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
});
test('getKnownPctAxisMax adds headroom above the highest known percentage', () => {
assert.equal(getKnownPctAxisMax([22.4, 31.2, 29.8]), 40);
});
test('getKnownPctAxisMax caps the chart top at 100%', () => {
assert.equal(getKnownPctAxisMax([97.1, 98.6]), 100);
});

View File

@@ -0,0 +1,226 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventType } from '../types/stats';
import {
buildSessionChartEvents,
collectPendingSessionEventNoteIds,
extractSessionEventNoteInfo,
getSessionEventCardRequest,
mergeSessionEventNoteInfos,
projectSessionMarkerLeftPx,
resolveActiveSessionMarkerKey,
togglePinnedSessionMarkerKey,
} from './session-events';
test('buildSessionChartEvents produces typed hover markers with parsed payload metadata', () => {
const chartEvents = buildSessionChartEvents([
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
{
eventType: EventType.SEEK_FORWARD,
tsMs: 3_000,
payload: '{"fromMs":1000,"toMs":5500}',
},
{ eventType: EventType.PAUSE_END, tsMs: 5_000, payload: null },
{
eventType: EventType.CARD_MINED,
tsMs: 6_000,
payload: '{"cardsMined":2,"noteIds":[11,22]}',
},
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
]);
assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind),
['seek', 'pause', 'card'],
);
const seekMarker = chartEvents.markers[0]!;
assert.equal(seekMarker.kind, 'seek');
assert.equal(seekMarker.direction, 'forward');
assert.equal(seekMarker.fromMs, 1_000);
assert.equal(seekMarker.toMs, 5_500);
const pauseMarker = chartEvents.markers[1]!;
assert.equal(pauseMarker.kind, 'pause');
assert.equal(pauseMarker.startMs, 2_000);
assert.equal(pauseMarker.endMs, 5_000);
assert.equal(pauseMarker.durationMs, 3_000);
assert.equal(pauseMarker.anchorTsMs, 3_500);
const cardMarker = chartEvents.markers[2]!;
assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]);
assert.equal(cardMarker.cardsDelta, 2);
assert.deepEqual(
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
[7_000],
);
});
test('projectSessionMarkerLeftPx respects chart plot offsets instead of full-width percentages', () => {
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 1_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
5,
);
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 6_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
484,
);
assert.equal(
projectSessionMarkerLeftPx({
anchorTsMs: 11_000,
tsMin: 1_000,
tsMax: 11_000,
plotLeftPx: 5,
plotWidthPx: 958,
}),
963,
);
});
test('extractSessionEventNoteInfo prefers expression-like fields and strips html', () => {
const info = extractSessionEventNoteInfo({
noteId: 91,
fields: {
Sentence: { value: '<div>この呪いの剣は危険だ</div>' },
Vocabulary: { value: '<span>呪いの剣</span>' },
Meaning: { value: '<div>cursed sword</div>' },
},
});
assert.deepEqual(info, {
noteId: 91,
expression: '呪いの剣',
context: 'この呪いの剣は危険だ',
meaning: 'cursed sword',
});
});
test('extractSessionEventNoteInfo prefers explicit preview payload over field-name guessing', () => {
const info = extractSessionEventNoteInfo({
noteId: 92,
preview: {
word: '連れる',
sentence: 'このまま 連れてって',
translation: 'to take along',
},
fields: {
UnexpectedWordField: { value: 'should not win' },
UnexpectedSentenceField: { value: 'should not win either' },
},
});
assert.deepEqual(info, {
noteId: 92,
expression: '連れる',
context: 'このまま 連れてって',
meaning: 'to take along',
});
});
test('extractSessionEventNoteInfo ignores malformed notes without a numeric note id', () => {
assert.equal(
extractSessionEventNoteInfo({
noteId: Number.NaN,
fields: {
Vocabulary: { value: '呪い' },
},
}),
null,
);
});
test('mergeSessionEventNoteInfos keys previews by both requested and returned note ids', () => {
const noteInfos = mergeSessionEventNoteInfos(
[111],
[
{
noteId: 222,
fields: {
Expression: { value: '呪い' },
Sentence: { value: 'この剣は呪いだ' },
},
},
],
);
assert.deepEqual(noteInfos.get(111), {
noteId: 222,
expression: '呪い',
context: 'この剣は呪いだ',
meaning: null,
});
assert.deepEqual(noteInfos.get(222), {
noteId: 222,
expression: '呪い',
context: 'この剣は呪いだ',
meaning: null,
});
});
test('collectPendingSessionEventNoteIds supports strict-mode cleanup and refetch', () => {
const noteInfos = new Map();
const pendingNoteIds = new Set<number>();
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]);
pendingNoteIds.add(177);
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []);
pendingNoteIds.delete(177);
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]);
noteInfos.set(177, {
noteId: 177,
expression: '対抗',
context: 'ダクネス 無理して 対抗 するな',
meaning: null,
});
assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []);
});
test('getSessionEventCardRequest stays stable across rebuilt marker objects', () => {
const events = [
{
eventType: EventType.CARD_MINED,
tsMs: 6_000,
payload: '{"cardsMined":1,"noteIds":[1773808840964]}',
},
];
const firstMarker = buildSessionChartEvents(events).markers[0]!;
const secondMarker = buildSessionChartEvents(events).markers[0]!;
assert.notEqual(firstMarker, secondMarker);
assert.deepEqual(getSessionEventCardRequest(firstMarker), {
noteIds: [1773808840964],
requestKey: 'card-6000:1773808840964',
});
assert.deepEqual(getSessionEventCardRequest(secondMarker), {
noteIds: [1773808840964],
requestKey: 'card-6000:1773808840964',
});
});
test('session marker pin helpers prefer pinned markers and toggle on repeat clicks', () => {
assert.equal(resolveActiveSessionMarkerKey('card-1', 'seek-2'), 'seek-2');
assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1');
assert.equal(togglePinnedSessionMarkerKey(null, 'card-1'), 'card-1');
assert.equal(togglePinnedSessionMarkerKey('card-1', 'card-1'), null);
assert.equal(togglePinnedSessionMarkerKey('card-1', 'seek-2'), 'seek-2');
});

View File

@@ -0,0 +1,384 @@
import { EventType, type SessionEvent } from '../types/stats';
export const SESSION_CHART_EVENT_TYPES = [
EventType.CARD_MINED,
EventType.SEEK_FORWARD,
EventType.SEEK_BACKWARD,
EventType.PAUSE_START,
EventType.PAUSE_END,
EventType.YOMITAN_LOOKUP,
] as const;
export interface PauseRegion {
startMs: number;
endMs: number;
}
export interface SessionChartEvents {
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
markers: SessionChartMarker[];
}
export interface SessionEventNoteInfo {
noteId: number;
expression: string;
context: string | null;
meaning: string | null;
}
export interface SessionChartPlotArea {
left: number;
width: number;
}
interface SessionEventNoteField {
value: string;
}
interface SessionEventNoteRecord {
noteId: unknown;
preview?: {
word?: unknown;
sentence?: unknown;
translation?: unknown;
} | null;
fields?: Record<string, SessionEventNoteField> | null;
}
export type SessionChartMarker =
| {
key: string;
kind: 'pause';
anchorTsMs: number;
eventTsMs: number;
startMs: number;
endMs: number;
durationMs: number;
}
| {
key: string;
kind: 'seek';
anchorTsMs: number;
eventTsMs: number;
direction: 'forward' | 'backward';
fromMs: number | null;
toMs: number | null;
}
| {
key: string;
kind: 'card';
anchorTsMs: number;
eventTsMs: number;
noteIds: number[];
cardsDelta: number;
};
function parsePayload(payload: string | null): Record<string, unknown> | null {
if (!payload) return null;
try {
const parsed = JSON.parse(payload);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function readNumberField(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function readNoteIds(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value.filter(
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry),
);
}
function stripHtml(value: string): string {
return value
.replace(/\[sound:[^\]]+\]/gi, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function pickFieldValue(
fields: Record<string, SessionEventNoteField>,
patterns: RegExp[],
excludeValues: Set<string> = new Set(),
): string | null {
const entries = Object.entries(fields);
for (const pattern of patterns) {
for (const [fieldName, field] of entries) {
if (!pattern.test(fieldName)) continue;
const cleaned = stripHtml(field?.value ?? '');
if (cleaned && !excludeValues.has(cleaned)) return cleaned;
}
}
return null;
}
function pickExpressionField(fields: Record<string, SessionEventNoteField>): string {
const entries = Object.entries(fields);
const preferredPatterns = [
/^(expression|word|vocab|vocabulary|target|target word|front)$/i,
/(expression|word|vocab|vocabulary|target)/i,
];
const preferredValue = pickFieldValue(fields, preferredPatterns);
if (preferredValue) return preferredValue;
for (const [, field] of entries) {
const cleaned = stripHtml(field?.value ?? '');
if (cleaned) return cleaned;
}
return '';
}
export function extractSessionEventNoteInfo(
note: SessionEventNoteRecord,
): SessionEventNoteInfo | null {
if (typeof note.noteId !== 'number' || !Number.isInteger(note.noteId) || note.noteId <= 0) {
return null;
}
const previewExpression =
typeof note.preview?.word === 'string' ? stripHtml(note.preview.word) : '';
const previewContext =
typeof note.preview?.sentence === 'string' ? stripHtml(note.preview.sentence) : '';
const previewMeaning =
typeof note.preview?.translation === 'string' ? stripHtml(note.preview.translation) : '';
if (previewExpression || previewContext || previewMeaning) {
return {
noteId: note.noteId,
expression: previewExpression,
context: previewContext || null,
meaning: previewMeaning || null,
};
}
const fields = note.fields ?? {};
const expression = pickExpressionField(fields);
const usedValues = new Set<string>(expression ? [expression] : []);
const context =
pickFieldValue(
fields,
[/^(sentence|context|example)$/i, /(sentence|context|example)/i],
usedValues,
) ?? null;
if (context) {
usedValues.add(context);
}
const meaning =
pickFieldValue(
fields,
[
/^(meaning|definition|gloss|translation|back)$/i,
/(meaning|definition|gloss|translation|back)/i,
],
usedValues,
) ?? null;
return {
noteId: note.noteId,
expression,
context,
meaning,
};
}
export function mergeSessionEventNoteInfos(
requestedNoteIds: number[],
notes: SessionEventNoteRecord[],
): Map<number, SessionEventNoteInfo> {
const next = new Map<number, SessionEventNoteInfo>();
notes.forEach((note, index) => {
const info = extractSessionEventNoteInfo(note);
if (!info) return;
next.set(info.noteId, info);
const requestedNoteId = requestedNoteIds[index];
if (requestedNoteId && requestedNoteId > 0) {
next.set(requestedNoteId, info);
}
});
return next;
}
export function collectPendingSessionEventNoteIds(
noteIds: number[],
noteInfos: ReadonlyMap<number, SessionEventNoteInfo>,
pendingNoteIds: ReadonlySet<number>,
): number[] {
const next: number[] = [];
const seen = new Set<number>();
for (const noteId of noteIds) {
if (!Number.isInteger(noteId) || noteId <= 0 || seen.has(noteId)) {
continue;
}
seen.add(noteId);
if (noteInfos.has(noteId) || pendingNoteIds.has(noteId)) {
continue;
}
next.push(noteId);
}
return next;
}
export function getSessionEventCardRequest(marker: SessionChartMarker | null): {
noteIds: number[];
requestKey: string | null;
} {
if (!marker || marker.kind !== 'card' || marker.noteIds.length === 0) {
return { noteIds: [], requestKey: null };
}
const noteIds = Array.from(
new Set(marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
);
return {
noteIds,
requestKey: noteIds.length > 0 ? `${marker.key}:${noteIds.join(',')}` : null,
};
}
export function resolveActiveSessionMarkerKey(
hoveredMarkerKey: string | null,
pinnedMarkerKey: string | null,
): string | null {
return pinnedMarkerKey ?? hoveredMarkerKey;
}
export function togglePinnedSessionMarkerKey(
currentPinnedMarkerKey: string | null,
nextMarkerKey: string,
): string | null {
return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey;
}
export function formatEventSeconds(ms: number | null): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
return `${(ms / 1000).toFixed(1)}s`;
}
export function projectSessionMarkerLeftPx({
anchorTsMs,
tsMin,
tsMax,
plotLeftPx,
plotWidthPx,
}: {
anchorTsMs: number;
tsMin: number;
tsMax: number;
plotLeftPx: number;
plotWidthPx: number;
}): number {
if (plotWidthPx <= 0) return plotLeftPx;
if (tsMax <= tsMin) return Math.round(plotLeftPx + plotWidthPx / 2);
const ratio = Math.max(0, Math.min(1, (anchorTsMs - tsMin) / (tsMax - tsMin)));
return Math.round(plotLeftPx + plotWidthPx * ratio);
}
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = [];
const seekEvents: SessionEvent[] = [];
const yomitanLookupEvents: SessionEvent[] = [];
const pauseRegions: PauseRegion[] = [];
const markers: SessionChartMarker[] = [];
let pendingPauseStartMs: number | null = null;
for (const event of events) {
switch (event.eventType) {
case EventType.CARD_MINED:
cardEvents.push(event);
{
const payload = parsePayload(event.payload);
markers.push({
key: `card-${event.tsMs}`,
kind: 'card',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
noteIds: readNoteIds(payload?.noteIds),
cardsDelta: readNumberField(payload?.cardsMined) ?? 1,
});
}
break;
case EventType.SEEK_FORWARD:
case EventType.SEEK_BACKWARD:
seekEvents.push(event);
{
const payload = parsePayload(event.payload);
markers.push({
key: `seek-${event.tsMs}-${event.eventType}`,
kind: 'seek',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
direction: event.eventType === EventType.SEEK_BACKWARD ? 'backward' : 'forward',
fromMs: readNumberField(payload?.fromMs),
toMs: readNumberField(payload?.toMs),
});
}
break;
case EventType.YOMITAN_LOOKUP:
yomitanLookupEvents.push(event);
break;
case EventType.PAUSE_START:
pendingPauseStartMs = event.tsMs;
break;
case EventType.PAUSE_END:
if (pendingPauseStartMs !== null) {
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: event.tsMs });
markers.push({
key: `pause-${pendingPauseStartMs}-${event.tsMs}`,
kind: 'pause',
anchorTsMs: pendingPauseStartMs + Math.round((event.tsMs - pendingPauseStartMs) / 2),
eventTsMs: pendingPauseStartMs,
startMs: pendingPauseStartMs,
endMs: event.tsMs,
durationMs: Math.max(0, event.tsMs - pendingPauseStartMs),
});
pendingPauseStartMs = null;
}
break;
default:
break;
}
}
if (pendingPauseStartMs !== null) {
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: pendingPauseStartMs + 2_000 });
markers.push({
key: `pause-${pendingPauseStartMs}-${pendingPauseStartMs + 2_000}`,
kind: 'pause',
anchorTsMs: pendingPauseStartMs + 1_000,
eventTsMs: pendingPauseStartMs,
startMs: pendingPauseStartMs,
endMs: pendingPauseStartMs + 2_000,
durationMs: 2_000,
});
}
markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs);
return {
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
};
}

View File

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

View File

@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
closeMediaDetail,
createInitialStatsView,
getSessionNavigationTarget,
navigateToAnime,
openAnimeEpisodeDetail,
openOverviewMediaDetail,
switchTab,
type StatsViewState,
} from './stats-navigation';
test('openAnimeEpisodeDetail opens dedicated media detail from anime context', () => {
const state = createInitialStatsView();
assert.deepEqual(openAnimeEpisodeDetail(state, 42, 7), {
activeTab: 'anime',
selectedAnimeId: 42,
focusedSessionId: null,
mediaDetail: {
videoId: 7,
initialSessionId: null,
origin: {
type: 'anime',
animeId: 42,
},
},
} satisfies StatsViewState);
});
test('closeMediaDetail returns to originating anime detail state', () => {
const state = openAnimeEpisodeDetail(navigateToAnime(createInitialStatsView(), 42), 42, 7);
assert.deepEqual(closeMediaDetail(state), {
activeTab: 'anime',
selectedAnimeId: 42,
focusedSessionId: null,
mediaDetail: null,
} satisfies StatsViewState);
});
test('openOverviewMediaDetail opens dedicated media detail from overview context', () => {
assert.deepEqual(openOverviewMediaDetail(createInitialStatsView(), 9), {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId: 9,
initialSessionId: null,
origin: {
type: 'overview',
},
},
} satisfies StatsViewState);
});
test('closeMediaDetail returns to overview when media detail originated there', () => {
const state = openOverviewMediaDetail(createInitialStatsView(), 9);
assert.deepEqual(closeMediaDetail(state), createInitialStatsView());
});
test('switchTab clears dedicated media detail state', () => {
const state = openAnimeEpisodeDetail(navigateToAnime(createInitialStatsView(), 42), 42, 7);
assert.deepEqual(switchTab(state, 'sessions'), {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
} satisfies StatsViewState);
});
test('getSessionNavigationTarget prefers media detail when video id exists', () => {
assert.deepEqual(getSessionNavigationTarget({ sessionId: 4, videoId: 12 }), {
type: 'media-detail',
videoId: 12,
sessionId: 4,
});
});
test('getSessionNavigationTarget falls back to session page when video id is missing', () => {
assert.deepEqual(getSessionNavigationTarget({ sessionId: 4, videoId: null }), {
type: 'session',
sessionId: 4,
});
});
test('openOverviewMediaDetail can carry a target session id for auto-expansion', () => {
assert.deepEqual(openOverviewMediaDetail(createInitialStatsView(), 9, 33), {
activeTab: 'overview',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId: 9,
initialSessionId: 33,
origin: {
type: 'overview',
},
},
} satisfies StatsViewState);
});

View File

@@ -0,0 +1,166 @@
import type { SessionSummary } from '../types/stats';
import type { TabId } from '../components/layout/TabBar';
export type MediaDetailOrigin =
| { type: 'anime'; animeId: number }
| { type: 'overview' }
| { type: 'sessions' };
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 openSessionsMediaDetail(state: StatsViewState, videoId: number): StatsViewState {
return {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: {
videoId,
initialSessionId: null,
origin: {
type: 'sessions',
},
},
};
}
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,
};
}
if (state.mediaDetail.origin.type === 'sessions') {
return {
activeTab: 'sessions',
selectedAnimeId: null,
focusedSessionId: null,
mediaDetail: null,
};
}
return {
activeTab: 'anime',
selectedAnimeId: state.mediaDetail.origin.animeId,
focusedSessionId: null,
mediaDetail: null,
};
}
export function getSessionNavigationTarget(session: Pick<SessionSummary, 'sessionId' | 'videoId'>):
| {
type: 'media-detail';
videoId: number;
sessionId: number;
}
| {
type: 'session';
sessionId: number;
} {
if (session.videoId != null) {
return {
type: 'media-detail',
videoId: session.videoId,
sessionId: session.sessionId,
};
}
return {
type: 'session',
sessionId: session.sessionId,
};
}

View File

@@ -0,0 +1,41 @@
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,
endedMediaMs: null,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalTokensSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
onOpenDetail={() => {}}
/>,
);
assert.match(markup, />Details</);
assert.match(markup, /Episode 9/);
});

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const VOCABULARY_TAB_PATH = fileURLToPath(
new URL('../components/vocabulary/VocabularyTab.tsx', import.meta.url),
);
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 ?? [], []);
});
test('VocabularyTab memoizes summary and known-word aggregate calculations', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(
source,
/const summary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/,
);
assert.match(
source,
/const knownWordCount = useMemo\(\(\) => \{[\s\S]*for \(const w of filteredWords\) \{[\s\S]*knownWords\.has\(w\.headword\)[\s\S]*\}\s*return count;\s*\}, \[filteredWords, knownWords\]\);/,
);
});

View File

@@ -0,0 +1,177 @@
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',
animeId: null,
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalTokensSeen: 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',
animeId: null,
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalTokensSeen: 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: 100,
endedMediaMs: 6,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 90,
totalCards: 1,
totalTokensSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
/>,
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.0 \/ 100 words/);
assert.match(markup, /6%/);
assert.doesNotMatch(markup, /90%/);
});
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,
totalTokensSeen: 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, /Yomitan lookups per 100 words seen/);
});
test('SessionRow prefers word-based 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,
tokensSeen: 42,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
}}
isExpanded={false}
detailsId="session-7"
onToggle={() => {}}
onDelete={() => {}}
/>,
);
assert.match(markup, />42</);
assert.doesNotMatch(markup, />12</);
});

View File

@@ -0,0 +1,25 @@
import type { SessionEvent } from '../types/stats';
import { EventType } from '../types/stats';
export interface LookupRateDisplay {
shortValue: string;
longValue: string;
}
export function buildLookupRateDisplay(
yomitanLookupCount: number,
tokensSeen: number,
): LookupRateDisplay | null {
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
return null;
}
const per100 = ((Math.max(0, yomitanLookupCount) / tokensSeen) * 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);
}

20
stats/src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@fontsource-variable/geist';
import '@fontsource-variable/geist-mono';
import { App } from './App';
import './styles/globals.css';
const isOverlay = new URLSearchParams(window.location.search).has('overlay');
if (isOverlay) {
document.body.classList.add('overlay-mode');
}
const root = document.getElementById('root');
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}

View File

@@ -0,0 +1,83 @@
@import 'tailwindcss';
@theme {
--color-ctp-base: #24273a;
--color-ctp-mantle: #1e2030;
--color-ctp-crust: #181926;
--color-ctp-surface0: #363a4f;
--color-ctp-surface1: #494d64;
--color-ctp-surface2: #5b6078;
--color-ctp-text: #cad3f5;
--color-ctp-subtext1: #b8c0e0;
--color-ctp-subtext0: #a5adcb;
--color-ctp-overlay2: #939ab7;
--color-ctp-overlay1: #8087a2;
--color-ctp-overlay0: #6e738d;
--color-ctp-blue: #8aadf4;
--color-ctp-green: #a6da95;
--color-ctp-cards-mined: #f5bde6;
--color-ctp-mauve: #c6a0f6;
--color-ctp-peach: #f5a97f;
--color-ctp-red: #ed8796;
--color-ctp-yellow: #eed49f;
--color-ctp-teal: #8bd5ca;
--color-ctp-lavender: #b7bdf8;
--color-ctp-flamingo: #f0c6c6;
--color-ctp-rosewater: #f4dbd6;
--color-ctp-sky: #91d7e3;
--color-ctp-sapphire: #7dc4e4;
--color-ctp-maroon: #ee99a0;
--color-ctp-pink: #f5bde6;
--font-sans:
'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
}
body {
margin: 0;
font-family: var(--font-sans);
background-color: var(--color-ctp-base);
color: var(--color-ctp-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body.overlay-mode {
background-color: rgba(36, 39, 58, 0.85);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-ctp-surface1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-ctp-surface2);
}
/* Tab content entrance animation */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeSlideIn 0.25s ease-out;
}

370
stats/src/types/stats.ts Normal file
View File

@@ -0,0 +1,370 @@
export interface SessionSummary {
sessionId: number;
canonicalTitle: string | null;
videoId: number | null;
animeId: number | null;
animeTitle: string | null;
startedAtMs: number;
endedAtMs: number | null;
totalWatchedMs: number;
activeWatchedMs: number;
linesSeen: number;
tokensSeen: number;
cardsMined: number;
lookupCount: number;
lookupHits: number;
yomitanLookupCount: number;
knownWordsSeen: number;
knownWordRate: number;
}
export interface DailyRollup {
rollupDayOrMonth: number;
videoId: number | null;
totalSessions: number;
totalActiveMin: number;
totalLinesSeen: number;
totalTokensSeen: number;
totalCards: number;
cardsPerHour: number | null;
tokensPerMin: number | null;
lookupHitRate: number | null;
}
export type MonthlyRollup = DailyRollup;
export interface SessionTimelinePoint {
sampleMs: number;
totalWatchedMs: number;
activeWatchedMs: number;
linesSeen: number;
tokensSeen: number;
cardsMined: number;
}
export interface SessionEvent {
eventType: EventType;
tsMs: number;
payload: string | null;
}
export interface AnkiNotePreview {
word: string;
sentence: string;
translation: string;
}
export interface StatsAnkiNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
preview?: AnkiNotePreview;
}
export interface VocabularyEntry {
wordId: number;
headword: string;
word: string;
reading: string;
partOfSpeech: string | null;
pos1: string | null;
pos2: string | null;
pos3: string | null;
frequency: number;
frequencyRank: number | null;
animeCount: number;
firstSeen: number;
lastSeen: number;
}
export interface KanjiEntry {
kanjiId: number;
kanji: string;
frequency: number;
firstSeen: number;
lastSeen: number;
}
export interface VocabularyOccurrenceEntry {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
occurrenceCount: number;
}
export interface OverviewData {
sessions: SessionSummary[];
rollups: DailyRollup[];
hints: {
totalSessions: number;
activeSessions: number;
episodesToday: number;
activeAnimeCount: number;
totalEpisodesWatched: number;
totalAnimeCompleted: number;
totalActiveMin: number;
activeDays: number;
totalCards?: number;
totalTokensSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
newWordsToday: number;
newWordsThisWeek: number;
};
}
export interface MediaLibraryItem {
videoId: number;
canonicalTitle: string;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
lastWatchedMs: number;
hasCoverArt: number;
}
export interface MediaDetailData {
detail: {
videoId: number;
canonicalTitle: string;
animeId: number | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
totalLinesSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
} | null;
sessions: SessionSummary[];
rollups: DailyRollup[];
}
export const EventType = {
SUBTITLE_LINE: 1,
MEDIA_BUFFER: 2,
LOOKUP: 3,
CARD_MINED: 4,
SEEK_FORWARD: 5,
SEEK_BACKWARD: 6,
PAUSE_START: 7,
PAUSE_END: 8,
YOMITAN_LOOKUP: 9,
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
export interface AnimeLibraryItem {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
episodeCount: number;
episodesTotal: number | null;
lastWatchedMs: number;
}
export interface AnilistEntry {
anilistId: number;
titleRomaji: string | null;
titleEnglish: string | null;
season: number | null;
}
export interface AnimeDetailData {
detail: {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
totalLinesSeen: number;
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: number;
episodeCount: number;
lastWatchedMs: number;
};
episodes: AnimeEpisode[];
anilistEntries: AnilistEntry[];
}
export interface AnimeEpisode {
videoId: number;
episode: number | null;
season: number | null;
durationMs: number;
endedMediaMs: number | null;
watched: number;
canonicalTitle: string;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
totalTokensSeen: number;
totalYomitanLookupCount: number;
lastWatchedMs: number;
}
export interface AnimeWord {
wordId: number;
headword: string;
word: string;
reading: string;
partOfSpeech: string | null;
frequency: number;
}
export interface StreakCalendarDay {
epochDay: number;
totalActiveMin: number;
}
export interface EpisodesPerDay {
epochDay: number;
episodeCount: number;
}
export interface NewAnimePerDay {
epochDay: number;
newAnimeCount: number;
}
export interface WatchTimePerAnime {
epochDay: number;
animeId: number;
animeTitle: string;
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;
headword: string;
word: string;
reading: string;
partOfSpeech: string | null;
pos1: string | null;
pos2: string | null;
pos3: string | null;
frequency: number;
firstSeen: number;
lastSeen: number;
};
animeAppearances: Array<{
animeId: number;
animeTitle: string;
occurrenceCount: number;
}>;
similarWords: Array<{
wordId: number;
headword: string;
word: string;
reading: string;
frequency: number;
}>;
}
export interface EpisodeCardEvent {
eventId: number;
sessionId: number;
tsMs: number;
cardsDelta: number;
noteIds: number[];
}
export interface EpisodeDetailData {
sessions: SessionSummary[];
words: AnimeWord[];
cardEvents: EpisodeCardEvent[];
}
export interface KanjiDetailData {
detail: {
kanjiId: number;
kanji: string;
frequency: number;
firstSeen: number;
lastSeen: number;
};
animeAppearances: Array<{
animeId: number;
animeTitle: string;
occurrenceCount: number;
}>;
words: Array<{
wordId: number;
headword: string;
word: string;
reading: string;
frequency: number;
}>;
}