feat: overhaul stats dashboard with navigation, trends, and anime views

Add navigation state machine for tab/detail routing, anime overview
stats with Yomitan lookup rates, session word count accuracy fixes,
vocabulary tab hook order fix, simplified trends data fetching from
backend-aggregated endpoints, and improved session detail charts.
This commit is contained in:
2026-03-17 19:54:15 -07:00
parent 08a5401a7d
commit f8e2ae4887
39 changed files with 2578 additions and 871 deletions

View File

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

View File

@@ -0,0 +1,133 @@
import { formatDuration, formatNumber } from '../../lib/formatters';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { Tooltip } from '../layout/Tooltip';
import type { AnimeDetailData } from '../../types/stats';
interface AnimeOverviewStatsProps {
detail: AnimeDetailData['detail'];
knownWordsSummary: {
totalUniqueWords: number;
knownWordCount: number;
} | null;
}
interface MetricProps {
label: string;
value: string;
unit?: string;
color: string;
tooltip: string;
sub?: string;
}
function Metric({ label, value, unit, color, tooltip, sub }: MetricProps) {
return (
<Tooltip text={tooltip}>
<div className="flex flex-col items-center gap-1 px-3 py-3 rounded-lg bg-ctp-surface1/40 hover:bg-ctp-surface1/70 transition-colors">
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>
{value}
{unit && <span className="text-sm font-normal text-ctp-overlay2 ml-0.5">{unit}</span>}
</div>
<div className="text-[11px] uppercase tracking-wider text-ctp-overlay2 font-medium">
{label}
</div>
{sub && <div className="text-[11px] text-ctp-overlay1">{sub}</div>}
</div>
</Tooltip>
);
}
export function AnimeOverviewStats({
detail,
knownWordsSummary,
}: AnimeOverviewStatsProps) {
const lookupRate = buildLookupRateDisplay(
detail.totalYomitanLookupCount,
detail.totalWordsSeen,
);
const knownPct =
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
? Math.round(
(knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100,
)
: null;
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 space-y-3">
{/* Primary metrics - always 4 columns on sm+ */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Metric
label="Watch Time"
value={formatDuration(detail.totalActiveMs)}
color="text-ctp-blue"
tooltip="Total active watch time for this anime"
/>
<Metric
label="Sessions"
value={String(detail.totalSessions)}
color="text-ctp-peach"
tooltip="Number of immersion sessions on this anime"
/>
<Metric
label="Episodes"
value={String(detail.episodeCount)}
color="text-ctp-yellow"
tooltip="Number of completed episodes for this anime"
/>
<Metric
label="Words Seen"
value={formatNumber(detail.totalWordsSeen)}
color="text-ctp-mauve"
tooltip="Total word occurrences across all sessions"
/>
</div>
{/* Secondary metrics - fills row evenly */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Metric
label="Cards Mined"
value={formatNumber(detail.totalCards)}
color="text-ctp-green"
tooltip="Anki cards created from subtitle lines in this anime"
/>
<Metric
label="Lookups"
value={formatNumber(detail.totalYomitanLookupCount)}
color="text-ctp-lavender"
tooltip="Total Yomitan dictionary lookups during sessions"
/>
{lookupRate ? (
<Metric
label="Lookup Rate"
value={lookupRate.shortValue}
color="text-ctp-sapphire"
tooltip="Yomitan lookups per 100 words seen"
/>
) : (
<Metric
label="Lookup Rate"
value="—"
color="text-ctp-overlay2"
tooltip="No lookups recorded yet"
/>
)}
{knownPct !== null ? (
<Metric
label="Known Words"
value={`${knownPct}%`}
color="text-ctp-green"
tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this anime`}
/>
) : (
<Metric
label="Known Words"
value="—"
color="text-ctp-overlay2"
tooltip="No word data available yet"
/>
)}
</div>
</div>
);
}

View File

@@ -39,9 +39,15 @@ interface AnimeTabProps {
initialAnimeId?: number | null;
onClearInitialAnime?: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (animeId: number, videoId: number) => void;
}
export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord }: AnimeTabProps) {
export function AnimeTab({
initialAnimeId,
onClearInitialAnime,
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
@@ -70,6 +76,11 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
animeId={selectedAnimeId}
onBack={() => setSelectedAnimeId(null)}
onNavigateToWord={onNavigateToWord}
onOpenEpisodeDetail={
onOpenEpisodeDetail
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
/>
);
}

View File

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

View File

@@ -2,15 +2,21 @@ import { Fragment, useState } from 'react';
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client';
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { EpisodeDetail } from './EpisodeDetail';
import type { AnimeEpisode } from '../../types/stats';
interface EpisodeListProps {
episodes: AnimeEpisode[];
onEpisodeDeleted?: () => void;
onOpenDetail?: (videoId: number) => void;
}
export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: EpisodeListProps) {
export function EpisodeList({
episodes: initialEpisodes,
onEpisodeDeleted,
onOpenDetail,
}: EpisodeListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
const [episodes, setEpisodes] = useState(initialEpisodes);
@@ -65,92 +71,119 @@ export function EpisodeList({ episodes: initialEpisodes, onEpisodeDeleted }: Epi
<th className="text-right py-2 pr-3 font-medium">Progress</th>
<th className="text-right py-2 pr-3 font-medium">Watch Time</th>
<th className="text-right py-2 pr-3 font-medium">Cards</th>
<th className="text-right py-2 pr-3 font-medium">Lookup Rate</th>
<th className="text-right py-2 pr-3 font-medium">Last Watched</th>
<th className="w-16 py-2 font-medium" />
<th className="w-28 py-2 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((ep, idx) => (
<Fragment key={ep.videoId}>
<tr
onClick={() =>
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
>
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
</td>
<td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
{ep.canonicalTitle}
</td>
<td className="py-2 pr-3 text-right">
{ep.durationMs > 0 ? (
<span
className={
ep.totalActiveMs >= ep.durationMs * 0.85
? 'text-ctp-green'
: ep.totalActiveMs >= ep.durationMs * 0.5
? 'text-ctp-peach'
: 'text-ctp-overlay2'
}
>
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
</span>
) : (
<span className="text-ctp-overlay2">{'\u2014'}</span>
)}
</td>
<td className="py-2 pr-3 text-right text-ctp-blue">
{formatDuration(ep.totalActiveMs)}
</td>
<td className="py-2 pr-3 text-right text-ctp-green">
{formatNumber(ep.totalCards)}
</td>
<td className="py-2 pr-3 text-right text-ctp-overlay2">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
</td>
<td className="py-2 text-center w-16">
<div className="flex items-center justify-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleWatched(ep.videoId, ep.watched);
}}
className={`w-5 h-5 rounded border transition-colors ${
ep.watched
? 'bg-ctp-green border-ctp-green text-ctp-base'
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
}`}
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
>
{'\u2713'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
title="Delete episode"
>
{'\u2715'}
</button>
</div>
</td>
</tr>
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={8} className="py-2">
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
{sorted.map((ep, idx) => {
const lookupRate = buildLookupRateDisplay(
ep.totalYomitanLookupCount,
ep.totalWordsSeen,
);
return (
<Fragment key={ep.videoId}>
<tr
onClick={() =>
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
}
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
>
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
</td>
<td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
{ep.canonicalTitle}
</td>
<td className="py-2 pr-3 text-right">
{ep.durationMs > 0 ? (
<span
className={
ep.totalActiveMs >= ep.durationMs * 0.85
? 'text-ctp-green'
: ep.totalActiveMs >= ep.durationMs * 0.5
? 'text-ctp-peach'
: 'text-ctp-overlay2'
}
>
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
</span>
) : (
<span className="text-ctp-overlay2">{'\u2014'}</span>
)}
</td>
<td className="py-2 pr-3 text-right text-ctp-blue">
{formatDuration(ep.totalActiveMs)}
</td>
<td className="py-2 pr-3 text-right text-ctp-green">
{formatNumber(ep.totalCards)}
</td>
<td className="py-2 pr-3 text-right">
<div className="text-ctp-sapphire">{lookupRate?.shortValue ?? '\u2014'}</div>
<div className="text-[11px] text-ctp-overlay2">
{lookupRate?.longValue ?? 'lookup rate'}
</div>
</td>
<td className="py-2 pr-3 text-right text-ctp-overlay2">
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
</td>
<td className="py-2 text-center w-28">
<div className="flex items-center justify-center gap-1">
{onOpenDetail ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenDetail(ep.videoId);
}}
className="px-2 py-1 rounded border border-ctp-surface2 text-[11px] text-ctp-blue hover:border-ctp-blue/50 hover:bg-ctp-blue/10 transition-colors"
title="Open episode details"
>
Details
</button>
) : null}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleWatched(ep.videoId, ep.watched);
}}
className={`w-5 h-5 rounded border transition-colors ${
ep.watched
? 'bg-ctp-green border-ctp-green text-ctp-base'
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
}`}
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
>
{'\u2713'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
title="Delete episode"
>
{'\u2715'}
</button>
</div>
</td>
</tr>
)}
</Fragment>
))}
{expandedVideoId === ep.videoId && (
<tr>
<td colSpan={9} className="py-2">
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>

View File

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

View File

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

View File

@@ -4,7 +4,11 @@ import { formatDuration } from '../../lib/formatters';
import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView';
export function LibraryTab() {
interface LibraryTabProps {
onNavigateToSession: (sessionId: number) => void;
}
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const { media, loading, error } = useMediaLibrary();
const [search, setSearch] = useState('');
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
@@ -18,7 +22,7 @@ export function LibraryTab() {
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
if (selectedVideoId !== null) {
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} onNavigateToSession={onNavigateToSession} />;
}
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;

View File

@@ -1,20 +1,70 @@
import { useEffect, useState } from 'react';
import { useMediaDetail } from '../../hooks/useMediaDetail';
import { apiClient } from '../../lib/api-client';
import { confirmSessionDelete } from '../../lib/delete-confirm';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { MediaHeader } from './MediaHeader';
import { MediaWatchChart } from './MediaWatchChart';
import { MediaSessionList } from './MediaSessionList';
import type { SessionSummary } from '../../types/stats';
interface MediaDetailViewProps {
videoId: number;
initialExpandedSessionId?: number | null;
onConsumeInitialExpandedSession?: () => void;
onBack: () => void;
backLabel?: string;
}
export function MediaDetailView({ videoId, onBack }: MediaDetailViewProps) {
export function MediaDetailView({
videoId,
initialExpandedSessionId = null,
onConsumeInitialExpandedSession,
onBack,
backLabel = 'Back to Library',
}: MediaDetailViewProps) {
const { data, loading, error } = useMediaDetail(videoId);
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
useEffect(() => {
setLocalSessions(data?.sessions ?? null);
}, [data?.sessions]);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Media not found</div>;
const sessions = localSessions ?? data.sessions;
const detail = {
...data.detail,
totalSessions: sessions.length,
totalActiveMs: sessions.reduce((sum, session) => sum + session.activeWatchedMs, 0),
totalCards: sessions.reduce((sum, session) => sum + session.cardsMined, 0),
totalWordsSeen: sessions.reduce((sum, session) => sum + getSessionDisplayWordCount(session), 0),
totalLinesSeen: sessions.reduce((sum, session) => sum + session.linesSeen, 0),
totalLookupCount: sessions.reduce((sum, session) => sum + session.lookupCount, 0),
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
};
const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return;
setDeleteError(null);
setDeletingSessionId(session.sessionId);
try {
await apiClient.deleteSession(session.sessionId);
setLocalSessions((prev) =>
(prev ?? data.sessions).filter((item) => item.sessionId !== session.sessionId),
);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
}
};
return (
<div className="space-y-4">
<button
@@ -22,11 +72,17 @@ export function MediaDetailView({ videoId, onBack }: MediaDetailViewProps) {
onClick={onBack}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
>
&larr; Back to Library
&larr; {backLabel}
</button>
<MediaHeader detail={data.detail} />
<MediaWatchChart rollups={data.rollups} />
<MediaSessionList sessions={data.sessions} />
<MediaHeader detail={detail} />
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<MediaSessionList
sessions={sessions}
onDeleteSession={handleDeleteSession}
deletingSessionId={deletingSessionId}
initialExpandedSessionId={initialExpandedSessionId}
onConsumeInitialExpandedSession={onConsumeInitialExpandedSession}
/>
</div>
);
}

View File

@@ -1,16 +1,44 @@
import { useState, useEffect } from 'react';
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
import { getStatsClient } from '../../hooks/useStatsApi';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import type { MediaDetailData } from '../../types/stats';
interface MediaHeaderProps {
detail: NonNullable<MediaDetailData['detail']>;
initialKnownWordsSummary?: {
totalUniqueWords: number;
knownWordCount: number;
} | null;
}
export function MediaHeader({ detail }: MediaHeaderProps) {
const hitRate =
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
const knownTokenRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalWordsSeen);
const [knownWordsSummary, setKnownWordsSummary] = useState<{
totalUniqueWords: number;
knownWordCount: number;
} | null>(initialKnownWordsSummary);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getMediaKnownWordsSummary(detail.videoId)
.then((data) => {
if (!cancelled) setKnownWordsSummary(data);
})
.catch(() => {
if (!cancelled) setKnownWordsSummary(null);
});
return () => {
cancelled = true;
};
}, [detail.videoId]);
return (
<div className="flex gap-4">
@@ -32,12 +60,37 @@ export function MediaHeader({ detail }: MediaHeaderProps) {
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalWordsSeen)}</div>
<div className="text-xs text-ctp-overlay2">words seen</div>
<div className="text-xs text-ctp-overlay2">word occurrences</div>
</div>
<div>
<div className="text-ctp-peach font-medium">{formatPercent(hitRate)}</div>
<div className="text-xs text-ctp-overlay2">lookup rate</div>
<div className="text-ctp-lavender font-medium">
{formatNumber(detail.totalYomitanLookupCount)}
</div>
<div className="text-xs text-ctp-overlay2">Yomitan lookups</div>
</div>
<div>
<div className="text-ctp-sapphire font-medium">
{lookupRate?.shortValue ?? '\u2014'}
</div>
<div className="text-xs text-ctp-overlay2">
{lookupRate?.longValue ?? 'lookup rate'}
</div>
</div>
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? (
<div>
<div className="text-ctp-green font-medium">
{formatNumber(knownWordsSummary.knownWordCount)} / {formatNumber(knownWordsSummary.totalUniqueWords)}
</div>
<div className="text-xs text-ctp-overlay2">
known unique words ({Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)}%)
</div>
</div>
) : (
<div>
<div className="text-ctp-peach font-medium">{formatPercent(knownTokenRate)}</div>
<div className="text-xs text-ctp-overlay2">known token match rate</div>
</div>
)}
<div>
<div className="text-ctp-text font-medium">{detail.totalSessions}</div>
<div className="text-xs text-ctp-overlay2">sessions</div>

View File

@@ -1,11 +1,38 @@
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
import { useEffect, useState } from 'react';
import { SessionDetail } from '../sessions/SessionDetail';
import { SessionRow } from '../sessions/SessionRow';
import type { SessionSummary } from '../../types/stats';
interface MediaSessionListProps {
sessions: SessionSummary[];
onDeleteSession: (session: SessionSummary) => void;
deletingSessionId?: number | null;
initialExpandedSessionId?: number | null;
onConsumeInitialExpandedSession?: () => void;
}
export function MediaSessionList({ sessions }: MediaSessionListProps) {
export function MediaSessionList({
sessions,
onDeleteSession,
deletingSessionId = null,
initialExpandedSessionId = null,
onConsumeInitialExpandedSession,
}: MediaSessionListProps) {
const [expandedId, setExpandedId] = useState<number | null>(initialExpandedSessionId);
useEffect(() => {
if (initialExpandedSessionId == null) return;
if (!sessions.some((session) => session.sessionId === initialExpandedSessionId)) return;
setExpandedId(initialExpandedSessionId);
onConsumeInitialExpandedSession?.();
}, [initialExpandedSessionId, onConsumeInitialExpandedSession, sessions]);
useEffect(() => {
if (expandedId == null) return;
if (sessions.some((session) => session.sessionId === expandedId)) return;
setExpandedId(null);
}, [expandedId, sessions]);
if (sessions.length === 0) {
return <div className="text-sm text-ctp-overlay2">No sessions recorded</div>;
}
@@ -14,25 +41,22 @@ export function MediaSessionList({ sessions }: MediaSessionListProps) {
<div className="space-y-2">
<h3 className="text-sm font-semibold text-ctp-text">Session History</h3>
{sessions.map((s) => (
<div
key={s.sessionId}
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center justify-between"
>
<div className="min-w-0">
<div className="text-sm text-ctp-text">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={`media-session-details-${s.sessionId}`}
onToggle={() =>
setExpandedId((current) => (current === s.sessionId ? null : s.sessionId))
}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
/>
{expandedId === s.sessionId ? (
<div id={`media-session-details-${s.sessionId}`}>
<SessionDetail session={s} />
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium">{formatNumber(s.cardsMined)}</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
) : null}
</div>
))}
</div>

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { useOverview } from '../../hooks/useOverview';
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
import { HeroStats } from './HeroStats';
@@ -6,14 +7,113 @@ import { RecentSessions } from './RecentSessions';
import { TrendChart } from '../trends/TrendChart';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { formatNumber } from '../../lib/formatters';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
import { Tooltip } from '../layout/Tooltip';
import {
confirmSessionDelete,
confirmDayGroupDelete,
confirmAnimeGroupDelete,
} from '../../lib/delete-confirm';
import type { SessionSummary } from '../../types/stats';
interface OverviewTabProps {
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
}
export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
const { data, sessions, loading, error } = useOverview();
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
const { data, sessions, setSessions, loading, error } = useOverview();
const { calendar, loading: calLoading } = useStreakCalendar(90);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
const [knownWordsSummary, setKnownWordsSummary] = useState<{
totalUniqueWords: number;
knownWordCount: number;
} | null>(null);
useEffect(() => {
let cancelled = false;
getStatsClient()
.getKnownWordsSummary()
.then((data) => {
if (!cancelled) setKnownWordsSummary(data);
})
.catch(() => {
if (!cancelled) setKnownWordsSummary(null);
});
return () => {
cancelled = true;
};
}, []);
const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return;
setDeleteError(null);
setDeletingIds((prev) => new Set(prev).add(session.sessionId));
try {
await apiClient.deleteSession(session.sessionId);
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(session.sessionId);
return next;
});
}
};
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return;
setDeleteError(null);
const ids = daySessions.map((s) => s.sessionId);
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
});
try {
await apiClient.deleteSessions(ids);
const idSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.delete(id);
return next;
});
}
};
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
const title =
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
if (!confirmAnimeGroupDelete(title, groupSessions.length)) return;
setDeleteError(null);
const ids = groupSessions.map((s) => s.sessionId);
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
});
try {
await apiClient.deleteSessions(ids);
const idSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.delete(id);
return next;
});
}
};
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
@@ -21,7 +121,7 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
const summary = buildOverviewSummary(data);
const streakData = buildStreakCalendar(calendar);
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.totalSessions > 0;
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
return (
<div className="space-y-4">
@@ -40,7 +140,7 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
Today cards/episodes are daily values. Lifetime totals are sourced from summary tables.
Lifetime totals sourced from summary tables.
</p>
{showTrackedCardNote && (
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
@@ -48,57 +148,131 @@ export function OverviewTab({ onNavigateToSession }: OverviewTabProps) {
appear here.
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Lifetime Sessions
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<Tooltip text="Total immersion sessions recorded across all time">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
{formatNumber(summary.totalSessions)}
</div>
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
{formatNumber(summary.totalSessions)}
</Tooltip>
<Tooltip text="Total active watch time across all sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
{summary.allTimeMinutes < 60
? `${summary.allTimeMinutes}m`
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
</div>
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-teal">
{formatNumber(summary.episodesToday)}
</Tooltip>
<Tooltip text="Number of distinct days with at least one session">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
{formatNumber(summary.activeDays)}
</div>
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Hours</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
{formatNumber(summary.allTimeHours)}
</Tooltip>
<Tooltip text="Average active watch time per session in minutes">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
{formatNumber(summary.averageSessionMinutes)}
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
</div>
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Days</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
{formatNumber(summary.activeDays)}
</Tooltip>
<Tooltip text="Total unique episodes (videos) watched across all anime">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</div>
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Cards</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(summary.totalTrackedCards)}
</Tooltip>
<Tooltip text="Number of anime series fully completed">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)}
</div>
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Lifetime Episodes
</Tooltip>
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(summary.totalTrackedCards)}
</div>
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</Tooltip>
<Tooltip text="Percentage of dictionary lookups that matched a known word">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
{summary.lookupRate != null ? `${summary.lookupRate}%` : '—'}
</div>
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lifetime Anime</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
{formatNumber(summary.totalAnimeCompleted)}
</Tooltip>
<Tooltip text="Total word occurrences encountered in today's sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
{formatNumber(summary.todayWords)}
</div>
</div>
</div>
</Tooltip>
<Tooltip text="Unique words seen for the first time today">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
New Words Today
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
{formatNumber(summary.newWordsToday)}
</div>
</div>
</Tooltip>
<Tooltip text="Unique words seen for the first time this week">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
{formatNumber(summary.newWordsThisWeek)}
</div>
</div>
</Tooltip>
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
<>
<Tooltip text="Words matched against your known-words list out of all unique words seen">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Known Words
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
{formatNumber(knownWordsSummary.knownWordCount)}
<span className="text-sm text-ctp-overlay2 ml-1">
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
</span>
</div>
</div>
</Tooltip>
</>
)}
</div>
</div>
<RecentSessions sessions={sessions} onNavigateToSession={onNavigateToSession} />
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<RecentSessions
sessions={sessions}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={handleDeleteSession}
onDeleteDayGroup={handleDeleteDayGroup}
onDeleteAnimeGroup={handleDeleteAnimeGroup}
deletingIds={deletingIds}
/>
</div>
);
}

View File

@@ -6,11 +6,18 @@ import {
formatSessionDayLabel,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
import type { SessionSummary } from '../../types/stats';
interface RecentSessionsProps {
sessions: SessionSummary[];
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
deletingIds: Set<number>;
}
interface AnimeGroup {
@@ -52,10 +59,11 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
: `session-${session.sessionId}`;
const existing = map.get(key);
const displayWordCount = getSessionDisplayWordCount(session);
if (existing) {
existing.sessions.push(session);
existing.totalCards += session.cardsMined;
existing.totalWords += session.wordsSeen;
existing.totalWords += displayWordCount;
existing.totalActiveMs += session.activeWatchedMs;
} else {
map.set(key, {
@@ -65,7 +73,7 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
videoId: session.videoId,
sessions: [session],
totalCards: session.cardsMined,
totalWords: session.wordsSeen,
totalWords: displayWordCount,
totalActiveMs: session.activeWatchedMs,
});
}
@@ -111,61 +119,104 @@ function CoverThumbnail({
function SessionItem({
session,
onNavigateToMediaDetail,
onNavigateToSession,
onDelete,
deleteDisabled,
}: {
session: SessionSummary;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDelete: () => void;
deleteDisabled: boolean;
}) {
const displayWordCount = getSessionDisplayWordCount(session);
const navigationTarget = getSessionNavigationTarget(session);
return (
<button
type="button"
onClick={() => onNavigateToSession(session.sessionId)}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer"
>
<CoverThumbnail
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
<div className="relative group">
<button
type="button"
onClick={() => {
if (navigationTarget.type === 'media-detail') {
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
return;
}
onNavigateToSession(navigationTarget.sessionId);
}}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer"
>
<CoverThumbnail
animeId={session.animeId}
videoId={session.videoId}
title={session.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{session.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
active
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</button>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(displayWordCount)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
</button>
<button
type="button"
onClick={onDelete}
disabled={deleteDisabled}
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
);
}
function AnimeGroupRow({
group,
onNavigateToMediaDetail,
onNavigateToSession,
onDeleteSession,
onDeleteAnimeGroup,
deletingIds,
}: {
group: AnimeGroup;
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
onNavigateToSession: (sessionId: number) => void;
onDeleteSession: (session: SessionSummary) => void;
onDeleteAnimeGroup: (group: AnimeGroup) => void;
deletingIds: Set<number>;
}) {
const [expanded, setExpanded] = useState(false);
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
if (group.sessions.length === 1) {
const s = group.sessions[0]!;
return (
<SessionItem session={group.sessions[0]!} onNavigateToSession={onNavigateToSession} />
<SessionItem
session={s}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDelete={() => onDeleteSession(s)}
deleteDisabled={deletingIds.has(s.sessionId)}
/>
);
}
@@ -174,91 +225,141 @@ function AnimeGroupRow({
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
return (
<div>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={disclosureId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
<CoverThumbnail
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
aria-hidden="true"
<div className="group/anime">
<div className="relative">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={disclosureId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
{'\u25B8'}
</div>
</button>
<CoverThumbnail
animeId={group.animeId}
videoId={mostRecentSession.videoId}
title={displayTitle}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
aria-hidden="true"
>
{'\u25B8'}
</div>
</button>
<button
type="button"
onClick={() => onDeleteAnimeGroup(group)}
disabled={groupDeleting}
aria-label={`Delete all sessions for ${displayTitle}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/anime:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title={`Delete all sessions for ${displayTitle}`}
>
{groupDeleting ? '\u2026' : '\u2715'}
</button>
</div>
{expanded && (
<div id={disclosureId} role="region" aria-label={`${displayTitle} sessions`} className="ml-6 mt-1 space-y-1">
{group.sessions.map((s) => (
<button
type="button"
key={s.sessionId}
onClick={() => onNavigateToSession(s.sessionId)}
className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer"
>
<CoverThumbnail
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
{s.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(s.cardsMined)}
<div
id={disclosureId}
role="region"
aria-label={`${displayTitle} sessions`}
className="ml-6 mt-1 space-y-1"
>
{group.sessions.map((s) => {
const navigationTarget = getSessionNavigationTarget(s);
return (
<div key={s.sessionId} className="relative group/nested">
<button
type="button"
onClick={() => {
if (navigationTarget.type === 'media-detail') {
onNavigateToMediaDetail(
navigationTarget.videoId,
navigationTarget.sessionId,
);
return;
}
onNavigateToSession(navigationTarget.sessionId);
}}
className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 pr-10 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer"
>
<CoverThumbnail
animeId={s.animeId}
videoId={s.videoId}
title={s.canonicalTitle ?? 'Unknown'}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-subtext1 truncate">
{s.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(s.wordsSeen)}
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(s.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(getSessionDisplayWordCount(s))}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</button>
<button
type="button"
onClick={() => onDeleteSession(s)}
disabled={deletingIds.has(s.sessionId)}
aria-label={`Delete session ${s.canonicalTitle ?? 'Unknown Media'}`}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/nested:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete session"
>
{'\u2715'}
</button>
</div>
</button>
))}
);
})}
</div>
)}
</div>
);
}
export function RecentSessions({ sessions, onNavigateToSession }: RecentSessionsProps) {
export function RecentSessions({
sessions,
onNavigateToMediaDetail,
onNavigateToSession,
onDeleteSession,
onDeleteDayGroup,
onDeleteAnimeGroup,
deletingIds,
}: RecentSessionsProps) {
if (sessions.length === 0) {
return (
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
@@ -268,22 +369,42 @@ export function RecentSessions({ sessions, onNavigateToSession }: RecentSessions
}
const groups = groupSessionsByDay(sessions);
const anyDeleting = deletingIds.size > 0;
return (
<div className="space-y-4">
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const animeGroups = groupSessionsByAnime(daySessions);
const groupDeleting = daySessions.some((s) => deletingIds.has(s.sessionId));
return (
<div key={dayLabel}>
<div key={dayLabel} className="group/day">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
<button
type="button"
onClick={() => onDeleteDayGroup(dayLabel, daySessions)}
disabled={anyDeleting}
aria-label={`Delete all sessions from ${dayLabel}`}
className="shrink-0 text-xs text-transparent hover:text-ctp-red transition-colors opacity-0 group-hover/day:opacity-100 focus:opacity-100 disabled:opacity-40 disabled:cursor-not-allowed"
title={`Delete all sessions from ${dayLabel}`}
>
{groupDeleting ? '\u2026' : '\u2715'}
</button>
</div>
<div className="space-y-2">
{animeGroups.map((group) => (
<AnimeGroupRow key={group.key} group={group} onNavigateToSession={onNavigateToSession} />
<AnimeGroupRow
key={group.key}
group={group}
onNavigateToMediaDetail={onNavigateToMediaDetail}
onNavigateToSession={onNavigateToSession}
onDeleteSession={onDeleteSession}
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
deletingIds={deletingIds}
/>
))}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import {
ComposedChart,
AreaChart,
Area,
LineChart,
Line,
XAxis,
YAxis,
@@ -8,15 +9,18 @@ import {
ResponsiveContainer,
ReferenceArea,
ReferenceLine,
CartesianGrid,
} from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions';
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme';
import { buildLookupRateDisplay, getYomitanLookupEvents } from '../../lib/yomitan-lookup';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { EventType } from '../../types/stats';
import type { SessionEvent } from '../../types/stats';
import type { SessionEvent, SessionSummary } from '../../types/stats';
interface SessionDetailProps {
sessionId: number;
cardsMined: number;
session: SessionSummary;
}
const tooltipStyle = {
@@ -35,6 +39,30 @@ function formatTime(ms: number): string {
});
}
/** Build a lookup: linesSeen → knownWordsSeen */
function buildKnownWordsLookup(
knownWordsTimeline: KnownWordsTimelinePoint[],
): Map<number, number> {
const map = new Map<number, number>();
for (const pt of knownWordsTimeline) {
map.set(pt.linesSeen, pt.knownWordsSeen);
}
return map;
}
/** For a given linesSeen value, find the closest known words count (floor lookup). */
function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
if (map.size === 0) return 0;
if (map.has(linesSeen)) return map.get(linesSeen)!;
let best = 0;
for (const k of map.keys()) {
if (k <= linesSeen && k > best) {
best = k;
}
}
return best > 0 ? map.get(best)! : 0;
}
interface PauseRegion {
startMs: number;
endMs: number;
@@ -55,223 +83,524 @@ function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
return regions;
}
interface ChartPoint {
interface RatioChartPoint {
tsMs: number;
activity: number;
knownPct: number;
unknownPct: number;
knownWords: number;
unknownWords: number;
totalWords: number;
paused: boolean;
}
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
const { timeline, events, loading, error } = useSessionDetail(sessionId);
interface FallbackChartPoint {
tsMs: number;
totalWords: number;
}
type TimelineEntry = {
sampleMs: number;
linesSeen: number;
wordsSeen: number;
tokensSeen: number;
};
export function SessionDetail({ session }: SessionDetailProps) {
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
session.sessionId,
);
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
const sorted = [...timeline].reverse();
const pauseRegions = buildPauseRegions(events);
const chartData: ChartPoint[] = sorted.map((t, i) => {
const prevWords = i > 0 ? sorted[i - 1]!.wordsSeen : 0;
const delta = Math.max(0, t.wordsSeen - prevWords);
const paused = pauseRegions.some((r) => t.sampleMs >= r.startMs && t.sampleMs <= r.endMs);
return {
tsMs: t.sampleMs,
activity: delta,
totalWords: t.wordsSeen,
paused,
};
});
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
const cardEvents = events.filter((e) => e.eventType === EventType.CARD_MINED);
const seekEvents = events.filter(
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
);
const yomitanLookupEvents = getYomitanLookupEvents(events);
const lookupRate = buildLookupRateDisplay(
session.yomitanLookupCount,
getSessionDisplayWordCount(session),
);
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length;
const pauseRegions = buildPauseRegions(events);
const maxActivity = Math.max(...chartData.map((d) => d.activity), 1);
const yMax = Math.ceil(maxActivity * 1.3);
const tsMin = chartData.length > 0 ? chartData[0]!.tsMs : 0;
const tsMax = chartData.length > 0 ? chartData[chartData.length - 1]!.tsMs : 0;
if (hasKnownWords) {
return (
<RatioView
sorted={sorted}
knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
/>
);
}
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
{chartData.length > 0 && (
<ResponsiveContainer width="100%" height={150}>
<ComposedChart data={chartData} barCategoryGap={0} barGap={0}>
<defs>
<linearGradient id={`actGrad-${sessionId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.5} />
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis
dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={24}
domain={[0, yMax]}
allowDecimals={false}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
allowDecimals={false}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(value: number, name: string) => {
if (name === 'New words') return [`${value}`, 'New words'];
if (name === 'Total words') return [`${value}`, 'Total words'];
return [value, name];
}}
/>
<FallbackView
sorted={sorted}
cardEvents={cardEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
/>
);
}
{/* Pause shaded regions */}
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="left"
x1={r.startMs}
x2={r.endMs}
y1={0}
y2={yMax}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
/* ── Ratio View (primary design) ────────────────────────────────── */
{/* Seek markers */}
{seekEvents.map((e, i) => (
<ReferenceLine
key={`seek-${i}`}
yAxisId="left"
x={e.tsMs}
stroke="#91d7e3"
strokeWidth={1}
strokeDasharray="3 4"
strokeOpacity={0.5}
/>
))}
function RatioView({
sorted,
knownWordsMap,
cardEvents,
yomitanLookupEvents,
pauseRegions,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
}: {
sorted: TimelineEntry[];
knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
}) {
const chartData: RatioChartPoint[] = [];
for (const t of sorted) {
const totalWords = getSessionDisplayWordCount(t);
if (totalWords === 0) continue;
const knownWords = Math.min(lookupKnownWords(knownWordsMap, t.linesSeen), totalWords);
const unknownWords = totalWords - knownWords;
const knownPct = (knownWords / totalWords) * 100;
chartData.push({
tsMs: t.sampleMs,
knownPct,
unknownPct: 100 - knownPct,
knownWords,
unknownWords,
totalWords,
});
}
{/* Card mined markers */}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
yAxisId="left"
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '⛏',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}}
/>
))}
if (chartData.length === 0) {
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
}
<Area
yAxisId="left"
dataKey="activity"
stroke="#c6a0f6"
strokeWidth={1.5}
fill={`url(#actGrad-${sessionId})`}
name="New words"
dot={false}
activeDot={{ r: 3, fill: '#c6a0f6', stroke: '#1e2030', strokeWidth: 1 }}
type="monotone"
isAnimationActive={false}
/>
<Line
yAxisId="right"
dataKey="totalWords"
stroke="#8aadf4"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
name="Total words"
type="monotone"
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
)}
const tsMin = chartData[0]!.tsMs;
const tsMax = chartData[chartData.length - 1]!.tsMs;
const finalTotal = chartData[chartData.length - 1]!.totalWords;
<div className="flex flex-wrap items-center gap-4 text-[11px]">
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-2 rounded-sm"
style={{
background:
'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))',
}}
const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords }));
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
{/* ── Top: Percentage area chart ── */}
<ResponsiveContainer width="100%" height={130}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
<stop offset="100%" stopColor="#a6da95" stopOpacity={0.15} />
</linearGradient>
<linearGradient id={`unknownGrad-${session.sessionId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.25} />
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.08} />
</linearGradient>
</defs>
<CartesianGrid
horizontal
vertical={false}
stroke="#494d64"
strokeDasharray="4 4"
strokeOpacity={0.4}
/>
<span className="text-ctp-overlay2">New words</span>
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#8aadf4' }} />
<span className="text-ctp-overlay2">Total words</span>
</span>
{pauseCount > 0 && (
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-2 rounded-sm"
style={{
background: 'rgba(245,169,127,0.2)',
border: '1px solid rgba(245,169,127,0.5)',
<XAxis
dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="pct"
orientation="right"
domain={[0, 100]}
ticks={[0, 50, 100]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
tickFormatter={(v: number) => `${v}%`}
axisLine={false}
tickLine={false}
width={32}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
const d = props.payload;
if (!d) return [_value, name];
if (name === 'Known')
return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known'];
if (name === 'Unknown')
return [
`${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`,
'Unknown',
];
return [_value, name];
}}
itemSorter={() => -1}
/>
{/* Pause shaded regions */}
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="pct"
x1={r.startMs}
x2={r.endMs}
y1={0}
y2={100}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
{/* Card mine markers */}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '\u26CF',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}}
/>
<span className="text-ctp-overlay2">
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
</span>
</span>
)}
{seekCount > 0 && (
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-0.5 rounded"
style={{ background: '#91d7e3', opacity: 0.7 }}
))}
{/* Yomitan lookup markers */}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
<span className="text-ctp-overlay2">
{seekCount} seek{seekCount !== 1 ? 's' : ''}
</span>
</span>
)}
<span className="flex items-center gap-1.5">
<span className="text-[12px]"></span>
<span className="text-ctp-green">
{Math.max(cardEventCount, cardsMined)} card
{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
</span>
))}
<Area
yAxisId="pct"
dataKey="knownPct"
stackId="ratio"
stroke="#a6da95"
strokeWidth={1.5}
fill={`url(#knownGrad-${session.sessionId})`}
name="Known"
type="monotone"
dot={false}
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
isAnimationActive={false}
/>
<Area
yAxisId="pct"
dataKey="unknownPct"
stackId="ratio"
stroke="#c6a0f6"
strokeWidth={0}
fill={`url(#unknownGrad-${session.sessionId})`}
name="Unknown"
type="monotone"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
{/* ── Bottom: Word accumulation sparkline ── */}
<div className="flex items-center gap-2 border-t border-ctp-surface1 pt-1">
<span className="text-[9px] text-ctp-overlay0 whitespace-nowrap">total words</span>
<div className="flex-1 h-[28px]">
<ResponsiveContainer width="100%" height={28}>
<LineChart data={sparkData}>
<XAxis dataKey="tsMs" type="number" domain={[tsMin, tsMax]} hide />
<YAxis hide />
<Line
dataKey="totalWords"
stroke="#8aadf4"
strokeWidth={1.5}
strokeOpacity={0.8}
dot={false}
type="monotone"
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<span className="text-[10px] text-ctp-blue font-semibold whitespace-nowrap tabular-nums">
{finalTotal.toLocaleString()}
</span>
</div>
{/* ── Stats bar ── */}
<StatsBar
hasKnownWords
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
/>
</div>
);
}
/* ── Fallback View (no known words data) ────────────────────────── */
function FallbackView({
sorted,
cardEvents,
yomitanLookupEvents,
pauseRegions,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
}: {
sorted: TimelineEntry[];
cardEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
}) {
const chartData: FallbackChartPoint[] = [];
for (const t of sorted) {
const totalWords = getSessionDisplayWordCount(t);
if (totalWords === 0) continue;
chartData.push({ tsMs: t.sampleMs, totalWords });
}
if (chartData.length === 0) {
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
}
const tsMin = chartData[0]!.tsMs;
const tsMax = chartData[chartData.length - 1]!.tsMs;
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
<ResponsiveContainer width="100%" height={130}>
<LineChart data={chartData}>
<XAxis
dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false}
tickLine={false}
width={30}
allowDecimals={false}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total words']}
/>
{pauseRegions.map((r, i) => (
<ReferenceArea
key={`pause-${i}`}
x1={r.startMs}
x2={r.endMs}
fill="#f5a97f"
fillOpacity={0.15}
stroke="#f5a97f"
strokeOpacity={0.4}
strokeDasharray="3 3"
strokeWidth={1}
/>
))}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '\u26CF',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}}
/>
))}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
))}
<Line
dataKey="totalWords"
stroke="#8aadf4"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
name="Total words"
type="monotone"
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
<StatsBar
hasKnownWords={false}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
/>
</div>
);
}
/* ── Stats Bar ──────────────────────────────────────────────────── */
function StatsBar({
hasKnownWords,
pauseCount,
seekCount,
cardEventCount,
session,
lookupRate,
}: {
hasKnownWords: boolean;
pauseCount: number;
seekCount: number;
cardEventCount: number;
session: SessionSummary;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
}) {
return (
<div className="flex flex-wrap items-center gap-4 text-[11px] pt-1">
{/* Group 1: Legend */}
{hasKnownWords && (
<>
<span className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 rounded-sm"
style={{ background: 'rgba(166,218,149,0.4)', border: '1px solid #a6da95' }}
/>
<span className="text-ctp-overlay2">Known</span>
</span>
<span className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 rounded-sm"
style={{ background: 'rgba(198,160,246,0.2)', border: '1px solid #c6a0f6' }}
/>
<span className="text-ctp-overlay2">Unknown</span>
</span>
<span className="text-ctp-surface2">|</span>
</>
)}
{/* Group 2: Playback stats */}
{pauseCount > 0 && (
<span className="text-ctp-overlay2">
<span className="text-ctp-peach">{pauseCount}</span> pause
{pauseCount !== 1 ? 's' : ''}
</span>
)}
{seekCount > 0 && (
<span className="text-ctp-overlay2">
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
</span>
)}
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
{/* Group 3: Learning events */}
<span className="flex items-center gap-1.5">
<span
className="inline-block w-3 h-0.5 rounded"
style={{ background: '#b7bdf8', opacity: 0.8 }}
/>
<span className="text-ctp-overlay2">
{session.yomitanLookupCount} Yomitan lookup
{session.yomitanLookupCount !== 1 ? 's' : ''}
</span>
</span>
{lookupRate && (
<span className="text-ctp-overlay2">
lookup rate: <span className="text-ctp-sapphire">{lookupRate.shortValue}</span>{' '}
<span className="text-ctp-subtext0">({lookupRate.longValue})</span>
</span>
)}
<span className="flex items-center gap-1.5">
<span className="text-[12px]">{'\u26CF'}</span>
<span className="text-ctp-green">
{Math.max(cardEventCount, session.cardsMined)} card
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
</span>
</span>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import type { SessionSummary } from '../../types/stats';
interface SessionRowProps {
@@ -56,15 +57,17 @@ export function SessionRow({
onDelete,
deleteDisabled = false,
}: SessionRowProps) {
const displayWordCount = getSessionDisplayWordCount(session);
return (
<div className="relative group">
<button
type="button"
onClick={onToggle}
aria-expanded={isExpanded}
aria-controls={detailsId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
onClick={onToggle}
aria-expanded={isExpanded}
aria-controls={detailsId}
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
>
<CoverThumbnail
animeId={session.animeId}
videoId={session.videoId}
@@ -88,7 +91,7 @@ export function SessionRow({
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
{formatNumber(displayWordCount)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>

View File

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

View File

@@ -2,113 +2,12 @@ import { useState } from 'react';
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
import { DateRangeSelector } from './DateRangeSelector';
import { TrendChart } from './TrendChart';
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart';
import { StackedTrendChart } from './StackedTrendChart';
import {
buildAnimeVisibilityOptions,
filterHiddenAnimeData,
pruneHiddenAnime,
} from './anime-visibility';
import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data';
import { localDayFromMs } from '../../lib/formatters';
import type { SessionSummary } from '../../types/stats';
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function buildWatchTimeByDayOfWeek(sessions: SessionSummary[]): ChartPoint[] {
const totals = new Array(7).fill(0);
for (const s of sessions) {
const dow = new Date(s.startedAtMs).getDay();
totals[dow] += s.activeWatchedMs;
}
return DAY_NAMES.map((name, i) => ({ label: name, value: Math.round(totals[i] / 60_000) }));
}
function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
const totals = new Array(24).fill(0);
for (const s of sessions) {
const hour = new Date(s.startedAtMs).getHours();
totals[hour] += s.activeWatchedMs;
}
return totals.map((ms, i) => ({
label: `${String(i).padStart(2, '0')}:00`,
value: Math.round(ms / 60_000),
}));
}
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
const byAnime = new Map<string, Map<number, number>>();
const allDays = new Set<number>();
for (const p of points) {
const dayMap = byAnime.get(p.animeTitle) ?? new Map();
dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value);
byAnime.set(p.animeTitle, dayMap);
allDays.add(p.epochDay);
}
const sortedDays = [...allDays].sort((a, b) => a - b);
if (sortedDays.length < 2) return points;
const minDay = sortedDays[0]!;
const maxDay = sortedDays[sortedDays.length - 1]!;
const everyDay: number[] = [];
for (let d = minDay; d <= maxDay; d++) {
everyDay.push(d);
}
const result: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of byAnime) {
let cumulative = 0;
const firstDay = Math.min(...dayMap.keys());
for (const day of everyDay) {
if (day < firstDay) continue;
cumulative += dayMap.get(day) ?? 0;
result.push({ epochDay: day, animeTitle, value: cumulative });
}
}
return result;
}
function buildPerAnimeFromSessions(
sessions: SessionSummary[],
getValue: (s: SessionSummary) => number,
): PerAnimeDataPoint[] {
const map = new Map<string, Map<number, number>>();
for (const s of sessions) {
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
const day = localDayFromMs(s.startedAtMs);
const animeMap = map.get(title) ?? new Map();
animeMap.set(day, (animeMap.get(day) ?? 0) + getValue(s));
map.set(title, animeMap);
}
const points: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of map) {
for (const [epochDay, value] of dayMap) {
points.push({ epochDay, animeTitle, value });
}
}
return points;
}
function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnimeDataPoint[] {
// Group by anime+day, counting distinct videoIds
const map = new Map<string, Map<number, Set<number | null>>>();
for (const s of sessions) {
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
const day = localDayFromMs(s.startedAtMs);
const animeMap = map.get(title) ?? new Map();
const videoSet = animeMap.get(day) ?? new Set();
videoSet.add(s.videoId);
animeMap.set(day, videoSet);
map.set(title, animeMap);
}
const points: PerAnimeDataPoint[] = [];
for (const [animeTitle, dayMap] of map) {
for (const [epochDay, videoSet] of dayMap) {
points.push({ epochDay, animeTitle, value: videoSet.size });
}
}
return points;
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
@@ -201,41 +100,34 @@ export function TrendsTab() {
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
if (!data) return null;
const dashboard = buildTrendDashboard(data.rollups);
const watchByDow = buildWatchTimeByDayOfWeek(data.sessions);
const watchByHour = buildWatchTimeByHour(data.sessions);
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
epochDay: e.epochDay,
animeTitle: e.animeTitle,
value: e.totalActiveMin,
}));
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
const animeProgress = buildCumulativePerAnime(episodesPerAnime);
const cardsProgress = buildCumulativePerAnime(cardsPerAnime);
const wordsProgress = buildCumulativePerAnime(wordsPerAnime);
const animeTitles = buildAnimeVisibilityOptions([
episodesPerAnime,
watchTimePerAnime,
cardsPerAnime,
wordsPerAnime,
animeProgress,
cardsProgress,
wordsProgress,
data.animePerDay.episodes,
data.animePerDay.watchTime,
data.animePerDay.cards,
data.animePerDay.words,
data.animePerDay.lookups,
data.animeCumulative.episodes,
data.animeCumulative.cards,
data.animeCumulative.words,
data.animeCumulative.watchTime,
]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
const filteredEpisodesPerAnime = filterHiddenAnimeData(episodesPerAnime, activeHiddenAnime);
const filteredWatchTimePerAnime = filterHiddenAnimeData(watchTimePerAnime, activeHiddenAnime);
const filteredCardsPerAnime = filterHiddenAnimeData(cardsPerAnime, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(wordsPerAnime, activeHiddenAnime);
const filteredAnimeProgress = filterHiddenAnimeData(animeProgress, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(cardsProgress, activeHiddenAnime);
const filteredWordsProgress = filterHiddenAnimeData(wordsProgress, activeHiddenAnime);
const filteredEpisodesPerAnime = filterHiddenAnimeData(data.animePerDay.episodes, activeHiddenAnime);
const filteredWatchTimePerAnime = filterHiddenAnimeData(data.animePerDay.watchTime, activeHiddenAnime);
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
data.animePerDay.lookupsPerHundred,
activeHiddenAnime,
);
const filteredAnimeProgress = filterHiddenAnimeData(data.animeCumulative.episodes, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(data.animeCumulative.cards, activeHiddenAnime);
const filteredWordsProgress = filterHiddenAnimeData(data.animeCumulative.words, activeHiddenAnime);
const filteredWatchTimeProgress = filterHiddenAnimeData(data.animeCumulative.watchTime, activeHiddenAnime);
return (
<div className="space-y-4">
@@ -245,23 +137,27 @@ export function TrendsTab() {
onRangeChange={setRange}
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader>Activity</SectionHeader>
<TrendChart
title="Watch Time (min)"
data={dashboard.watchTime}
data={data.activity.watchTime}
color="#8aadf4"
type="bar"
/>
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
<TrendChart
title="Avg Session (min)"
data={dashboard.averageSessionMinutes}
color="#f5bde6"
type="line"
/>
<TrendChart title="Cards Mined" data={data.activity.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Period Trends</SectionHeader>
<TrendChart title="Watch Time (min)" data={data.progress.watchTime} color="#8aadf4" type="line" />
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart title="New Words Seen" data={data.progress.newWords} color="#c6a0f6" type="line" />
<TrendChart title="Cards Mined" data={data.progress.cards} color="#a6da95" type="line" />
<TrendChart title="Episodes Watched" data={data.progress.episodes} color="#91d7e3" type="line" />
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart title="Lookups / 100 Words" data={data.ratios.lookupsPerHundred} color="#f5a97f" type="line" />
<SectionHeader>Anime Per Day</SectionHeader>
<AnimeVisibilityFilter
@@ -285,8 +181,11 @@ export function TrendsTab() {
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
<StackedTrendChart title="Lookups/100w per Anime" data={filteredLookupsPerHundredPerAnime} />
<SectionHeader>Anime Cumulative</SectionHeader>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
@@ -294,13 +193,13 @@ export function TrendsTab() {
<SectionHeader>Patterns</SectionHeader>
<TrendChart
title="Watch Time by Day of Week (min)"
data={watchByDow}
data={data.patterns.watchTimeByDayOfWeek}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Watch Time by Hour (min)"
data={watchByHour}
data={data.patterns.watchTimeByHour}
color="#c6a0f6"
type="bar"
/>

View File

@@ -65,6 +65,13 @@ export function VocabularyTab({
const summary = buildVocabularySummary(filteredWords, kanji);
let knownWordCount = 0;
if (knownWords.size > 0) {
for (const w of filteredWords) {
if (knownWords.has(w.headword)) knownWordCount++;
}
}
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
};
@@ -80,16 +87,23 @@ export function VocabularyTab({
return (
<div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
<StatCard
label="Unique Words"
value={formatNumber(summary.uniqueWords)}
color="text-ctp-blue"
/>
{knownWords.size > 0 && (
<StatCard
label="Known Words"
value={`${formatNumber(knownWordCount)} (${summary.uniqueWords > 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`}
color="text-ctp-green"
/>
)}
<StatCard
label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)}
color="text-ctp-green"
color="text-ctp-teal"
/>
<StatCard
label="New This Week"

View File

@@ -135,6 +135,10 @@ export function WordDetailPanel({
occ: VocabularyOccurrenceEntry,
mode: 'word' | 'sentence' | 'audio',
) => {
if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) {
return;
}
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try {
@@ -358,60 +362,75 @@ export function WordDetailPanel({
{formatNumber(occ.occurrenceCount)} in line
</div>
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
<span>
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
· session {occ.sessionId}
</span>
{occ.sourcePath &&
occ.segmentStartMs != null &&
occ.segmentEndMs != null &&
(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
const audioStatus = mineStatus[`${baseKey}-audio`];
return (
<>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
disabled={wordStatus?.loading}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
{(() => {
const canMine =
!!occ.sourcePath &&
occ.segmentStartMs != null &&
occ.segmentEndMs != null;
const unavailableReason = canMine
? null
: occ.sourcePath
? 'This line is missing segment timing.'
: 'This source has no local file path.';
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
const audioStatus = mineStatus[`${baseKey}-audio`];
return (
<>
<button
type="button"
title={unavailableReason ?? 'Mine this word from video clip'}
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
disabled={wordStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Word'}
</button>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
disabled={sentenceStatus?.loading}
onClick={() => void handleMine(occ, 'sentence')}
>
{sentenceStatus?.loading
? 'Mining...'
: sentenceStatus?.success
? 'Mined!'
</button>
<button
type="button"
title={unavailableReason ?? 'Mine this sentence from video clip'}
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
disabled={sentenceStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'sentence')}
>
{sentenceStatus?.loading
? 'Mining...'
: sentenceStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Sentence'}
</button>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
disabled={audioStatus?.loading}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
</button>
<button
type="button"
title={unavailableReason ?? 'Mine this line as audio-only card'}
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
disabled={audioStatus?.loading || !!unavailableReason}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
: unavailableReason
? 'Unavailable'
: 'Mine Audio'}
</button>
</>
);
})()}
</button>
</>
);
})()}
</div>
{(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;