feat(stats): build anime-centric stats dashboard frontend

5-tab React dashboard with Catppuccin Mocha theme:
- Overview: hero stats, streak calendar, watch time chart, recent sessions
- Anime: grid with cover art, episode list with completion %, detail view
- Trends: 15 charts across Activity, Efficiency, Anime, and Patterns
- Vocabulary: POS-filtered word/kanji lists with detail panels
- Sessions: expandable session history with event timeline

Features:
- Cross-tab navigation (anime <-> vocabulary)
- Global word detail panel overlay
- Expandable episode detail with Anki card links (Expression field)
- Per-anime multi-line trend charts
- Watch time by day-of-week and hour-of-day
- Collapsible sections with accessibility (aria-expanded)
- Card size selector for anime grid
- Cover art caching via AniList
- HTTP API client with file:// protocol fallback for Electron overlay
This commit is contained in:
2026-03-14 22:15:02 -07:00
parent 950263bd66
commit 0f44107beb
68 changed files with 5372 additions and 0 deletions

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,72 @@
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-green">
{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,33 @@
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,131 @@
import { useState, useEffect } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi';
import { formatDuration, formatNumber, epochDayToDate } from '../../lib/formatters';
import { StatCard } from '../layout/StatCard';
import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
import { AnimeWordList } from './AnimeWordList';
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;
}
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>
);
}
export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) {
const { data, loading, error } = useAnimeDetail(animeId);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data;
const avgSessionMs = detail.totalSessions > 0
? Math.round(detail.totalActiveMs / detail.totalSessions)
: 0;
return (
<div className="space-y-4">
<button
type="button"
onClick={onBack}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
>
&larr; Back to Anime
</button>
<AnimeHeader detail={detail} anilistEntries={anilistEntries ?? []} />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
<StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" />
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
<StatCard label="Words" value={formatNumber(detail.totalWordsSeen)} color="text-ctp-mauve" />
<StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" />
<StatCard label="Avg Session" value={formatDuration(avgSessionMs)} />
</div>
<EpisodeList episodes={episodes} />
<AnimeWatchChart animeId={animeId} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
interface AnimeHeaderProps {
detail: AnimeDetailData['detail'];
anilistEntries: AnilistEntry[];
}
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 }: 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>
{anilistEntries.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-2">
{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>
)}
</div>
) : detail.anilistId ? (
<a
href={`https://anilist.co/anime/${detail.anilistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-2 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}
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
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;
}
export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord }: 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}
/>
);
}
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 gap-1 shrink-0">
{(['sm', 'md', 'lg'] as const).map((size) => (
<button
key={size}
onClick={() => setCardSize(size)}
className={`px-2 py-1 rounded text-xs ${
cardSize === size
? 'bg-ctp-surface2 text-ctp-text'
: '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,57 @@
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,28 @@
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,125 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from '../../hooks/useStatsApi';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import type { EpisodeDetailData } from '../../types/stats';
interface EpisodeDetailProps {
videoId: number;
}
interface NoteInfo {
noteId: number;
expression: string;
}
export function EpisodeDetail({ videoId }: 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.fields?.Expression?.value ??
note.fields?.expression?.value ??
note.fields?.Word?.value ??
note.fields?.word?.value ??
'';
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]);
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">
<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-green">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-peach">{formatNumber(s.wordsSeen)} words</span>
</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-green">
+{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,134 @@
import { Fragment, useState } from 'react';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client';
import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats';
interface EpisodeListProps {
episodes: AnimeEpisode[];
}
export function EpisodeList({ episodes: initialEpisodes }: 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 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">Last Watched</th>
<th className="w-8 py-2 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((ep, idx) => (
<Fragment key={ep.videoId}>
<tr
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
>
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
</td>
<td className="py-2 pr-3 text-ctp-subtext0">
{ep.episode ?? idx + 1}
</td>
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
{ep.canonicalTitle}
</td>
<td className="py-2 pr-3 text-right">
{ep.durationMs > 0 ? (
<span className={
ep.totalActiveMs >= ep.durationMs * 0.85
? 'text-ctp-green'
: ep.totalActiveMs >= ep.durationMs * 0.5
? 'text-ctp-peach'
: 'text-ctp-overlay2'
}>
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
</span>
) : (
<span className="text-ctp-overlay2">{'\u2014'}</span>
)}
</td>
<td className="py-2 pr-3 text-right text-ctp-blue">
{formatDuration(ep.totalActiveMs)}
</td>
<td className="py-2 pr-3 text-right text-ctp-green">
{formatNumber(ep.totalCards)}
</td>
<td className="py-2 pr-3 text-right text-ctp-overlay2">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
</td>
<td className="py-2 text-center w-8">
<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>
</td>
</tr>
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={8} className="py-2">
<EpisodeDetail videoId={ep.videoId} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
interface StatCardProps {
label: string;
value: string;
subValue?: string;
color?: string;
trend?: { direction: 'up' | 'down' | 'flat'; text: string };
}
export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 text-center">
<div className={`text-2xl font-bold ${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 ${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,46 @@
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
interface Tab {
id: TabId;
label: string;
}
const TABS: Tab[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'anime', label: 'Anime' },
{ 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) {
return (
<nav className="flex border-b border-ctp-surface1" role="tablist" aria-label="Stats tabs">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-controls={`panel-${tab.id}`}
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => onTabChange(tab.id)}
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,30 @@
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,57 @@
import { useState, useMemo } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration } from '../../lib/formatters';
import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView';
export function LibraryTab() {
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)} />;
}
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,32 @@
import { useMediaDetail } from '../../hooks/useMediaDetail';
import { MediaHeader } from './MediaHeader';
import { MediaWatchChart } from './MediaWatchChart';
import { MediaSessionList } from './MediaSessionList';
interface MediaDetailViewProps {
videoId: number;
onBack: () => void;
}
export function MediaDetailView({ videoId, onBack }: MediaDetailViewProps) {
const { data, loading, error } = useMediaDetail(videoId);
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>;
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>
<MediaHeader detail={data.detail} />
<MediaWatchChart rollups={data.rollups} />
<MediaSessionList sessions={data.sessions} />
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
import type { MediaDetailData } from '../../types/stats';
interface MediaHeaderProps {
detail: NonNullable<MediaDetailData['detail']>;
}
export function MediaHeader({ detail }: MediaHeaderProps) {
const hitRate = detail.totalLookupCount > 0
? detail.totalLookupHits / detail.totalLookupCount
: null;
const avgSessionMs = detail.totalSessions > 0
? Math.round(detail.totalActiveMs / detail.totalSessions)
: 0;
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-green 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.totalWordsSeen)}</div>
<div className="text-xs text-ctp-overlay2">words seen</div>
</div>
<div>
<div className="text-ctp-peach font-medium">{formatPercent(hitRate)}</div>
<div className="text-xs text-ctp-overlay2">lookup 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,40 @@
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
interface MediaSessionListProps {
sessions: SessionSummary[];
}
export function MediaSessionList({ sessions }: MediaSessionListProps) {
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}
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center justify-between"
>
<div className="min-w-0">
<div className="text-sm text-ctp-text">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(s.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,79 @@
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,51 @@
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-green"
/>
<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,80 @@
import { useOverview } from '../../hooks/useOverview';
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
import { HeroStats } from './HeroStats';
import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { formatNumber } from '../../lib/formatters';
export function OverviewTab() {
const { data, sessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90);
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.totalSessions > 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>
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text mb-3">Tracking Snapshot</h3>
{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 tracked card-add events in the current immersion DB yet. New cards mined after this fix will show here.
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 text-sm">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Total Sessions</div>
<div className="mt-1 text-xl font-semibold text-ctp-lavender">
{formatNumber(summary.totalSessions)}
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Today</div>
<div className="mt-1 text-xl font-semibold text-ctp-teal">
{formatNumber(summary.episodesToday)}
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">All-Time Hours</div>
<div className="mt-1 text-xl font-semibold text-ctp-mauve">
{formatNumber(summary.allTimeHours)}
</div>
</div>
<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 text-ctp-peach">
{formatNumber(summary.activeDays)}
</div>
</div>
<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 text-ctp-green">
{formatNumber(summary.totalTrackedCards)}
</div>
</div>
</div>
</div>
<RecentSessions sessions={sessions} />
</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-green font-medium">{weekCards}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
import { useState } from 'react';
import { formatDuration, formatRelativeDate, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import type { SessionSummary } from '../../types/stats';
interface RecentSessionsProps {
sessions: SessionSummary[];
}
interface AnimeGroup {
key: string;
animeId: number | null;
animeTitle: string | null;
videoId: number | null;
sessions: SessionSummary[];
totalCards: number;
totalWords: number;
totalActiveMs: number;
}
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
const today = todayLocalDay();
for (const session of sessions) {
const sessionDay = localDayFromMs(session.startedAtMs);
let label: string;
if (sessionDay === today) {
label = 'Today';
} else if (sessionDay === today - 1) {
label = 'Yesterday';
} else {
label = new Date(session.startedAtMs).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
});
}
const group = groups.get(label);
if (group) {
group.push(session);
} else {
groups.set(label, [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);
if (existing) {
existing.sessions.push(session);
existing.totalCards += session.cardsMined;
existing.totalWords += session.wordsSeen;
existing.totalActiveMs += session.activeWatchedMs;
} else {
map.set(key, {
key,
animeId: session.animeId,
animeTitle: session.animeTitle,
videoId: session.videoId,
sessions: [session],
totalCards: session.cardsMined,
totalWords: session.wordsSeen,
totalActiveMs: session.activeWatchedMs,
});
}
}
return Array.from(map.values());
}
function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) {
const fallbackChar = title.charAt(0) || '?';
if (!videoId) {
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>
);
}
return (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
alt=""
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
onError={(e) => {
const target = e.currentTarget;
target.style.display = 'none';
const placeholder = document.createElement('div');
placeholder.className = 'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0';
placeholder.textContent = fallbackChar;
target.parentElement?.insertBefore(placeholder, target);
}}
/>
);
}
function SessionItem({ session }: { session: SessionSummary }) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3">
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(session.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</div>
);
}
function AnimeGroupRow({ group }: { group: AnimeGroup }) {
const [expanded, setExpanded] = useState(false);
if (group.sessions.length === 1) {
return <SessionItem session={group.sessions[0]!} />;
}
const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media';
const mostRecentSession = group.sessions[0]!;
return (
<div>
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
<CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{displayTitle}
</div>
<div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(group.totalCards)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(group.totalWords)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
>
{'\u25B8'}
</div>
</button>
{expanded && (
<div className="ml-6 mt-1 space-y-1">
{group.sessions.map((s) => (
<div
key={s.sessionId}
className="bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 flex items-center gap-3"
>
<CoverThumbnail videoId={s.videoId} title={s.canonicalTitle ?? 'Unknown'} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
{s.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(s.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
export function RecentSessions({ sessions }: 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);
return (
<div className="space-y-4">
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const animeGroups = groupSessionsByAnime(daySessions);
return (
<div key={dayLabel}>
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2">
{dayLabel}
</h3>
<div className="space-y-2">
{animeGroups.map((group) => (
<AnimeGroupRow key={group.key} group={group} />
))}
</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,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 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,121 @@
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme';
import { EventType } from '../../types/stats';
interface SessionDetailProps {
sessionId: number;
cardsMined: number;
}
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',
});
}
const EVENT_COLORS: Partial<Record<number, { color: string; label: string }>> = {
[EventType.CARD_MINED]: { color: '#a6da95', label: 'Card mined' },
[EventType.PAUSE_START]: { color: '#f5a97f', label: 'Pause' },
};
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
const { timeline, events, loading, error } = useSessionDetail(sessionId);
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
const chartData = [...timeline]
.reverse()
.map((t) => ({
tsMs: t.sampleMs,
time: formatTime(t.sampleMs),
words: t.wordsSeen,
cards: t.cardsMined,
}));
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = events.filter(
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
).length;
const cardEventCount = events.filter((e) => e.eventType === EventType.CARD_MINED).length;
const markerEvents = events.filter((e) => EVENT_COLORS[e.eventType]);
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
{chartData.length > 0 && (
<ResponsiveContainer width="100%" height={120}>
<LineChart data={chartData}>
<XAxis
dataKey="time"
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip contentStyle={tooltipStyle} />
<Line dataKey="words" stroke="#c6a0f6" strokeWidth={1.5} dot={false} name="Words" />
<Line dataKey="cards" stroke="#a6da95" strokeWidth={1.5} dot={false} name="Cards" />
{markerEvents.map((e, i) => {
const cfg = EVENT_COLORS[e.eventType]!;
const matchIdx = chartData.findIndex((d) => d.tsMs >= e.tsMs);
const x = matchIdx >= 0 ? chartData[matchIdx]!.time : null;
if (!x) return null;
return (
<ReferenceLine
key={`${e.eventType}-${i}`}
x={x}
stroke={cfg.color}
strokeDasharray="3 3"
strokeOpacity={0.6}
label=""
/>
);
})}
</LineChart>
</ResponsiveContainer>
)}
<div className="flex flex-wrap gap-4 text-xs text-ctp-subtext0">
<span>{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span>
<span>{seekCount} seek{seekCount !== 1 ? 's' : ''}</span>
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span>
</div>
{markerEvents.length > 0 && (
<div className="flex flex-wrap gap-3 text-[10px]">
{Object.entries(EVENT_COLORS).map(([type, cfg]) => {
if (!cfg) return null;
const count = markerEvents.filter((e) => e.eventType === Number(type)).length;
if (count === 0) return null;
return (
<span key={type} className="flex items-center gap-1">
<span className="inline-block w-2.5 h-0.5 rounded" style={{ background: cfg.color }} />
<span className="text-ctp-overlay2">{cfg.label} ({count})</span>
</span>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
import {
formatDuration,
formatRelativeDate,
formatNumber,
} from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
interface SessionRowProps {
session: SessionSummary;
isExpanded: boolean;
detailsId: string;
onToggle: () => void;
}
function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
if (!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>
);
}
return (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
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 }: SessionRowProps) {
return (
<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 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(session.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
>
{'\u25B8'}
</div>
</button>
);
}

View File

@@ -0,0 +1,99 @@
import { useState, useMemo } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { todayLocalDay, localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const groups = new Map<string, SessionSummary[]>();
const today = todayLocalDay();
for (const session of sessions) {
const sessionDay = localDayFromMs(session.startedAtMs);
let label: string;
if (sessionDay === today) {
label = 'Today';
} else if (sessionDay === today - 1) {
label = 'Yesterday';
} else {
label = new Date(session.startedAtMs).toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
});
}
const group = groups.get(label);
if (group) {
group.push(session);
} else {
groups.set(label, [session]);
}
}
return groups;
}
export function SessionsTab() {
const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter(
(s) => s.canonicalTitle?.toLowerCase().includes(q),
);
}, [sessions, search]);
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
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="text"
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"
/>
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
<div key={dayLabel}>
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2">
{dayLabel}
</h3>
<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)}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail sessionId={s.sessionId} cardsMined={s.cardsMined} />
</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,58 @@
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
interface DateRangeSelectorProps {
range: TimeRange;
groupBy: GroupBy;
onRangeChange: (r: TimeRange) => void;
onGroupByChange: (g: GroupBy) => void;
}
export function DateRangeSelector({
range,
groupBy,
onRangeChange,
onGroupByChange,
}: DateRangeSelectorProps) {
const ranges: TimeRange[] = ['7d', '30d', '90d', 'all'];
const groups: GroupBy[] = ['day', 'month'];
return (
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Range</span>
{ranges.map((r) => (
<button
key={r}
onClick={() => onRangeChange(r)}
aria-pressed={range === r}
className={`px-2.5 py-1 rounded text-xs ${
range === r
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{r === 'all' ? 'All' : r}
</button>
))}
</div>
<span className="text-ctp-surface2">{'\u00B7'}</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Group by</span>
{groups.map((g) => (
<button
key={g}
onClick={() => onGroupByChange(g)}
aria-pressed={groupBy === g}
className={`px-2.5 py-1 rounded text-xs capitalize ${
groupBy === g
? 'bg-ctp-surface2 text-ctp-text'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{g}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import {
LineChart, Line, 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[];
}
const 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]) => ({
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
...values,
}));
return { points, seriesKeys: topTitles };
}
export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data);
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}>
<LineChart 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) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={LINE_COLORS[i % LINE_COLORS.length]}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{seriesKeys.map((key, i) => (
<span key={key} className="flex items-center gap-1 text-[10px] text-ctp-subtext0">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }}
/>
{key}
</span>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
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;
}
export function TrendChart({ title, data, color, type, formatter }: 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]} />
</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,160 @@
import { useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart';
import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data';
import { localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function buildWatchTimeByDayOfWeek(sessions: SessionSummary[]): ChartPoint[] {
const totals = new Array(7).fill(0);
for (const s of sessions) {
const dow = new Date(s.startedAtMs).getDay();
totals[dow] += s.activeWatchedMs;
}
return DAY_NAMES.map((name, i) => ({ label: name, value: Math.round(totals[i] / 60_000) }));
}
function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
const totals = new Array(24).fill(0);
for (const s of sessions) {
const hour = new Date(s.startedAtMs).getHours();
totals[hour] += s.activeWatchedMs;
}
return totals.map((ms, i) => ({
label: `${String(i).padStart(2, '0')}:00`,
value: Math.round(ms / 60_000),
}));
}
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
const byAnime = new Map<string, Map<number, number>>();
for (const p of points) {
const dayMap = byAnime.get(p.animeTitle) ?? new Map();
dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value);
byAnime.set(p.animeTitle, dayMap);
}
const result: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of byAnime) {
const sorted = [...dayMap.entries()].sort(([a], [b]) => a - b);
let cumulative = 0;
for (const [epochDay, value] of sorted) {
cumulative += value;
result.push({ epochDay, animeTitle, value: cumulative });
}
}
return result;
}
function buildPerAnimeFromSessions(
sessions: SessionSummary[],
getValue: (s: SessionSummary) => number,
): PerAnimeDataPoint[] {
const map = new Map<string, Map<number, number>>();
for (const s of sessions) {
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
const day = localDayFromMs(s.startedAtMs);
const animeMap = map.get(title) ?? new Map();
animeMap.set(day, (animeMap.get(day) ?? 0) + getValue(s));
map.set(title, animeMap);
}
const points: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of map) {
for (const [epochDay, value] of dayMap) {
points.push({ epochDay, animeTitle, value });
}
}
return points;
}
function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnimeDataPoint[] {
// Group by anime+day, counting distinct videoIds
const map = new Map<string, Map<number, Set<number | null>>>();
for (const s of sessions) {
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
const day = localDayFromMs(s.startedAtMs);
const animeMap = map.get(title) ?? new Map();
const videoSet = animeMap.get(day) ?? new Set();
videoSet.add(s.videoId);
animeMap.set(day, videoSet);
map.set(title, animeMap);
}
const points: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of map) {
for (const [epochDay, videoSet] of dayMap) {
points.push({ epochDay, animeTitle, value: videoSet.size });
}
}
return points;
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-ctp-subtext0 text-sm font-medium uppercase tracking-wider mt-6 mb-2 col-span-full">
{children}
</h3>
);
}
export function TrendsTab() {
const [range, setRange] = useState<TimeRange>('30d');
const [groupBy, setGroupBy] = useState<GroupBy>('day');
const { data, loading, error } = useTrends(range, groupBy);
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>;
const dashboard = buildTrendDashboard(data.rollups);
const watchByDow = buildWatchTimeByDayOfWeek(data.sessions);
const watchByHour = buildWatchTimeByHour(data.sessions);
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
epochDay: e.epochDay, animeTitle: e.animeTitle, value: e.totalActiveMin,
}));
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
const animeProgress = buildCumulativePerAnime(episodesPerAnime);
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 xl:grid-cols-3 gap-4">
<SectionHeader>Activity</SectionHeader>
<TrendChart title="Watch Time (min)" data={dashboard.watchTime} color="#8aadf4" type="bar" />
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
<TrendChart
title="Avg Session (min)"
data={dashboard.averageSessionMinutes}
color="#f5bde6"
type="line"
/>
<SectionHeader>Efficiency</SectionHeader>
<TrendChart title="Cards per Hour" data={dashboard.cardsPerHour} color="#f5a97f" type="line" />
<SectionHeader>Anime</SectionHeader>
<StackedTrendChart title="Anime Progress (episodes)" data={animeProgress} />
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} />
<SectionHeader>Patterns</SectionHeader>
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" />
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" />
</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,232 @@
import { useRef, useState } from 'react';
import { useKanjiDetail } from '../../hooks/useKanjiDetail';
import { apiClient } from '../../lib/api-client';
import { 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);
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(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(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,149 @@
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,109 @@
import { useMemo, useState } 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 { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { buildVocabularySummary } from '../../lib/dashboard-data';
import { isFilterable } from './pos-helpers';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
interface VocabularyTabProps {
onNavigateToAnime?: (animeId: number) => void;
onOpenWordDetail?: (wordId: number) => void;
}
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
const { words, kanji, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [hideParticles, setHideParticles] = useState(true);
const [search, setSearch] = useState('');
const filteredWords = useMemo(
() => hideParticles ? words.filter(w => !isFilterable(w)) : words,
[words, hideParticles],
);
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>;
const summary = buildVocabularySummary(filteredWords, kanji);
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
};
const openKanjiDetail = (entry: KanjiEntry): void => {
setSelectedKanjiId(entry.kanjiId);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
<StatCard label="Unique Words" value={formatNumber(summary.uniqueWords)} color="text-ctp-blue" />
<StatCard label="Unique Kanji" value={formatNumber(summary.uniqueKanji)} color="text-ctp-green" />
<StatCard
label="New This Week"
value={`+${formatNumber(summary.newThisWeek)}`}
color="text-ctp-mauve"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs text-ctp-subtext0 select-none cursor-pointer">
<input
type="checkbox"
checked={hideParticles}
onChange={(e) => setHideParticles(e.target.checked)}
className="rounded border-ctp-surface2 bg-ctp-surface1 text-ctp-blue focus:ring-ctp-blue"
/>
Hide particles & single kana
</label>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search words..."
className="rounded border border-ctp-surface2 bg-ctp-surface1 px-3 py-1 text-xs text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-blue focus:outline-none focus:ring-1 focus:ring-ctp-blue"
/>
</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"
/>
<TrendChart
title="New Words by Day"
data={summary.newWordsTimeline}
color="#c6a0f6"
type="line"
/>
</div>
<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}
/>
</div>
);
}

View File

@@ -0,0 +1,246 @@
import { useRef, useState } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client';
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
import type { VocabularyOccurrenceEntry } from '../../types/stats';
import { PosBadge } from './pos-helpers';
const OCCURRENCES_PAGE_SIZE = 50;
interface WordDetailPanelProps {
wordId: 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 WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime }: 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 requestIdRef = useRef(0);
if (wordId === null) return null;
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: 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,
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, 0, false);
};
const handleLoadMore = () => {
if (!data || occLoadingMore || !hasMore) return;
void loadOccurrences(data.detail, occurrences.length, true);
};
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">{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>
<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-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(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(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 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-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
{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-blue hover:text-ctp-blue 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,126 @@
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,37 @@
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;
}