feat: improve stats dashboard and annotation settings

This commit is contained in:
2026-03-15 21:18:35 -07:00
parent 650e95cdc3
commit 04682a02cc
75 changed files with 3420 additions and 619 deletions

View File

@@ -5,6 +5,8 @@
"": {
"name": "@subminer/stats-ui",
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
@@ -113,6 +115,10 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],

View File

@@ -8,6 +8,8 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0"

View File

@@ -42,12 +42,12 @@ export function App() {
</header>
<main className="flex-1 overflow-y-auto p-4">
{activeTab === 'overview' ? (
<section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview">
<section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" key="overview" className="animate-fade-in">
<OverviewTab />
</section>
) : null}
{activeTab === 'anime' ? (
<section id="panel-anime" role="tabpanel" aria-labelledby="tab-anime">
<section id="panel-anime" role="tabpanel" aria-labelledby="tab-anime" key="anime" className="animate-fade-in">
<AnimeTab
initialAnimeId={selectedAnimeId}
onClearInitialAnime={() => setSelectedAnimeId(null)}
@@ -56,12 +56,12 @@ export function App() {
</section>
) : null}
{activeTab === 'trends' ? (
<section id="panel-trends" role="tabpanel" aria-labelledby="tab-trends">
<section id="panel-trends" role="tabpanel" aria-labelledby="tab-trends" key="trends" className="animate-fade-in">
<TrendsTab />
</section>
) : null}
{activeTab === 'vocabulary' ? (
<section id="panel-vocabulary" role="tabpanel" aria-labelledby="tab-vocabulary">
<section id="panel-vocabulary" role="tabpanel" aria-labelledby="tab-vocabulary" key="vocabulary" className="animate-fade-in">
<VocabularyTab
onNavigateToAnime={navigateToAnime}
onOpenWordDetail={openWordDetail}
@@ -69,7 +69,7 @@ export function App() {
</section>
) : null}
{activeTab === 'sessions' ? (
<section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions">
<section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" key="sessions" className="animate-fade-in">
<SessionsTab />
</section>
) : null}

View File

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

View File

@@ -6,6 +6,7 @@ import { StatCard } from '../layout/StatCard';
import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
import { AnimeWordList } from './AnimeWordList';
import { AnilistSelector } from './AnilistSelector';
import { CHART_THEME } from '../../lib/chart-theme';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import type { DailyRollup } from '../../types/stats';
@@ -95,7 +96,8 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
}
export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) {
const { data, loading, error } = useAnimeDetail(animeId);
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
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>;
@@ -115,7 +117,11 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
>
&larr; Back to Anime
</button>
<AnimeHeader detail={detail} anilistEntries={anilistEntries ?? []} />
<AnimeHeader
detail={detail}
anilistEntries={anilistEntries ?? []}
onChangeAnilist={() => setShowAnilistSelector(true)}
/>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
<StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" />
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
@@ -126,6 +132,17 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
<EpisodeList episodes={episodes} />
<AnimeWatchChart animeId={animeId} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
{showAnilistSelector && (
<AnilistSelector
animeId={animeId}
initialQuery={detail.canonicalTitle}
onClose={() => setShowAnilistSelector(false)}
onLinked={() => {
setShowAnilistSelector(false);
reload();
}}
/>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
interface AnimeHeaderProps {
detail: AnimeDetailData['detail'];
anilistEntries: AnilistEntry[];
onChangeAnilist?: () => void;
}
function AnilistButton({ entry }: { entry: AnilistEntry }) {
@@ -24,7 +25,7 @@ function AnilistButton({ entry }: { entry: AnilistEntry }) {
);
}
export function AnimeHeader({ detail, anilistEntries }: AnimeHeaderProps) {
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative]
.filter((t): t is string => t != null && t !== detail.canonicalTitle);
const uniqueAltTitles = [...new Set(altTitles)];
@@ -48,9 +49,9 @@ export function AnimeHeader({ detail, anilistEntries }: AnimeHeaderProps) {
<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 ? (
<div className="flex flex-wrap gap-1.5 mt-2">
{anilistEntries.length > 0 ? (
hasMultipleEntries ? (
anilistEntries.map((entry) => (
<AnilistButton key={entry.anilistId} entry={entry} />
))
@@ -63,18 +64,33 @@ export function AnimeHeader({ detail, anilistEntries }: AnimeHeaderProps) {
>
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}
)
) : detail.anilistId ? (
<a
href={`https://anilist.co/anime/${detail.anilistId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
>
View on AniList <span className="text-[10px]">{'\u2197'}</span>
</a>
) : null}
{onChangeAnilist && (
<button
type="button"
onClick={onChangeAnilist}
title="Search AniList and manually select the correct anime entry"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
>
{anilistEntries.length > 0 || detail.anilistId ? 'Change AniList Entry' : 'Link to AniList'}
</button>
)}
</div>
{detail.description && (
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
{detail.description}
</p>
)}
</div>
</div>
);

View File

@@ -92,14 +92,14 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
<option key={opt.key} value={opt.key}>{opt.label}</option>
))}
</select>
<div className="flex gap-1 shrink-0">
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
{(['sm', 'md', 'lg'] as const).map((size) => (
<button
key={size}
onClick={() => setCardSize(size)}
className={`px-2 py-1 rounded text-xs ${
className={`px-2 py-1 rounded-md text-xs transition-colors ${
cardSize === size
? 'bg-ctp-surface2 text-ctp-text'
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>

View File

@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from '../../hooks/useStatsApi';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import type { EpisodeDetailData } from '../../types/stats';
interface EpisodeDetailProps {
videoId: number;
onSessionDeleted?: () => void;
}
interface NoteInfo {
@@ -12,7 +15,7 @@ interface NoteInfo {
expression: string;
}
export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
const [data, setData] = useState<EpisodeDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
@@ -46,13 +49,30 @@ export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
}
})
.catch(() => { if (!cancelled) setData(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
.catch(() => {
if (!cancelled) setData(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [videoId]);
const handleDeleteSession = async (sessionId: number) => {
if (!confirmSessionDelete()) return;
await apiClient.deleteSession(sessionId);
setData((prev) => {
if (!prev) return prev;
return { ...prev, sessions: prev.sessions.filter((s) => s.sessionId !== sessionId) };
});
onSessionDeleted?.();
};
if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>;
if (!data) return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
if (!data)
return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
const { sessions, cardEvents } = data;
@@ -63,14 +83,25 @@ export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
<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 key={s.sessionId} className="flex items-center gap-3 text-xs group">
<span className="text-ctp-overlay2">
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
</span>
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
<span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-peach">{formatNumber(s.wordsSeen)} words</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteSession(s.sessionId);
}}
className="ml-auto opacity-0 group-hover:opacity-100 text-ctp-red/70 hover:text-ctp-red transition-opacity text-[10px] px-1.5 py-0.5 rounded hover:bg-ctp-red/10"
title="Delete session"
>
Delete
</button>
</div>
))}
</div>
</div>
@@ -82,16 +113,16 @@ export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
<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>
<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>
<span className="text-ctp-text font-medium truncate">
{info.expression}
</span>
)}
<button
type="button"

View File

@@ -1,14 +1,16 @@
import { Fragment, useState } from 'react';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client';
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats';
interface EpisodeListProps {
episodes: AnimeEpisode[];
onEpisodeDeleted?: () => void;
}
export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: EpisodeListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
const [episodes, setEpisodes] = useState(initialEpisodes);
@@ -35,6 +37,14 @@ export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
}
};
const handleDeleteEpisode = async (videoId: number, title: string) => {
if (!confirmEpisodeDelete(title)) return;
await apiClient.deleteVideo(videoId);
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
if (expandedVideoId === videoId) setExpandedVideoId(null);
onEpisodeDeleted?.();
};
const watchedCount = episodes.filter((ep) => ep.watched).length;
return (
@@ -56,34 +66,36 @@ export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
<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" />
<th className="w-16 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"
onClick={() =>
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
>
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
</td>
<td className="py-2 pr-3 text-ctp-subtext0">
{ep.episode ?? idx + 1}
</td>
<td className="py-2 pr-3 text-ctp-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'
}>
<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>
) : (
@@ -99,28 +111,41 @@ export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
<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 className="py-2 text-center w-16">
<div className="flex items-center justify-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleWatched(ep.videoId, ep.watched);
}}
className={`w-5 h-5 rounded border transition-colors ${
ep.watched
? 'bg-ctp-green border-ctp-green text-ctp-base'
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
}`}
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
>
{'\u2713'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
title="Delete episode"
>
{'\u2715'}
</button>
</div>
</td>
</tr>
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={8} className="py-2">
<EpisodeDetail videoId={ep.videoId} />
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
</td>
</tr>
)}

View File

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

View File

@@ -43,43 +43,43 @@ export function OverviewTab() {
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Total Sessions</div>
<div className="mt-1 text-xl font-semibold text-ctp-lavender">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums 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">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-teal">
{formatNumber(summary.episodesToday)}
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">All-Time Hours</div>
<div className="mt-1 text-xl font-semibold text-ctp-mauve">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums 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">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums 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">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(summary.totalTrackedCards)}
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Completed</div>
<div className="mt-1 text-xl font-semibold text-ctp-blue">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime Completed</div>
<div className="mt-1 text-xl font-semibold text-ctp-sapphire">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)}
</div>
</div>

View File

@@ -121,11 +121,11 @@ function SessionItem({ session }: { session: SessionSummary }) {
</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-green font-medium font-mono tabular-nums">{formatNumber(session.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(session.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
@@ -161,11 +161,11 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</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-green font-medium font-mono tabular-nums">{formatNumber(group.totalCards)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(group.totalWords)}</div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(group.totalWords)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
@@ -193,11 +193,11 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</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-green font-medium font-mono tabular-nums">{formatNumber(s.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
@@ -226,9 +226,12 @@ export function RecentSessions({ sessions }: RecentSessionsProps) {
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="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{animeGroups.map((group) => (
<AnimeGroupRow key={group.key} group={group} />

View File

@@ -36,14 +36,14 @@ export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
<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">
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
{ranges.map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-2 py-0.5 text-xs rounded ${
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
range === r
? 'bg-ctp-surface2 text-ctp-text'
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>

View File

@@ -1,10 +1,6 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
import {
formatDuration,
formatRelativeDate,
formatNumber,
} from '../../lib/formatters';
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
interface SessionRowProps {
@@ -12,6 +8,8 @@ interface SessionRowProps {
isExpanded: boolean;
detailsId: string;
onToggle: () => void;
onDelete: () => void;
deleteDisabled?: boolean;
}
function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) {
@@ -37,40 +35,63 @@ function CoverThumbnail({ videoId, title }: { videoId: number | null; title: str
);
}
export function SessionRow({ session, isExpanded, detailsId, onToggle }: SessionRowProps) {
export function SessionRow({
session,
isExpanded,
detailsId,
onToggle,
onDelete,
deleteDisabled = false,
}: 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' : ''}`}
<div className="relative group">
<button
type="button"
onClick={onToggle}
aria-expanded={isExpanded}
aria-controls={detailsId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
{'\u25B8'}
</div>
</button>
<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 font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(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>
<button
type="button"
onClick={onDelete}
disabled={deleteDisabled}
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { useState, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { todayLocalDay, localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
@@ -37,17 +39,38 @@ export function SessionsTab() {
const { sessions, loading, error } = useSessions();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
useEffect(() => {
setVisibleSessions(sessions);
}, [sessions]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter(
(s) => s.canonicalTitle?.toLowerCase().includes(q),
);
}, [sessions, search]);
if (!q) return visibleSessions;
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
}, [visibleSessions, search]);
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return;
setDeleteError(null);
setDeletingSessionId(session.sessionId);
try {
await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
setExpandedId((prev) => (prev === session.sessionId ? null : prev));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
}
};
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
@@ -61,11 +84,16 @@ export function SessionsTab() {
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
<div key={dayLabel}>
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2">
{dayLabel}
</h3>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{daySessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
@@ -76,6 +104,8 @@ export function SessionsTab() {
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>

View File

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

View File

@@ -1,5 +1,5 @@
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
@@ -39,10 +39,15 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
const points = [...byDay.entries()]
.sort(([a], [b]) => a - b)
.map(([epochDay, values]) => ({
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
...values,
}));
.map(([epochDay, values]) => {
const row: Record<string, string | number> = {
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
};
for (const title of topTitles) {
row[title] = values[title] ?? 0;
}
return row;
});
return { points, seriesKeys: topTitles };
}
@@ -67,31 +72,36 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
<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}>
<AreaChart data={points}>
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
<Tooltip contentStyle={tooltipStyle} />
{seriesKeys.map((key, i) => (
<Line
<Area
key={key}
type="monotone"
dataKey={key}
stroke={LINE_COLORS[i % LINE_COLORS.length]}
strokeWidth={2}
dot={false}
fill={LINE_COLORS[i % LINE_COLORS.length]}
fillOpacity={0.15}
strokeWidth={1.5}
connectNulls
/>
))}
</LineChart>
</AreaChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
{seriesKeys.map((key, i) => (
<span key={key} className="flex items-center gap-1 text-[10px] text-ctp-subtext0">
<span
key={key}
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
title={key}
>
<span
className="inline-block w-2 h-2 rounded-full"
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }}
/>
{key}
<span className="truncate">{key}</span>
</span>
))}
</div>

View File

@@ -32,18 +32,32 @@ function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
const byAnime = new Map<string, Map<number, number>>();
const allDays = new Set<number>();
for (const p of points) {
const dayMap = byAnime.get(p.animeTitle) ?? new Map();
dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value);
byAnime.set(p.animeTitle, dayMap);
allDays.add(p.epochDay);
}
const sortedDays = [...allDays].sort((a, b) => a - b);
if (sortedDays.length < 2) return points;
const minDay = sortedDays[0]!;
const maxDay = sortedDays[sortedDays.length - 1]!;
const everyDay: number[] = [];
for (let d = minDay; d <= maxDay; d++) {
everyDay.push(d);
}
const result: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of byAnime) {
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 });
const firstDay = Math.min(...dayMap.keys());
for (const day of everyDay) {
if (day < firstDay) continue;
cumulative += dayMap.get(day) ?? 0;
result.push({ epochDay: day, animeTitle, value: cumulative });
}
}
return result;
@@ -93,9 +107,12 @@ function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnime
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>
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
<h3 className="text-ctp-subtext0 text-xs font-semibold uppercase tracking-widest shrink-0">
{children}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
);
}
@@ -119,6 +136,8 @@ export function TrendsTab() {
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
const animeProgress = buildCumulativePerAnime(episodesPerAnime);
const cardsProgress = buildCumulativePerAnime(cardsPerAnime);
const wordsProgress = buildCumulativePerAnime(wordsPerAnime);
return (
<div className="space-y-4">
@@ -141,13 +160,17 @@ export function TrendsTab() {
type="line"
/>
<SectionHeader>Anime</SectionHeader>
<StackedTrendChart title="Anime Progress (episodes)" data={animeProgress} />
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
<SectionHeader>Anime Per Day</SectionHeader>
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} />
<SectionHeader>Anime Cumulative</SectionHeader>
<StackedTrendChart title="Episodes Progress" data={animeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={cardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} />
<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" />

View File

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

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useVocabulary } from '../../hooks/useVocabulary';
import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList';
@@ -6,6 +6,7 @@ import { KanjiBreakdown } from './KanjiBreakdown';
import { KanjiDetailPanel } from './KanjiDetailPanel';
import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { FrequencyRankTable } from './FrequencyRankTable';
import { buildVocabularySummary } from '../../lib/dashboard-data';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
@@ -14,10 +15,21 @@ interface VocabularyTabProps {
onOpenWordDetail?: (wordId: number) => void;
}
function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞';
}
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
const { words, kanji, loading, error } = useVocabulary();
const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false);
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
const filteredWords = useMemo(
() => hideNames ? words.filter((w) => !isProperNoun(w)) : words,
[words, hideNames],
);
if (loading) {
return (
@@ -34,7 +46,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
);
}
const summary = buildVocabularySummary(words, kanji);
const summary = buildVocabularySummary(filteredWords, kanji);
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
@@ -56,14 +68,27 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
<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"
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{hasNames && (
<button
type="button"
onClick={() => setHideNames(!hideNames)}
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
hideNames
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
}`}
>
Hide Names
</button>
)}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
@@ -81,8 +106,10 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
/>
</div>
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
<WordList
words={words}
words={filteredWords}
selectedKey={null}
onSelectWord={handleSelectWord}
search={search}

View File

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

View File

@@ -5,6 +5,7 @@ import type { VocabularyEntry, KanjiEntry } from '../types/stats';
export function useVocabulary() {
const [words, setWords] = useState<VocabularyEntry[]>([]);
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -12,8 +13,8 @@ export function useVocabulary() {
setLoading(true);
setError(null);
const client = getStatsClient();
Promise.allSettled([client.getVocabulary(500), client.getKanji(200)])
.then(([wordsResult, kanjiResult]) => {
Promise.allSettled([client.getVocabulary(500), client.getKanji(200), client.getKnownWords()])
.then(([wordsResult, kanjiResult, knownResult]) => {
const errors: string[] = [];
if (wordsResult.status === 'fulfilled') {
@@ -28,6 +29,10 @@ export function useVocabulary() {
errors.push(kanjiResult.reason.message);
}
if (knownResult.status === 'fulfilled') {
setKnownWords(new Set(knownResult.value));
}
if (errors.length > 0) {
setError(errors.join('; '));
}
@@ -35,5 +40,5 @@ export function useVocabulary() {
.finally(() => setLoading(false));
}, []);
return { words, kanji, loading, error };
return { words, kanji, knownWords, loading, error };
}

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'http:',
origin: 'http://127.0.0.1:6123',
search: '',
});
assert.equal(baseUrl, 'http://127.0.0.1:6123');
});
test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => {
const baseUrl = resolveStatsBaseUrl({
protocol: 'file:',
origin: 'null',
search: '?overlay=1',
});
assert.equal(baseUrl, 'http://127.0.0.1:6969');
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(null, { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.deleteSession(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession throws when the stats API delete request fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response('boom', {
status: 500,
statusText: 'Internal Server Error',
})) as typeof globalThis.fetch;
try {
await assert.rejects(() => apiClient.deleteSession(7), /Stats API error: 500 boom/);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -22,12 +22,27 @@ import type {
EpisodeDetailData,
} from '../types/stats';
export const BASE_URL = window.location.protocol === 'file:'
? 'http://127.0.0.1:5175'
: window.location.origin;
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`);
export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
const resolvedLocation =
location ??
(typeof window === 'undefined'
? { protocol: 'file:', origin: 'null', search: '' }
: window.location);
const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
if (queryApiBase) {
return queryApiBase;
}
return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
}
export const BASE_URL = resolveStatsBaseUrl();
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE_URL}${path}`, init);
if (!res.ok) {
let body = '';
try {
@@ -39,6 +54,11 @@ async function fetchJson<T>(path: string): Promise<T> {
body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`,
);
}
return res;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetchResponse(path);
return res.json() as Promise<T>;
}
@@ -55,13 +75,7 @@ export const apiClient = {
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
getVocabulary: (limit = 100) =>
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
getWordOccurrences: (
headword: string,
word: string,
reading: string,
limit = 50,
offset = 0,
) =>
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
),
@@ -71,11 +85,9 @@ export const apiClient = {
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
),
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
getMediaDetail: (videoId: number) =>
fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
getAnimeDetail: (animeId: number) =>
fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) =>
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
getAnimeRollups: (animeId: number, limit = 90) =>
@@ -96,16 +108,54 @@ export const apiClient = {
getEpisodeDetail: (videoId: number) =>
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
await fetch(`${BASE_URL}/api/stats/media/${videoId}/watched`, {
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched }),
});
},
ankiBrowse: async (noteId: number): Promise<void> => {
await fetch(`${BASE_URL}/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
deleteSession: async (sessionId: number): Promise<void> => {
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
},
ankiNotesInfo: async (noteIds: number[]): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
},
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
searchAnilist: (query: string) =>
fetchJson<
Array<{
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}>
>(`/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
reassignAnimeAnilist: async (
animeId: number,
info: {
anilistId: number;
titleRomaji?: string | null;
titleEnglish?: string | null;
titleNative?: string | null;
episodesTotal?: number | null;
description?: string | null;
coverUrl?: string | null;
},
): Promise<void> => {
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info),
});
},
ankiBrowse: async (noteId: number): Promise<void> => {
await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
},
ankiNotesInfo: async (
noteIds: number[],
): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { confirmEpisodeDelete, confirmSessionDelete } from './delete-confirm';
test('confirmSessionDelete uses the shared session delete warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmSessionDelete(), true);
assert.deepEqual(calls, ['Delete this session and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return false;
}) as typeof globalThis.confirm;
try {
assert.equal(confirmEpisodeDelete('Episode 4'), false);
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
} finally {
globalThis.confirm = originalConfirm;
}
});

View File

@@ -0,0 +1,7 @@
export function confirmSessionDelete(): boolean {
return globalThis.confirm('Delete this session and all associated data?');
}
export function confirmEpisodeDelete(title: string): boolean {
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
}

View File

@@ -1,5 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@fontsource-variable/geist';
import '@fontsource-variable/geist-mono';
import { App } from './App';
import './styles/globals.css';

View File

@@ -27,15 +27,55 @@
--color-ctp-sapphire: #7dc4e4;
--color-ctp-maroon: #ee99a0;
--color-ctp-pink: #f5bde6;
--font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-family: var(--font-sans);
background-color: var(--color-ctp-base);
color: var(--color-ctp-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body.overlay-mode {
background-color: rgba(36, 39, 58, 0.85);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-ctp-surface1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-ctp-surface2);
}
/* Tab content entrance animation */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeSlideIn 0.25s ease-out;
}

View File

@@ -58,6 +58,7 @@ export interface VocabularyEntry {
pos2: string | null;
pos3: string | null;
frequency: number;
frequencyRank: number | null;
firstSeen: number;
lastSeen: number;
}
@@ -164,6 +165,7 @@ export interface AnimeDetailData {
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;