mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
151
stats/src/components/anime/AnilistSelector.tsx
Normal file
151
stats/src/components/anime/AnilistSelector.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
|
||||
interface AnilistMedia {
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
season: string | null;
|
||||
seasonYear: number | null;
|
||||
description: string | null;
|
||||
coverImage: { large: string | null; medium: string | null } | null;
|
||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||
}
|
||||
|
||||
interface AnilistSelectorProps {
|
||||
animeId: number;
|
||||
initialQuery: string;
|
||||
onClose: () => void;
|
||||
onLinked: () => void;
|
||||
}
|
||||
|
||||
export function AnilistSelector({
|
||||
animeId,
|
||||
initialQuery,
|
||||
onClose,
|
||||
onLinked,
|
||||
}: AnilistSelectorProps) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [results, setResults] = useState<AnilistMedia[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [linking, setLinking] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
if (initialQuery) doSearch(initialQuery);
|
||||
}, []);
|
||||
|
||||
const doSearch = async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiClient.searchAnilist(q.trim());
|
||||
setResults(data);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setQuery(value);
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
||||
};
|
||||
|
||||
const handleSelect = async (media: AnilistMedia) => {
|
||||
setLinking(media.id);
|
||||
try {
|
||||
await apiClient.reassignAnimeAnilist(animeId, {
|
||||
anilistId: media.id,
|
||||
titleRomaji: media.title?.romaji ?? null,
|
||||
titleEnglish: media.title?.english ?? null,
|
||||
titleNative: media.title?.native ?? null,
|
||||
episodesTotal: media.episodes ?? null,
|
||||
description: media.description ?? null,
|
||||
coverUrl: media.coverImage?.large ?? media.coverImage?.medium ?? null,
|
||||
});
|
||||
onLinked();
|
||||
} catch {
|
||||
setLinking(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||
<div
|
||||
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-ctp-surface1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Select AniList Entry</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
placeholder="Search AniList..."
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading && <div className="text-xs text-ctp-overlay2 p-3">Searching...</div>}
|
||||
{!loading && results.length === 0 && query.trim() && (
|
||||
<div className="text-xs text-ctp-overlay2 p-3">No results</div>
|
||||
)}
|
||||
{results.map((media) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
disabled={linking !== null}
|
||||
onClick={() => void handleSelect(media)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
|
||||
>
|
||||
{media.coverImage?.medium ? (
|
||||
<img
|
||||
src={media.coverImage.medium}
|
||||
alt=""
|
||||
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface1"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-14 rounded bg-ctp-surface1 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">
|
||||
{media.title?.romaji ?? media.title?.english ?? 'Unknown'}
|
||||
</div>
|
||||
{media.title?.english && media.title.english !== media.title.romaji && (
|
||||
<div className="text-xs text-ctp-subtext0 truncate">{media.title.english}</div>
|
||||
)}
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5">
|
||||
{media.episodes ? `${media.episodes} eps` : 'Unknown eps'}
|
||||
{media.seasonYear ? ` · ${media.season ?? ''} ${media.seasonYear}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{linking === media.id ? (
|
||||
<span className="text-xs text-ctp-blue shrink-0">Linking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
stats/src/components/anime/AnimeCard.tsx
Normal file
35
stats/src/components/anime/AnimeCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface AnimeCardProps {
|
||||
anime: AnimeLibraryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AnimeCard({ anime, onClick }: AnimeCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-blue/50 hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full"
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<AnimeCoverImage
|
||||
animeId={anime.animeId}
|
||||
title={anime.canonicalTitle}
|
||||
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||
{anime.episodeCount} episode{anime.episodeCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
74
stats/src/components/anime/AnimeCardsList.tsx
Normal file
74
stats/src/components/anime/AnimeCardsList.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import { EpisodeDetail } from './EpisodeDetail';
|
||||
import type { AnimeEpisode } from '../../types/stats';
|
||||
|
||||
interface AnimeCardsListProps {
|
||||
episodes: AnimeEpisode[];
|
||||
totalCards: number;
|
||||
}
|
||||
|
||||
export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
|
||||
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
|
||||
|
||||
if (totalCards === 0) {
|
||||
return (
|
||||
<CollapsibleSection title="Cards Mined (0)" defaultOpen={false}>
|
||||
<p className="text-sm text-ctp-overlay2">No cards mined from this anime yet.</p>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
const withCards = episodes.filter((ep) => ep.totalCards > 0);
|
||||
|
||||
return (
|
||||
<CollapsibleSection title={`Cards Mined (${formatNumber(totalCards)})`} defaultOpen={false}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="w-6 py-2 pr-1 font-medium" />
|
||||
<th className="text-left py-2 pr-3 font-medium">Episode</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Cards</th>
|
||||
<th className="text-right py-2 font-medium">Last Watched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{withCards.map((ep) => (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() =>
|
||||
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
|
||||
}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '▼' : '▶'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[300px]">
|
||||
<span className="text-ctp-subtext0 mr-2">
|
||||
{ep.episode != null ? `#${ep.episode}` : ''}
|
||||
</span>
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-ctp-overlay2">
|
||||
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
35
stats/src/components/anime/AnimeCoverImage.tsx
Normal file
35
stats/src/components/anime/AnimeCoverImage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
|
||||
interface AnimeCoverImageProps {
|
||||
animeId: number;
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src = getStatsClient().getAnimeCoverUrl(animeId);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
186
stats/src/components/anime/AnimeDetailView.tsx
Normal file
186
stats/src/components/anime/AnimeDetailView.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { AnimeHeader } from './AnimeHeader';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
import { AnimeWordList } from './AnimeWordList';
|
||||
import { AnilistSelector } from './AnilistSelector';
|
||||
import { AnimeOverviewStats } from './AnimeOverviewStats';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface AnimeDetailViewProps {
|
||||
animeId: number;
|
||||
onBack: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
onOpenEpisodeDetail?: (videoId: number) => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
function AnimeWatchChart({ animeId }: { animeId: number }) {
|
||||
const [rollups, setRollups] = useState<DailyRollup[]>([]);
|
||||
const [range, setRange] = useState<Range>(30);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeRollups(animeId, 90)
|
||||
.then((data) => {
|
||||
if (!cancelled) setRollups(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRollups([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAnimeKnownWords(animeId: number) {
|
||||
const [summary, setSummary] = useState<{
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeKnownWordsSummary(animeId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setSummary(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSummary(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function AnimeDetailView({
|
||||
animeId,
|
||||
onBack,
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
const knownWordsSummary = useAnimeKnownWords(animeId);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
|
||||
|
||||
const { detail, episodes, anilistEntries } = data;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
← Back to Library
|
||||
</button>
|
||||
<AnimeHeader
|
||||
detail={detail}
|
||||
anilistEntries={anilistEntries ?? []}
|
||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||
/>
|
||||
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
||||
/>
|
||||
<AnimeWatchChart animeId={animeId} />
|
||||
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
|
||||
{showAnilistSelector && (
|
||||
<AnilistSelector
|
||||
animeId={animeId}
|
||||
initialQuery={detail.canonicalTitle}
|
||||
onClose={() => setShowAnilistSelector(false)}
|
||||
onLinked={() => {
|
||||
setShowAnilistSelector(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
stats/src/components/anime/AnimeHeader.tsx
Normal file
99
stats/src/components/anime/AnimeHeader.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
|
||||
|
||||
interface AnimeHeaderProps {
|
||||
detail: AnimeDetailData['detail'];
|
||||
anilistEntries: AnilistEntry[];
|
||||
onChangeAnilist?: () => void;
|
||||
}
|
||||
|
||||
function AnilistButton({ entry }: { entry: AnilistEntry }) {
|
||||
const label =
|
||||
entry.season != null
|
||||
? `Season ${entry.season}`
|
||||
: (entry.titleEnglish ?? entry.titleRomaji ?? 'AniList');
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${entry.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
{label}
|
||||
<span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
|
||||
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
|
||||
(t): t is string => t != null && t !== detail.canonicalTitle,
|
||||
);
|
||||
const uniqueAltTitles = [...new Set(altTitles)];
|
||||
|
||||
const hasMultipleEntries = anilistEntries.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<AnimeCoverImage
|
||||
animeId={detail.animeId}
|
||||
title={detail.canonicalTitle}
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
{uniqueAltTitles.length > 0 && (
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5 truncate">
|
||||
{uniqueAltTitles.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-ctp-subtext0 mt-2">
|
||||
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{anilistEntries.length > 0 ? (
|
||||
hasMultipleEntries ? (
|
||||
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
|
||||
) : (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
)
|
||||
) : detail.anilistId ? (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${detail.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
) : null}
|
||||
{onChangeAnilist && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeAnilist}
|
||||
title="Search AniList and manually select the correct anime entry"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
|
||||
>
|
||||
{anilistEntries.length > 0 || detail.anilistId
|
||||
? 'Change AniList Entry'
|
||||
: 'Link to AniList'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{detail.description && (
|
||||
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
|
||||
{detail.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
stats/src/components/anime/AnimeOverviewStats.tsx
Normal file
125
stats/src/components/anime/AnimeOverviewStats.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import { Tooltip } from '../layout/Tooltip';
|
||||
import type { AnimeDetailData } from '../../types/stats';
|
||||
|
||||
interface AnimeOverviewStatsProps {
|
||||
detail: AnimeDetailData['detail'];
|
||||
knownWordsSummary: {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
color: string;
|
||||
tooltip: string;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
function Metric({ label, value, unit, color, tooltip, sub }: MetricProps) {
|
||||
return (
|
||||
<Tooltip text={tooltip}>
|
||||
<div className="flex flex-col items-center gap-1 px-3 py-3 rounded-lg bg-ctp-surface1/40 hover:bg-ctp-surface1/70 transition-colors">
|
||||
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>
|
||||
{value}
|
||||
{unit && <span className="text-sm font-normal text-ctp-overlay2 ml-0.5">{unit}</span>}
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-ctp-overlay2 font-medium">
|
||||
{label}
|
||||
</div>
|
||||
{sub && <div className="text-[11px] text-ctp-overlay1">{sub}</div>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewStatsProps) {
|
||||
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
|
||||
|
||||
const knownPct =
|
||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 space-y-3">
|
||||
{/* Primary metrics - always 4 columns on sm+ */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Metric
|
||||
label="Watch Time"
|
||||
value={formatDuration(detail.totalActiveMs)}
|
||||
color="text-ctp-blue"
|
||||
tooltip="Total active watch time for this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Sessions"
|
||||
value={String(detail.totalSessions)}
|
||||
color="text-ctp-peach"
|
||||
tooltip="Number of immersion sessions on this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Episodes"
|
||||
value={String(detail.episodeCount)}
|
||||
color="text-ctp-yellow"
|
||||
tooltip="Number of completed episodes for this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Words Seen"
|
||||
value={formatNumber(detail.totalTokensSeen)}
|
||||
color="text-ctp-mauve"
|
||||
tooltip="Total word occurrences across all sessions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary metrics - fills row evenly */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<Metric
|
||||
label="Cards Mined"
|
||||
value={formatNumber(detail.totalCards)}
|
||||
color="text-ctp-cards-mined"
|
||||
tooltip="Anki cards created from subtitle lines in this anime"
|
||||
/>
|
||||
<Metric
|
||||
label="Lookups"
|
||||
value={formatNumber(detail.totalYomitanLookupCount)}
|
||||
color="text-ctp-lavender"
|
||||
tooltip="Total Yomitan dictionary lookups during sessions"
|
||||
/>
|
||||
{lookupRate ? (
|
||||
<Metric
|
||||
label="Lookup Rate"
|
||||
value={lookupRate.shortValue}
|
||||
color="text-ctp-sapphire"
|
||||
tooltip="Yomitan lookups per 100 words seen"
|
||||
/>
|
||||
) : (
|
||||
<Metric
|
||||
label="Lookup Rate"
|
||||
value="—"
|
||||
color="text-ctp-overlay2"
|
||||
tooltip="No lookups recorded yet"
|
||||
/>
|
||||
)}
|
||||
{knownPct !== null ? (
|
||||
<Metric
|
||||
label="Known Words"
|
||||
value={`${knownPct}%`}
|
||||
color="text-ctp-green"
|
||||
tooltip={`${formatNumber(knownWordsSummary!.knownWordCount)} known out of ${formatNumber(knownWordsSummary!.totalUniqueWords)} unique words in this anime`}
|
||||
/>
|
||||
) : (
|
||||
<Metric
|
||||
label="Known Words"
|
||||
value="—"
|
||||
color="text-ctp-overlay2"
|
||||
tooltip="No word data available yet"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
stats/src/components/anime/AnimeTab.tsx
Normal file
147
stats/src/components/anime/AnimeTab.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { AnimeCard } from './AnimeCard';
|
||||
import { AnimeDetailView } from './AnimeDetailView';
|
||||
|
||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||
type CardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
const GRID_CLASSES: Record<CardSize, string> = {
|
||||
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
||||
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
|
||||
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'lastWatched', label: 'Last Watched' },
|
||||
{ key: 'watchTime', label: 'Watch Time' },
|
||||
{ key: 'cards', label: 'Cards' },
|
||||
{ key: 'episodes', label: 'Episodes' },
|
||||
];
|
||||
|
||||
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
|
||||
return [...list].sort((a, b) => {
|
||||
switch (key) {
|
||||
case 'lastWatched':
|
||||
return b.lastWatchedMs - a.lastWatchedMs;
|
||||
case 'watchTime':
|
||||
return b.totalActiveMs - a.totalActiveMs;
|
||||
case 'cards':
|
||||
return b.totalCards - a.totalCards;
|
||||
case 'episodes':
|
||||
return b.episodeCount - a.episodeCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface AnimeTabProps {
|
||||
initialAnimeId?: number | null;
|
||||
onClearInitialAnime?: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
onOpenEpisodeDetail?: (animeId: number, videoId: number) => void;
|
||||
}
|
||||
|
||||
export function AnimeTab({
|
||||
initialAnimeId,
|
||||
onClearInitialAnime,
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeTabProps) {
|
||||
const { anime, loading, error } = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<CardSize>('md');
|
||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAnimeId != null) {
|
||||
setSelectedAnimeId(initialAnimeId);
|
||||
onClearInitialAnime?.();
|
||||
}
|
||||
}, [initialAnimeId, onClearInitialAnime]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const base = search.trim()
|
||||
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
|
||||
: anime;
|
||||
return sortAnime(base, sortKey);
|
||||
}, [anime, search, sortKey]);
|
||||
|
||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
|
||||
if (selectedAnimeId !== null) {
|
||||
return (
|
||||
<AnimeDetailView
|
||||
animeId={selectedAnimeId}
|
||||
onBack={() => setSelectedAnimeId(null)}
|
||||
onNavigateToWord={onNavigateToWord}
|
||||
onOpenEpisodeDetail={
|
||||
onOpenEpisodeDetail
|
||||
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search anime..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setCardSize(size)}
|
||||
className={`px-2 py-1 rounded-md text-xs transition-colors ${
|
||||
cardSize === size
|
||||
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{size === 'sm' ? '▪' : size === 'md' ? '◼' : '⬛'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} anime · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
|
||||
) : (
|
||||
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
||||
{filtered.map((item) => (
|
||||
<AnimeCard
|
||||
key={item.animeId}
|
||||
anime={item}
|
||||
onClick={() => setSelectedAnimeId(item.animeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
stats/src/components/anime/AnimeWordList.tsx
Normal file
65
stats/src/components/anime/AnimeWordList.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import type { AnimeWord } from '../../types/stats';
|
||||
|
||||
interface AnimeWordListProps {
|
||||
animeId: number;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
export function AnimeWordList({ animeId, onNavigateToWord }: AnimeWordListProps) {
|
||||
const [words, setWords] = useState<AnimeWord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getStatsClient()
|
||||
.getAnimeWords(animeId, 50)
|
||||
.then((data) => {
|
||||
if (!cancelled) setWords(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setWords([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-sm p-4">Loading words...</div>;
|
||||
if (words.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CollapsibleSection title={`Top Words (${words.length})`} defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{words.map((w) => (
|
||||
<button
|
||||
key={w.wordId}
|
||||
type="button"
|
||||
onClick={() => onNavigateToWord?.(w.wordId)}
|
||||
className="bg-ctp-base border border-ctp-surface1 rounded-md p-2 hover:border-ctp-blue transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<div className="text-sm font-medium text-ctp-text">{w.headword}</div>
|
||||
{w.reading && w.reading !== w.headword && (
|
||||
<div className="text-xs text-ctp-overlay2">{w.reading}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{w.partOfSpeech && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-ctp-surface1 text-ctp-subtext0 rounded">
|
||||
{w.partOfSpeech}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ctp-mauve ml-auto">{formatNumber(w.frequency)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
38
stats/src/components/anime/CollapsibleSection.tsx
Normal file
38
stats/src/components/anime/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const contentId = useId();
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-ctp-text">{title}</h3>
|
||||
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">
|
||||
{open ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div id={contentId} className="px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
stats/src/components/anime/EpisodeDetail.tsx
Normal file
155
stats/src/components/anime/EpisodeDetail.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import type { EpisodeDetailData } from '../../types/stats';
|
||||
|
||||
interface EpisodeDetailProps {
|
||||
videoId: number;
|
||||
onSessionDeleted?: () => void;
|
||||
}
|
||||
|
||||
interface NoteInfo {
|
||||
noteId: number;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) {
|
||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getStatsClient()
|
||||
.getEpisodeDetail(videoId)
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
setData(d);
|
||||
const allNoteIds = d.cardEvents.flatMap((ev) => ev.noteIds);
|
||||
if (allNoteIds.length > 0) {
|
||||
getStatsClient()
|
||||
.ankiNotesInfo(allNoteIds)
|
||||
.then((notes) => {
|
||||
if (cancelled) return;
|
||||
const map = new Map<number, NoteInfo>();
|
||||
for (const note of notes) {
|
||||
const expr = note.preview?.word ?? '';
|
||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||
}
|
||||
setNoteInfos(map);
|
||||
})
|
||||
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setData(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [videoId]);
|
||||
|
||||
const handleDeleteSession = async (sessionId: number) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
await apiClient.deleteSession(sessionId);
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, sessions: prev.sessions.filter((s) => s.sessionId !== sessionId) };
|
||||
});
|
||||
onSessionDeleted?.();
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>;
|
||||
if (!data)
|
||||
return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
|
||||
|
||||
const { sessions, cardEvents } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||
{sessions.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Sessions</h4>
|
||||
<div className="space-y-1">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.sessionId} className="flex items-center gap-3 text-xs group">
|
||||
<span className="text-ctp-overlay2">
|
||||
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
|
||||
</span>
|
||||
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
|
||||
<span className="text-ctp-cards-mined">{formatNumber(s.cardsMined)} cards</span>
|
||||
<span className="text-ctp-peach">
|
||||
{formatNumber(getSessionDisplayWordCount(s))} words
|
||||
</span>
|
||||
<span className="text-ctp-green">{formatNumber(s.knownWordsSeen)} known words</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteSession(s.sessionId);
|
||||
}}
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 text-ctp-red/70 hover:text-ctp-red transition-opacity text-[10px] px-1.5 py-0.5 rounded hover:bg-ctp-red/10"
|
||||
title="Delete session"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardEvents.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||
<div className="space-y-1.5">
|
||||
{cardEvents.map((ev) => (
|
||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-ctp-overlay2 shrink-0">{formatRelativeDate(ev.tsMs)}</span>
|
||||
{ev.noteIds.length > 0 ? (
|
||||
ev.noteIds.map((noteId) => {
|
||||
const info = noteInfos.get(noteId);
|
||||
return (
|
||||
<div key={noteId} className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{info?.expression && (
|
||||
<span className="text-ctp-text font-medium truncate">
|
||||
{info.expression}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
getStatsClient().ankiBrowse(noteId);
|
||||
}}
|
||||
className="px-1.5 py-0.5 bg-ctp-surface1 text-ctp-blue rounded text-[10px] hover:bg-ctp-surface2 transition-colors cursor-pointer shrink-0 ml-auto"
|
||||
>
|
||||
Open in Anki
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-ctp-cards-mined">
|
||||
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && cardEvents.length === 0 && (
|
||||
<div className="p-3 text-xs text-ctp-overlay2">No detailed data available.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
stats/src/components/anime/EpisodeList.tsx
Normal file
196
stats/src/components/anime/EpisodeList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import { EpisodeDetail } from './EpisodeDetail';
|
||||
import type { AnimeEpisode } from '../../types/stats';
|
||||
|
||||
interface EpisodeListProps {
|
||||
episodes: AnimeEpisode[];
|
||||
onEpisodeDeleted?: () => void;
|
||||
onOpenDetail?: (videoId: number) => void;
|
||||
}
|
||||
|
||||
export function EpisodeList({
|
||||
episodes: initialEpisodes,
|
||||
onEpisodeDeleted,
|
||||
onOpenDetail,
|
||||
}: EpisodeListProps) {
|
||||
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
|
||||
const [episodes, setEpisodes] = useState(initialEpisodes);
|
||||
|
||||
if (episodes.length === 0) return null;
|
||||
|
||||
const sorted = [...episodes].sort((a, b) => {
|
||||
if (a.episode != null && b.episode != null) return a.episode - b.episode;
|
||||
if (a.episode != null) return -1;
|
||||
if (b.episode != null) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const toggleWatched = async (videoId: number, currentWatched: number) => {
|
||||
const newWatched = currentWatched ? 0 : 1;
|
||||
setEpisodes((prev) =>
|
||||
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: newWatched } : ep)),
|
||||
);
|
||||
try {
|
||||
await apiClient.setVideoWatched(videoId, newWatched === 1);
|
||||
} catch {
|
||||
setEpisodes((prev) =>
|
||||
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: currentWatched } : ep)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEpisode = async (videoId: number, title: string) => {
|
||||
if (!confirmEpisodeDelete(title)) return;
|
||||
await apiClient.deleteVideo(videoId);
|
||||
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
|
||||
if (expandedVideoId === videoId) setExpandedVideoId(null);
|
||||
onEpisodeDeleted?.();
|
||||
};
|
||||
|
||||
const watchedCount = episodes.filter((ep) => ep.watched).length;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Episodes</h3>
|
||||
<span className="text-xs text-ctp-overlay2">
|
||||
{watchedCount}/{episodes.length} watched
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="w-6 py-2 pr-1 font-medium" />
|
||||
<th className="text-left py-2 pr-3 font-medium">#</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Title</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Progress</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Watch Time</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Cards</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Lookup Rate</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Last Watched</th>
|
||||
<th className="w-28 py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((ep, idx) => {
|
||||
const lookupRate = buildLookupRateDisplay(
|
||||
ep.totalYomitanLookupCount,
|
||||
ep.totalTokensSeen,
|
||||
);
|
||||
const progressPct =
|
||||
ep.durationMs > 0 && ep.endedMediaMs != null
|
||||
? Math.min(100, Math.round((ep.endedMediaMs / ep.durationMs) * 100))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() =>
|
||||
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
|
||||
}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors group"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-subtext0">{ep.episode ?? idx + 1}</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
{progressPct != null ? (
|
||||
<span
|
||||
className={
|
||||
progressPct >= 85
|
||||
? 'text-ctp-green'
|
||||
: progressPct >= 50
|
||||
? 'text-ctp-peach'
|
||||
: 'text-ctp-overlay2'
|
||||
}
|
||||
>
|
||||
{progressPct}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ctp-overlay2">{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-blue">
|
||||
{formatDuration(ep.totalActiveMs)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<div className="text-ctp-sapphire">{lookupRate?.shortValue ?? '\u2014'}</div>
|
||||
<div className="text-[11px] text-ctp-overlay2">
|
||||
{lookupRate?.longValue ?? 'lookup rate'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-overlay2">
|
||||
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
|
||||
</td>
|
||||
<td className="py-2 text-center w-28">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{onOpenDetail ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenDetail(ep.videoId);
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-ctp-surface2 text-[11px] text-ctp-blue hover:border-ctp-blue/50 hover:bg-ctp-blue/10 transition-colors"
|
||||
title="Open episode details"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleWatched(ep.videoId, ep.watched);
|
||||
}}
|
||||
className={`w-5 h-5 rounded border transition-colors ${
|
||||
ep.watched
|
||||
? 'bg-ctp-green border-ctp-green text-ctp-base'
|
||||
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
|
||||
}`}
|
||||
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
|
||||
>
|
||||
{'\u2713'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
|
||||
}}
|
||||
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
|
||||
title="Delete episode"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} onSessionDeleted={onEpisodeDeleted} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
stats/src/components/layout/StatCard.tsx
Normal file
52
stats/src/components/layout/StatCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
subValue?: string;
|
||||
color?: string;
|
||||
trend?: { direction: 'up' | 'down' | 'flat'; text: string };
|
||||
}
|
||||
|
||||
const COLOR_TO_BORDER: Record<string, string> = {
|
||||
'text-ctp-blue': 'border-l-ctp-blue',
|
||||
'text-ctp-green': 'border-l-ctp-green',
|
||||
'text-ctp-mauve': 'border-l-ctp-mauve',
|
||||
'text-ctp-peach': 'border-l-ctp-peach',
|
||||
'text-ctp-teal': 'border-l-ctp-teal',
|
||||
'text-ctp-lavender': 'border-l-ctp-lavender',
|
||||
'text-ctp-red': 'border-l-ctp-red',
|
||||
'text-ctp-yellow': 'border-l-ctp-yellow',
|
||||
'text-ctp-sapphire': 'border-l-ctp-sapphire',
|
||||
'text-ctp-sky': 'border-l-ctp-sky',
|
||||
'text-ctp-flamingo': 'border-l-ctp-flamingo',
|
||||
'text-ctp-maroon': 'border-l-ctp-maroon',
|
||||
'text-ctp-pink': 'border-l-ctp-pink',
|
||||
'text-ctp-text': 'border-l-ctp-surface2',
|
||||
};
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
color = 'text-ctp-text',
|
||||
trend,
|
||||
}: StatCardProps) {
|
||||
const borderClass = COLOR_TO_BORDER[color] ?? 'border-l-ctp-surface2';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface0 border border-ctp-surface1 border-l-[3px] ${borderClass} rounded-lg p-4 text-center`}
|
||||
>
|
||||
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>{value}</div>
|
||||
<div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div>
|
||||
{subValue && <div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>}
|
||||
{trend && (
|
||||
<div
|
||||
className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}
|
||||
>
|
||||
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'}{' '}
|
||||
{trend.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
stats/src/components/layout/TabBar.tsx
Normal file
88
stats/src/components/layout/TabBar.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useRef, type KeyboardEvent } from 'react';
|
||||
|
||||
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'anime', label: 'Library' },
|
||||
{ id: 'trends', label: 'Trends' },
|
||||
{ id: 'vocabulary', label: 'Vocabulary' },
|
||||
{ id: 'sessions', label: 'Sessions' },
|
||||
];
|
||||
|
||||
interface TabBarProps {
|
||||
activeTab: TabId;
|
||||
onTabChange: (tabId: TabId) => void;
|
||||
}
|
||||
|
||||
export function TabBar({ activeTab, onTabChange }: TabBarProps) {
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
const activateAtIndex = (index: number) => {
|
||||
const tab = TABS[index];
|
||||
if (!tab) return;
|
||||
tabRefs.current[index]?.focus();
|
||||
onTabChange(tab.id);
|
||||
};
|
||||
|
||||
const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activateAtIndex((index + 1) % TABS.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activateAtIndex((index - 1 + TABS.length) % TABS.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
activateAtIndex(0);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
activateAtIndex(TABS.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex border-b border-ctp-surface1"
|
||||
role="tablist"
|
||||
aria-label="Stats tabs"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
ref={(element) => {
|
||||
tabRefs.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
aria-selected={activeTab === tab.id}
|
||||
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
onKeyDown={(event) => onTabKeyDown(event, index)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'text-ctp-text border-b-2 border-ctp-lavender'
|
||||
: 'text-ctp-subtext0 hover:text-ctp-subtext1'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
22
stats/src/components/layout/Tooltip.tsx
Normal file
22
stats/src/components/layout/Tooltip.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface TooltipProps {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tooltip({ text, children }: TooltipProps) {
|
||||
return (
|
||||
<div className="group/tip relative">
|
||||
{children}
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
|
||||
max-w-56 px-2.5 py-1.5 rounded-md text-xs text-ctp-text bg-ctp-surface2 border border-ctp-overlay0 shadow-lg
|
||||
opacity-0 scale-95 transition-all duration-150
|
||||
group-hover/tip:opacity-100 group-hover/tip:scale-100"
|
||||
>
|
||||
{text}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-ctp-surface2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
stats/src/components/library/CoverImage.tsx
Normal file
32
stats/src/components/library/CoverImage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
|
||||
interface CoverImageProps {
|
||||
videoId: number;
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
|
||||
alt={title}
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
stats/src/components/library/LibraryTab.tsx
Normal file
67
stats/src/components/library/LibraryTab.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
interface LibraryTabProps {
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
}
|
||||
|
||||
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
const { media, loading, error } = useMediaLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return media;
|
||||
const q = search.toLowerCase();
|
||||
return media.filter((m) => m.canonicalTitle.toLowerCase().includes(q));
|
||||
}, [media, search]);
|
||||
|
||||
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return (
|
||||
<MediaDetailView
|
||||
videoId={selectedVideoId}
|
||||
onBack={() => setSelectedVideoId(null)}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search titles..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filtered.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
stats/src/components/library/MediaCard.tsx
Normal file
33
stats/src/components/library/MediaCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import type { MediaLibraryItem } from '../../types/stats';
|
||||
|
||||
interface MediaCardProps {
|
||||
item: MediaLibraryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-surface2 transition-colors text-left w-full"
|
||||
>
|
||||
<CoverImage
|
||||
videoId={item.videoId}
|
||||
title={item.canonicalTitle}
|
||||
className="w-full aspect-[3/4] rounded-t-lg"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
105
stats/src/components/library/MediaDetailView.tsx
Normal file
105
stats/src/components/library/MediaDetailView.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { MediaHeader } from './MediaHeader';
|
||||
import { MediaSessionList } from './MediaSessionList';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface MediaDetailViewProps {
|
||||
videoId: number;
|
||||
initialExpandedSessionId?: number | null;
|
||||
onConsumeInitialExpandedSession?: () => void;
|
||||
onBack: () => void;
|
||||
backLabel?: string;
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
}
|
||||
|
||||
export function MediaDetailView({
|
||||
videoId,
|
||||
initialExpandedSessionId = null,
|
||||
onConsumeInitialExpandedSession,
|
||||
onBack,
|
||||
backLabel = 'Back to Library',
|
||||
onNavigateToAnime,
|
||||
}: MediaDetailViewProps) {
|
||||
const { data, loading, error } = useMediaDetail(videoId);
|
||||
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSessions(data?.sessions ?? null);
|
||||
}, [data?.sessions]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Media not found</div>;
|
||||
|
||||
const sessions = localSessions ?? data.sessions;
|
||||
const animeId = data.detail.animeId;
|
||||
const detail = {
|
||||
...data.detail,
|
||||
totalSessions: sessions.length,
|
||||
totalActiveMs: sessions.reduce((sum, session) => sum + session.activeWatchedMs, 0),
|
||||
totalCards: sessions.reduce((sum, session) => sum + session.cardsMined, 0),
|
||||
totalTokensSeen: sessions.reduce(
|
||||
(sum, session) => sum + getSessionDisplayWordCount(session),
|
||||
0,
|
||||
),
|
||||
totalLinesSeen: sessions.reduce((sum, session) => sum + session.linesSeen, 0),
|
||||
totalLookupCount: sessions.reduce((sum, session) => sum + session.lookupCount, 0),
|
||||
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
||||
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingSessionId(session.sessionId);
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setLocalSessions((prev) =>
|
||||
(prev ?? data.sessions).filter((item) => item.sessionId !== session.sessionId),
|
||||
);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
setDeletingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
← {backLabel}
|
||||
</button>
|
||||
{onNavigateToAnime != null && animeId != null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateToAnime(animeId)}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View Anime →
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<MediaHeader detail={detail} />
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
<MediaSessionList
|
||||
sessions={sessions}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
deletingSessionId={deletingSessionId}
|
||||
initialExpandedSessionId={initialExpandedSessionId}
|
||||
onConsumeInitialExpandedSession={onConsumeInitialExpandedSession}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
stats/src/components/library/MediaHeader.tsx
Normal file
113
stats/src/components/library/MediaHeader.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import type { MediaDetailData } from '../../types/stats';
|
||||
|
||||
interface MediaHeaderProps {
|
||||
detail: NonNullable<MediaDetailData['detail']>;
|
||||
initialKnownWordsSummary?: {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
|
||||
const knownTokenRate =
|
||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||
const avgSessionMs =
|
||||
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
|
||||
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
|
||||
|
||||
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null>(initialKnownWordsSummary);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getMediaKnownWordsSummary(detail.videoId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setKnownWordsSummary(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setKnownWordsSummary(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detail.videoId]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<CoverImage
|
||||
videoId={detail.videoId}
|
||||
title={detail.canonicalTitle}
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">total watch time</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-cards-mined font-medium">
|
||||
{formatNumber(detail.totalCards)}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">cards mined</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalTokensSeen)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">word occurrences</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-lavender font-medium">
|
||||
{formatNumber(detail.totalYomitanLookupCount)}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">Yomitan lookups</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-sapphire font-medium">
|
||||
{lookupRate?.shortValue ?? '\u2014'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{lookupRate?.longValue ?? 'lookup rate'}
|
||||
</div>
|
||||
</div>
|
||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? (
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">
|
||||
{formatNumber(knownWordsSummary.knownWordCount)} /{' '}
|
||||
{formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
known unique words (
|
||||
{Math.round(
|
||||
(knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100,
|
||||
)}
|
||||
%)
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-ctp-peach font-medium">{formatPercent(knownTokenRate)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">known word match rate</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-ctp-text font-medium">{detail.totalSessions}</div>
|
||||
<div className="text-xs text-ctp-overlay2">sessions</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-text font-medium">{formatDuration(avgSessionMs)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">avg session</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
stats/src/components/library/MediaSessionList.tsx
Normal file
64
stats/src/components/library/MediaSessionList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SessionDetail } from '../sessions/SessionDetail';
|
||||
import { SessionRow } from '../sessions/SessionRow';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface MediaSessionListProps {
|
||||
sessions: SessionSummary[];
|
||||
onDeleteSession: (session: SessionSummary) => void;
|
||||
deletingSessionId?: number | null;
|
||||
initialExpandedSessionId?: number | null;
|
||||
onConsumeInitialExpandedSession?: () => void;
|
||||
}
|
||||
|
||||
export function MediaSessionList({
|
||||
sessions,
|
||||
onDeleteSession,
|
||||
deletingSessionId = null,
|
||||
initialExpandedSessionId = null,
|
||||
onConsumeInitialExpandedSession,
|
||||
}: MediaSessionListProps) {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(initialExpandedSessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialExpandedSessionId == null) return;
|
||||
if (!sessions.some((session) => session.sessionId === initialExpandedSessionId)) return;
|
||||
setExpandedId(initialExpandedSessionId);
|
||||
onConsumeInitialExpandedSession?.();
|
||||
}, [initialExpandedSessionId, onConsumeInitialExpandedSession, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expandedId == null) return;
|
||||
if (sessions.some((session) => session.sessionId === expandedId)) return;
|
||||
setExpandedId(null);
|
||||
}, [expandedId, sessions]);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return <div className="text-sm text-ctp-overlay2">No sessions recorded</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Session History</h3>
|
||||
{sessions.map((s) => (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={`media-session-details-${s.sessionId}`}
|
||||
onToggle={() =>
|
||||
setExpandedId((current) => (current === s.sessionId ? null : s.sessionId))
|
||||
}
|
||||
onDelete={() => onDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
/>
|
||||
{expandedId === s.sessionId ? (
|
||||
<div id={`media-session-details-${s.sessionId}`}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
stats/src/components/library/MediaWatchChart.tsx
Normal file
89
stats/src/components/library/MediaWatchChart.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface MediaWatchChartProps {
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
export function MediaWatchChart({ rollups }: MediaWatchChartProps) {
|
||||
const [range, setRange] = useState<Range>(30);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
stats/src/components/overview/HeroStats.tsx
Normal file
45
stats/src/components/overview/HeroStats.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { StatCard } from '../layout/StatCard';
|
||||
import { formatDuration, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface HeroStatsProps {
|
||||
summary: OverviewSummary;
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
|
||||
export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
||||
const today = todayLocalDay();
|
||||
const sessionsToday = sessions.filter((s) => localDayFromMs(s.startedAtMs) === today).length;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 xl:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
label="Watch Time Today"
|
||||
value={formatDuration(summary.todayActiveMs)}
|
||||
color="text-ctp-blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Cards Mined Today"
|
||||
value={formatNumber(summary.todayCards)}
|
||||
color="text-ctp-cards-mined"
|
||||
/>
|
||||
<StatCard
|
||||
label="Sessions Today"
|
||||
value={formatNumber(sessionsToday)}
|
||||
color="text-ctp-lavender"
|
||||
/>
|
||||
<StatCard
|
||||
label="Episodes Today"
|
||||
value={formatNumber(summary.episodesToday)}
|
||||
color="text-ctp-teal"
|
||||
/>
|
||||
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
|
||||
<StatCard
|
||||
label="Active Anime"
|
||||
value={formatNumber(summary.activeAnimeCount)}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
stats/src/components/overview/OverviewTab.tsx
Normal file
158
stats/src/components/overview/OverviewTab.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useOverview } from '../../hooks/useOverview';
|
||||
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
|
||||
import { HeroStats } from './HeroStats';
|
||||
import { StreakCalendar } from './StreakCalendar';
|
||||
import { RecentSessions } from './RecentSessions';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import {
|
||||
confirmSessionDelete,
|
||||
confirmDayGroupDelete,
|
||||
confirmAnimeGroupDelete,
|
||||
} from '../../lib/delete-confirm';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface OverviewTabProps {
|
||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
}
|
||||
|
||||
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
|
||||
const { data, sessions, setSessions, loading, error } = useOverview();
|
||||
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
|
||||
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getKnownWordsSummary()
|
||||
.then((data) => {
|
||||
if (!cancelled) setKnownWordsSummary(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setKnownWordsSummary(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
setDeleteError(null);
|
||||
setDeletingIds((prev) => new Set(prev).add(session.sessionId));
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(session.sessionId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
|
||||
if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return;
|
||||
setDeleteError(null);
|
||||
const ids = daySessions.map((s) => s.sessionId);
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) next.add(id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await apiClient.deleteSessions(ids);
|
||||
const idSet = new Set(ids);
|
||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
} finally {
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
|
||||
const title =
|
||||
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
|
||||
if (!confirmAnimeGroupDelete(title, groupSessions.length)) return;
|
||||
setDeleteError(null);
|
||||
const ids = groupSessions.map((s) => s.sessionId);
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) next.add(id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await apiClient.deleteSessions(ids);
|
||||
const idSet = new Set(ids);
|
||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
} finally {
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
const summary = buildOverviewSummary(data);
|
||||
const streakData = buildStreakCalendar(calendar);
|
||||
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<HeroStats summary={summary} sessions={sessions} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TrendChart
|
||||
title="Last 14 Days Watch Time (min)"
|
||||
data={summary.recentWatchTime}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
{!calLoading && <StreakCalendar data={streakData} />}
|
||||
</div>
|
||||
|
||||
<TrackingSnapshot
|
||||
summary={summary}
|
||||
showTrackedCardNote={showTrackedCardNote}
|
||||
knownWordsSummary={knownWordsSummary}
|
||||
/>
|
||||
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
|
||||
<RecentSessions
|
||||
sessions={sessions}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onDeleteDayGroup={handleDeleteDayGroup}
|
||||
onDeleteAnimeGroup={handleDeleteAnimeGroup}
|
||||
deletingIds={deletingIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
stats/src/components/overview/QuickStats.tsx
Normal file
46
stats/src/components/overview/QuickStats.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { todayLocalDay } from '../../lib/formatters';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface QuickStatsProps {
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
export function QuickStats({ rollups }: QuickStatsProps) {
|
||||
const daysWithActivity = new Set(
|
||||
rollups.filter((r) => r.totalActiveMin > 0).map((r) => r.rollupDayOrMonth),
|
||||
);
|
||||
const today = todayLocalDay();
|
||||
const streakStart = daysWithActivity.has(today) ? today : today - 1;
|
||||
let streak = 0;
|
||||
for (let d = streakStart; daysWithActivity.has(d); d--) {
|
||||
streak++;
|
||||
}
|
||||
|
||||
const weekStart = today - 6;
|
||||
const weekRollups = rollups.filter((r) => r.rollupDayOrMonth >= weekStart);
|
||||
const weekMinutes = weekRollups.reduce((sum, r) => sum + r.totalActiveMin, 0);
|
||||
const weekCards = weekRollups.reduce((sum, r) => sum + r.totalCards, 0);
|
||||
const avgMinPerDay = Math.round(weekMinutes / 7);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Quick Stats</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ctp-subtext0">Streak</span>
|
||||
<span className="text-ctp-peach font-medium">
|
||||
{streak} day{streak !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ctp-subtext0">Avg/day this week</span>
|
||||
<span className="text-ctp-text">{avgMinPerDay}m</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ctp-subtext0">Cards this week</span>
|
||||
<span className="text-ctp-cards-mined font-medium">{weekCards}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
433
stats/src/components/overview/RecentSessions.tsx
Normal file
433
stats/src/components/overview/RecentSessions.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
formatDuration,
|
||||
formatRelativeDate,
|
||||
formatNumber,
|
||||
formatSessionDayLabel,
|
||||
} from '../../lib/formatters';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface RecentSessionsProps {
|
||||
sessions: SessionSummary[];
|
||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
onDeleteSession: (session: SessionSummary) => void;
|
||||
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
|
||||
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
|
||||
deletingIds: Set<number>;
|
||||
}
|
||||
|
||||
interface AnimeGroup {
|
||||
key: string;
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
videoId: number | null;
|
||||
sessions: SessionSummary[];
|
||||
totalCards: number;
|
||||
totalWords: number;
|
||||
totalActiveMs: number;
|
||||
totalKnownWords: number;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
const groups = new Map<string, SessionSummary[]>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const dayLabel = formatSessionDayLabel(session.startedAtMs);
|
||||
const group = groups.get(dayLabel);
|
||||
if (group) {
|
||||
group.push(session);
|
||||
} else {
|
||||
groups.set(dayLabel, [session]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
const map = new Map<string, AnimeGroup>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key =
|
||||
session.animeId != null
|
||||
? `anime-${session.animeId}`
|
||||
: session.videoId != null
|
||||
? `video-${session.videoId}`
|
||||
: `session-${session.sessionId}`;
|
||||
|
||||
const existing = map.get(key);
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
if (existing) {
|
||||
existing.sessions.push(session);
|
||||
existing.totalCards += session.cardsMined;
|
||||
existing.totalWords += displayWordCount;
|
||||
existing.totalActiveMs += session.activeWatchedMs;
|
||||
existing.totalKnownWords += session.knownWordsSeen;
|
||||
} else {
|
||||
map.set(key, {
|
||||
key,
|
||||
animeId: session.animeId,
|
||||
animeTitle: session.animeTitle,
|
||||
videoId: session.videoId,
|
||||
sessions: [session],
|
||||
totalCards: session.cardsMined,
|
||||
totalWords: displayWordCount,
|
||||
totalActiveMs: session.activeWatchedMs,
|
||||
totalKnownWords: session.knownWordsSeen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function CoverThumbnail({
|
||||
animeId,
|
||||
videoId,
|
||||
title,
|
||||
}: {
|
||||
animeId: number | null;
|
||||
videoId: number | null;
|
||||
title: string;
|
||||
}) {
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
const [isFallback, setIsFallback] = useState(false);
|
||||
|
||||
if ((!animeId && !videoId) || isFallback) {
|
||||
return (
|
||||
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src =
|
||||
animeId != null
|
||||
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
|
||||
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
|
||||
onError={() => setIsFallback(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
onNavigateToMediaDetail,
|
||||
onNavigateToSession,
|
||||
onDelete,
|
||||
deleteDisabled,
|
||||
}: {
|
||||
session: SessionSummary;
|
||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
onDelete: () => void;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
const navigationTarget = getSessionNavigationTarget(session);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigationTarget.type === 'media-detail') {
|
||||
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
|
||||
return;
|
||||
}
|
||||
onNavigateToSession(navigationTarget.sessionId);
|
||||
}}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={session.animeId}
|
||||
videoId={session.videoId}
|
||||
title={session.canonicalTitle ?? 'Unknown'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{session.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.cardsMined)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(displayWordCount)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleteDisabled}
|
||||
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Delete session"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimeGroupRow({
|
||||
group,
|
||||
onNavigateToMediaDetail,
|
||||
onNavigateToSession,
|
||||
onDeleteSession,
|
||||
onDeleteAnimeGroup,
|
||||
deletingIds,
|
||||
}: {
|
||||
group: AnimeGroup;
|
||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
onDeleteSession: (session: SessionSummary) => void;
|
||||
onDeleteAnimeGroup: (group: AnimeGroup) => void;
|
||||
deletingIds: Set<number>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
|
||||
|
||||
if (group.sessions.length === 1) {
|
||||
const s = group.sessions[0]!;
|
||||
return (
|
||||
<SessionItem
|
||||
session={s}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onDelete={() => onDeleteSession(s)}
|
||||
deleteDisabled={deletingIds.has(s.sessionId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media';
|
||||
const mostRecentSession = group.sessions[0]!;
|
||||
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
|
||||
|
||||
return (
|
||||
<div className="group/anime">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={disclosureId}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={group.animeId}
|
||||
videoId={mostRecentSession.videoId}
|
||||
title={displayTitle}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalCards)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalWords)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalKnownWords)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{'\u25B8'}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteAnimeGroup(group)}
|
||||
disabled={groupDeleting}
|
||||
aria-label={`Delete all sessions for ${displayTitle}`}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/anime:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={`Delete all sessions for ${displayTitle}`}
|
||||
>
|
||||
{groupDeleting ? '\u2026' : '\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div
|
||||
id={disclosureId}
|
||||
role="region"
|
||||
aria-label={`${displayTitle} sessions`}
|
||||
className="ml-6 mt-1 space-y-1"
|
||||
>
|
||||
{group.sessions.map((s) => {
|
||||
const navigationTarget = getSessionNavigationTarget(s);
|
||||
|
||||
return (
|
||||
<div key={s.sessionId} className="relative group/nested">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigationTarget.type === 'media-detail') {
|
||||
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
|
||||
return;
|
||||
}
|
||||
onNavigateToSession(navigationTarget.sessionId);
|
||||
}}
|
||||
className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 pr-10 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={s.animeId}
|
||||
videoId={s.videoId}
|
||||
title={s.canonicalTitle ?? 'Unknown'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
||||
{s.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.cardsMined)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(getSessionDisplayWordCount(s))}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSession(s)}
|
||||
disabled={deletingIds.has(s.sessionId)}
|
||||
aria-label={`Delete session ${s.canonicalTitle ?? 'Unknown Media'}`}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/nested:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Delete session"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentSessions({
|
||||
sessions,
|
||||
onNavigateToMediaDetail,
|
||||
onNavigateToSession,
|
||||
onDeleteSession,
|
||||
onDeleteDayGroup,
|
||||
onDeleteAnimeGroup,
|
||||
deletingIds,
|
||||
}: RecentSessionsProps) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="text-sm text-ctp-overlay2">No sessions yet</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupSessionsByDay(sessions);
|
||||
const anyDeleting = deletingIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
|
||||
const animeGroups = groupSessionsByAnime(daySessions);
|
||||
const groupDeleting = daySessions.some((s) => deletingIds.has(s.sessionId));
|
||||
return (
|
||||
<div key={dayLabel} className="group/day">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDayGroup(dayLabel, daySessions)}
|
||||
disabled={anyDeleting}
|
||||
aria-label={`Delete all sessions from ${dayLabel}`}
|
||||
className="shrink-0 text-xs text-transparent hover:text-ctp-red transition-colors opacity-0 group-hover/day:opacity-100 focus:opacity-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={`Delete all sessions from ${dayLabel}`}
|
||||
>
|
||||
{groupDeleting ? '\u2026' : '\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{animeGroups.map((group) => (
|
||||
<AnimeGroupRow
|
||||
key={group.key}
|
||||
group={group}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
|
||||
deletingIds={deletingIds}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
stats/src/components/overview/StreakCalendar.tsx
Normal file
96
stats/src/components/overview/StreakCalendar.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import type { StreakCalendarPoint } from '../../lib/dashboard-data';
|
||||
|
||||
interface StreakCalendarProps {
|
||||
data: StreakCalendarPoint[];
|
||||
}
|
||||
|
||||
function intensityClass(value: number): string {
|
||||
if (value === 0) return 'bg-ctp-surface0';
|
||||
if (value <= 30) return 'bg-ctp-green/30';
|
||||
if (value <= 60) return 'bg-ctp-green/60';
|
||||
return 'bg-ctp-green';
|
||||
}
|
||||
|
||||
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
||||
|
||||
export function StreakCalendar({ data }: StreakCalendarProps) {
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
|
||||
const lookup = new Map(data.map((d) => [d.date, d.value]));
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(today);
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 89);
|
||||
|
||||
const startDow = (startDate.getDay() + 6) % 7;
|
||||
|
||||
const cells: Array<{ date: string; value: number; row: number; col: number }> = [];
|
||||
let col = 0;
|
||||
let row = startDow;
|
||||
|
||||
const cursor = new Date(startDate);
|
||||
while (cursor <= endDate) {
|
||||
const dateStr = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`;
|
||||
cells.push({ date: dateStr, value: lookup.get(dateStr) ?? 0, row, col });
|
||||
|
||||
row += 1;
|
||||
if (row >= 7) {
|
||||
row = 0;
|
||||
col += 1;
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalCols = col + (row > 0 ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Activity (90 days)</h3>
|
||||
<div className="relative flex gap-1">
|
||||
<div className="flex flex-col gap-1 text-[10px] text-ctp-overlay2 pr-1 shrink-0">
|
||||
{DAY_LABELS.map((label, i) => (
|
||||
<div key={i} className="h-3 flex items-center leading-none">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-[3px]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${totalCols}, 12px)`,
|
||||
gridTemplateRows: 'repeat(7, 12px)',
|
||||
}}
|
||||
>
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.date}
|
||||
className={`w-3 h-3 rounded-sm ${intensityClass(cell.value)} cursor-default`}
|
||||
style={{ gridRow: cell.row + 1, gridColumn: cell.col + 1 }}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 4,
|
||||
text: `${cell.date}: ${Math.round(cell.value * 100) / 100}m`,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 px-2 py-1 text-xs bg-ctp-crust text-ctp-text rounded shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full"
|
||||
style={{ left: tooltip.x, top: tooltip.y }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
stats/src/components/overview/TrackingSnapshot.test.tsx
Normal file
47
stats/src/components/overview/TrackingSnapshot.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
|
||||
const summary: OverviewSummary = {
|
||||
todayActiveMs: 0,
|
||||
todayCards: 0,
|
||||
streakDays: 0,
|
||||
allTimeMinutes: 120,
|
||||
totalTrackedCards: 9,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
averageSessionMinutes: 40,
|
||||
activeDays: 12,
|
||||
totalSessions: 15,
|
||||
lookupRate: {
|
||||
shortValue: '2.3 / 100 words',
|
||||
longValue: '2.3 lookups per 100 words',
|
||||
},
|
||||
todayTokens: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
recentWatchTime: [],
|
||||
};
|
||||
|
||||
test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Lookup Rate/);
|
||||
assert.match(markup, /2\.3 \/ 100 words/);
|
||||
assert.match(markup, /Lifetime Yomitan lookups normalized by total words seen/);
|
||||
});
|
||||
|
||||
test('TrackingSnapshot labels new words as unique headwords', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Unique headwords seen for the first time today/);
|
||||
assert.match(markup, /Unique headwords seen for the first time this week/);
|
||||
});
|
||||
149
stats/src/components/overview/TrackingSnapshot.tsx
Normal file
149
stats/src/components/overview/TrackingSnapshot.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { Tooltip } from '../layout/Tooltip';
|
||||
|
||||
interface KnownWordsSummary {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
}
|
||||
|
||||
interface TrackingSnapshotProps {
|
||||
summary: OverviewSummary;
|
||||
showTrackedCardNote?: boolean;
|
||||
knownWordsSummary: KnownWordsSummary | null;
|
||||
}
|
||||
|
||||
export function TrackingSnapshot({
|
||||
summary,
|
||||
showTrackedCardNote = false,
|
||||
knownWordsSummary,
|
||||
}: TrackingSnapshotProps) {
|
||||
const knownWordPercent =
|
||||
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
|
||||
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Tracking Snapshot</h3>
|
||||
<p className="mt-1 mb-3 text-xs text-ctp-overlay2">
|
||||
Lifetime totals sourced from summary tables.
|
||||
</p>
|
||||
{showTrackedCardNote && (
|
||||
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
|
||||
No lifetime card totals in the summary table yet. New cards mined after this fix will
|
||||
appear here.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<Tooltip text="Total immersion sessions recorded across all time">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Sessions</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total active watch time across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Watch Time</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-mauve">
|
||||
{summary.allTimeMinutes < 60
|
||||
? `${summary.allTimeMinutes}m`
|
||||
: `${(summary.allTimeMinutes / 60).toFixed(1)}h`}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of distinct days with at least one session">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Average active watch time per session in minutes">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Avg Session</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-yellow">
|
||||
{formatNumber(summary.averageSessionMinutes)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-0.5">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total unique episodes (videos) watched across all anime">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Number of anime series fully completed">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Anime</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sapphire">
|
||||
{formatNumber(summary.totalAnimeCompleted)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
|
||||
{formatNumber(summary.totalTrackedCards)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Lifetime Yomitan lookups normalized by total words seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Lookup Rate</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-flamingo">
|
||||
{summary.lookupRate?.shortValue ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Total word occurrences encountered in today's sessions">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
|
||||
{formatNumber(summary.todayTokens)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique headwords seen for the first time today">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words Today</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-rosewater">
|
||||
{formatNumber(summary.newWordsToday)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip text="Unique headwords seen for the first time this week">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">New Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-pink">
|
||||
{formatNumber(summary.newWordsThisWeek)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 && (
|
||||
<Tooltip text="Words matched against your known-words list out of all unique words seen">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Known Words</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
|
||||
{formatNumber(knownWordsSummary.knownWordCount)}
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">
|
||||
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||
</span>
|
||||
{knownWordPercent != null ? (
|
||||
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
stats/src/components/overview/WatchTimeChart.tsx
Normal file
85
stats/src/components/overview/WatchTimeChart.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface WatchTimeChartProps {
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string, _name?: string, _payload?: unknown) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
|
||||
const [range, setRange] = useState<Range>(14);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([dayA], [dayB]) => dayA - dayB)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
|
||||
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
827
stats/src/components/sessions/SessionDetail.tsx
Normal file
827
stats/src/components/sessions/SessionDetail.tsx
Normal file
@@ -0,0 +1,827 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
CartesianGrid,
|
||||
Customized,
|
||||
} from 'recharts';
|
||||
import { useSessionDetail } from '../../hooks/useSessions';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import {
|
||||
buildSessionChartEvents,
|
||||
collectPendingSessionEventNoteIds,
|
||||
getSessionEventCardRequest,
|
||||
mergeSessionEventNoteInfos,
|
||||
resolveActiveSessionMarkerKey,
|
||||
type SessionChartMarker,
|
||||
type SessionEventNoteInfo,
|
||||
type SessionChartPlotArea,
|
||||
} from '../../lib/session-events';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { EventType } from '../../types/stats';
|
||||
import type { SessionEvent, SessionSummary } from '../../types/stats';
|
||||
import { SessionEventOverlay } from './SessionEventOverlay';
|
||||
|
||||
interface SessionDetailProps {
|
||||
session: SessionSummary;
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a lookup: linesSeen → knownWordsSeen */
|
||||
function buildKnownWordsLookup(knownWordsTimeline: KnownWordsTimelinePoint[]): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
for (const pt of knownWordsTimeline) {
|
||||
map.set(pt.linesSeen, pt.knownWordsSeen);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** For a given linesSeen value, find the closest known words count (floor lookup). */
|
||||
function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
|
||||
if (map.size === 0) return 0;
|
||||
if (map.has(linesSeen)) return map.get(linesSeen)!;
|
||||
let best = 0;
|
||||
for (const k of map.keys()) {
|
||||
if (k <= linesSeen && k > best) {
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
return best > 0 ? map.get(best)! : 0;
|
||||
}
|
||||
|
||||
interface RatioChartPoint {
|
||||
tsMs: number;
|
||||
knownWords: number;
|
||||
unknownWords: number;
|
||||
totalWords: number;
|
||||
}
|
||||
|
||||
interface FallbackChartPoint {
|
||||
tsMs: number;
|
||||
totalWords: number;
|
||||
}
|
||||
|
||||
type TimelineEntry = {
|
||||
sampleMs: number;
|
||||
linesSeen: number;
|
||||
tokensSeen: number;
|
||||
};
|
||||
|
||||
function SessionChartOffsetProbe({
|
||||
offset,
|
||||
onPlotAreaChange,
|
||||
}: {
|
||||
offset?: { left?: number; width?: number };
|
||||
onPlotAreaChange: (plotArea: SessionChartPlotArea) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!offset) return;
|
||||
const { left, width } = offset;
|
||||
if (typeof left !== 'number' || !Number.isFinite(left)) return;
|
||||
if (typeof width !== 'number' || !Number.isFinite(width)) return;
|
||||
onPlotAreaChange({ left, width });
|
||||
}, [offset?.left, offset?.width, onPlotAreaChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SessionDetail({ session }: SessionDetailProps) {
|
||||
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
|
||||
session.sessionId,
|
||||
);
|
||||
const [hoveredMarkerKey, setHoveredMarkerKey] = useState<string | null>(null);
|
||||
const [pinnedMarkerKey, setPinnedMarkerKey] = useState<string | null>(null);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
|
||||
const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set());
|
||||
const pendingNoteIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const sorted = [...timeline].reverse();
|
||||
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
|
||||
const hasKnownWords = knownWordsMap.size > 0;
|
||||
|
||||
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
|
||||
buildSessionChartEvents(events);
|
||||
const lookupRate = buildLookupRateDisplay(
|
||||
session.yomitanLookupCount,
|
||||
getSessionDisplayWordCount(session),
|
||||
);
|
||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||
const seekCount = seekEvents.length;
|
||||
const cardEventCount = cardEvents.length;
|
||||
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
||||
const activeMarker = useMemo<SessionChartMarker | null>(
|
||||
() => markers.find((marker) => marker.key === activeMarkerKey) ?? null,
|
||||
[markers, activeMarkerKey],
|
||||
);
|
||||
const activeCardRequest = useMemo(
|
||||
() => getSessionEventCardRequest(activeMarker),
|
||||
[activeMarkerKey, markers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCardRequest.requestKey || activeCardRequest.noteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingNoteIds = collectPendingSessionEventNoteIds(
|
||||
activeCardRequest.noteIds,
|
||||
noteInfos,
|
||||
pendingNoteIdsRef.current,
|
||||
);
|
||||
if (missingNoteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const noteId of missingNoteIds) {
|
||||
pendingNoteIdsRef.current.add(noteId);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingNoteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const noteId of missingNoteIds) {
|
||||
next.add(noteId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
getStatsClient()
|
||||
.ankiNotesInfo(missingNoteIds)
|
||||
.then((notes) => {
|
||||
if (cancelled) return;
|
||||
setNoteInfos((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [noteId, info] of mergeSessionEventNoteInfos(missingNoteIds, notes)) {
|
||||
next.set(noteId, info);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to fetch session event Anki note info:', err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
for (const noteId of missingNoteIds) {
|
||||
pendingNoteIdsRef.current.delete(noteId);
|
||||
}
|
||||
setLoadingNoteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const noteId of missingNoteIds) {
|
||||
next.delete(noteId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const noteId of missingNoteIds) {
|
||||
pendingNoteIdsRef.current.delete(noteId);
|
||||
}
|
||||
setLoadingNoteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const noteId of missingNoteIds) {
|
||||
next.delete(noteId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, [activeCardRequest.requestKey, noteInfos]);
|
||||
|
||||
const handleOpenNote = (noteId: number) => {
|
||||
void getStatsClient().ankiBrowse(noteId);
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
|
||||
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
|
||||
|
||||
if (hasKnownWords) {
|
||||
return (
|
||||
<RatioView
|
||||
sorted={sorted}
|
||||
knownWordsMap={knownWordsMap}
|
||||
cardEvents={cardEvents}
|
||||
seekEvents={seekEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
markers={markers}
|
||||
hoveredMarkerKey={hoveredMarkerKey}
|
||||
onHoveredMarkerChange={setHoveredMarkerKey}
|
||||
pinnedMarkerKey={pinnedMarkerKey}
|
||||
onPinnedMarkerChange={setPinnedMarkerKey}
|
||||
noteInfos={noteInfos}
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={handleOpenNote}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FallbackView
|
||||
sorted={sorted}
|
||||
cardEvents={cardEvents}
|
||||
seekEvents={seekEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
markers={markers}
|
||||
hoveredMarkerKey={hoveredMarkerKey}
|
||||
onHoveredMarkerChange={setHoveredMarkerKey}
|
||||
pinnedMarkerKey={pinnedMarkerKey}
|
||||
onPinnedMarkerChange={setPinnedMarkerKey}
|
||||
noteInfos={noteInfos}
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={handleOpenNote}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ratio View (primary design) ────────────────────────────────── */
|
||||
|
||||
function RatioView({
|
||||
sorted,
|
||||
knownWordsMap,
|
||||
cardEvents,
|
||||
seekEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
markers,
|
||||
hoveredMarkerKey,
|
||||
onHoveredMarkerChange,
|
||||
pinnedMarkerKey,
|
||||
onPinnedMarkerChange,
|
||||
noteInfos,
|
||||
loadingNoteIds,
|
||||
onOpenNote,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
}: {
|
||||
sorted: TimelineEntry[];
|
||||
knownWordsMap: Map<number, number>;
|
||||
cardEvents: SessionEvent[];
|
||||
seekEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||
markers: SessionChartMarker[];
|
||||
hoveredMarkerKey: string | null;
|
||||
onHoveredMarkerChange: (markerKey: string | null) => void;
|
||||
pinnedMarkerKey: string | null;
|
||||
onPinnedMarkerChange: (markerKey: string | null) => void;
|
||||
noteInfos: Map<number, SessionEventNoteInfo>;
|
||||
loadingNoteIds: Set<number>;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
}) {
|
||||
const [plotArea, setPlotArea] = useState<SessionChartPlotArea | null>(null);
|
||||
const chartData: RatioChartPoint[] = [];
|
||||
for (const t of sorted) {
|
||||
const totalWords = getSessionDisplayWordCount(t);
|
||||
if (totalWords === 0) continue;
|
||||
const knownWords = Math.min(lookupKnownWords(knownWordsMap, t.linesSeen), totalWords);
|
||||
const unknownWords = totalWords - knownWords;
|
||||
chartData.push({
|
||||
tsMs: t.sampleMs,
|
||||
knownWords,
|
||||
unknownWords,
|
||||
totalWords,
|
||||
});
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
|
||||
}
|
||||
|
||||
const tsMin = chartData[0]!.tsMs;
|
||||
const tsMax = chartData[chartData.length - 1]!.tsMs;
|
||||
const finalTotal = chartData[chartData.length - 1]!.totalWords;
|
||||
|
||||
const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords }));
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
|
||||
{/* ── Top: Percentage area chart ── */}
|
||||
<div className="relative">
|
||||
<ResponsiveContainer width="100%" height={130}>
|
||||
<AreaChart data={chartData}>
|
||||
<Customized
|
||||
component={
|
||||
<SessionChartOffsetProbe
|
||||
onPlotAreaChange={(nextPlotArea) => {
|
||||
setPlotArea((prevPlotArea) =>
|
||||
prevPlotArea &&
|
||||
prevPlotArea.left === nextPlotArea.left &&
|
||||
prevPlotArea.width === nextPlotArea.width
|
||||
? prevPlotArea
|
||||
: nextPlotArea,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#a6da95" stopOpacity={0.15} />
|
||||
</linearGradient>
|
||||
<linearGradient id={`unknownGrad-${session.sessionId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.08} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid
|
||||
horizontal
|
||||
vertical={false}
|
||||
stroke="#494d64"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.4}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
dataKey="tsMs"
|
||||
type="number"
|
||||
domain={[tsMin, tsMax]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatTime}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="pct"
|
||||
orientation="right"
|
||||
domain={[0, finalTotal]}
|
||||
allowDataOverflow
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
tickFormatter={(v: number) => `${v.toLocaleString()}`}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={formatTime}
|
||||
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
|
||||
const d = props.payload;
|
||||
if (!d) return [_value, name];
|
||||
if (name === 'Known words') {
|
||||
const knownPct = d.totalWords === 0 ? 0 : (d.knownWords / d.totalWords) * 100;
|
||||
return [`${d.knownWords.toLocaleString()} (${knownPct.toFixed(1)}%)`, name];
|
||||
}
|
||||
if (name === 'Unknown words') return [d.unknownWords.toLocaleString(), name];
|
||||
return [_value, name];
|
||||
}}
|
||||
itemSorter={() => -1}
|
||||
/>
|
||||
|
||||
{/* Pause shaded regions */}
|
||||
{pauseRegions.map((r, i) => (
|
||||
<ReferenceArea
|
||||
key={`pause-${i}`}
|
||||
yAxisId="pct"
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
y1={0}
|
||||
y2={finalTotal}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Card mine markers */}
|
||||
{cardEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`card-${i}`}
|
||||
yAxisId="pct"
|
||||
x={e.tsMs}
|
||||
stroke="#a6da95"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.8}
|
||||
/>
|
||||
))}
|
||||
|
||||
{seekEvents.map((e, i) => {
|
||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`seek-${i}`}
|
||||
yAxisId="pct"
|
||||
x={e.tsMs}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.75}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Yomitan lookup markers */}
|
||||
{yomitanLookupEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`yomitan-${i}`}
|
||||
yAxisId="pct"
|
||||
x={e.tsMs}
|
||||
stroke="#b7bdf8"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="2 3"
|
||||
strokeOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="knownWords"
|
||||
stackId="ratio"
|
||||
stroke="#a6da95"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#knownGrad-${session.sessionId})`}
|
||||
name="Known words"
|
||||
type="monotone"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="unknownWords"
|
||||
stackId="ratio"
|
||||
stroke="#c6a0f6"
|
||||
strokeWidth={0}
|
||||
fill={`url(#unknownGrad-${session.sessionId})`}
|
||||
name="Unknown words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<SessionEventOverlay
|
||||
markers={markers}
|
||||
tsMin={tsMin}
|
||||
tsMax={tsMax}
|
||||
plotArea={plotArea}
|
||||
hoveredMarkerKey={hoveredMarkerKey}
|
||||
onHoveredMarkerChange={onHoveredMarkerChange}
|
||||
pinnedMarkerKey={pinnedMarkerKey}
|
||||
onPinnedMarkerChange={onPinnedMarkerChange}
|
||||
noteInfos={noteInfos}
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom: Token accumulation sparkline ── */}
|
||||
<div className="flex items-center gap-2 border-t border-ctp-surface1 pt-1">
|
||||
<span className="text-[9px] text-ctp-overlay0 whitespace-nowrap">total words</span>
|
||||
<div className="flex-1 h-[28px]">
|
||||
<ResponsiveContainer width="100%" height={28}>
|
||||
<LineChart data={sparkData}>
|
||||
<XAxis dataKey="tsMs" type="number" domain={[tsMin, tsMax]} hide />
|
||||
<YAxis hide />
|
||||
<Line
|
||||
dataKey="totalWords"
|
||||
stroke="#8aadf4"
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.8}
|
||||
dot={false}
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<span className="text-[10px] text-ctp-blue font-semibold whitespace-nowrap tabular-nums">
|
||||
{finalTotal.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Stats bar ── */}
|
||||
<StatsBar
|
||||
hasKnownWords
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Fallback View (no known words data) ────────────────────────── */
|
||||
|
||||
function FallbackView({
|
||||
sorted,
|
||||
cardEvents,
|
||||
seekEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
markers,
|
||||
hoveredMarkerKey,
|
||||
onHoveredMarkerChange,
|
||||
pinnedMarkerKey,
|
||||
onPinnedMarkerChange,
|
||||
noteInfos,
|
||||
loadingNoteIds,
|
||||
onOpenNote,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
}: {
|
||||
sorted: TimelineEntry[];
|
||||
cardEvents: SessionEvent[];
|
||||
seekEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: Array<{ startMs: number; endMs: number }>;
|
||||
markers: SessionChartMarker[];
|
||||
hoveredMarkerKey: string | null;
|
||||
onHoveredMarkerChange: (markerKey: string | null) => void;
|
||||
pinnedMarkerKey: string | null;
|
||||
onPinnedMarkerChange: (markerKey: string | null) => void;
|
||||
noteInfos: Map<number, SessionEventNoteInfo>;
|
||||
loadingNoteIds: Set<number>;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
}) {
|
||||
const [plotArea, setPlotArea] = useState<SessionChartPlotArea | null>(null);
|
||||
const chartData: FallbackChartPoint[] = [];
|
||||
for (const t of sorted) {
|
||||
const totalWords = getSessionDisplayWordCount(t);
|
||||
if (totalWords === 0) continue;
|
||||
chartData.push({ tsMs: t.sampleMs, totalWords });
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
|
||||
}
|
||||
|
||||
const tsMin = chartData[0]!.tsMs;
|
||||
const tsMax = chartData[chartData.length - 1]!.tsMs;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
|
||||
<div className="relative">
|
||||
<ResponsiveContainer width="100%" height={130}>
|
||||
<LineChart data={chartData}>
|
||||
<Customized
|
||||
component={
|
||||
<SessionChartOffsetProbe
|
||||
onPlotAreaChange={(nextPlotArea) => {
|
||||
setPlotArea((prevPlotArea) =>
|
||||
prevPlotArea &&
|
||||
prevPlotArea.left === nextPlotArea.left &&
|
||||
prevPlotArea.width === nextPlotArea.width
|
||||
? prevPlotArea
|
||||
: nextPlotArea,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="tsMs"
|
||||
type="number"
|
||||
domain={[tsMin, tsMax]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatTime}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={formatTime}
|
||||
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total words']}
|
||||
/>
|
||||
|
||||
{pauseRegions.map((r, i) => (
|
||||
<ReferenceArea
|
||||
key={`pause-${i}`}
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{cardEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`card-${i}`}
|
||||
x={e.tsMs}
|
||||
stroke="#a6da95"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.8}
|
||||
/>
|
||||
))}
|
||||
{seekEvents.map((e, i) => {
|
||||
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
|
||||
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`seek-${i}`}
|
||||
x={e.tsMs}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.75}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{yomitanLookupEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`yomitan-${i}`}
|
||||
x={e.tsMs}
|
||||
stroke="#b7bdf8"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="2 3"
|
||||
strokeOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Line
|
||||
dataKey="totalWords"
|
||||
stroke="#8aadf4"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
name="Total words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<SessionEventOverlay
|
||||
markers={markers}
|
||||
tsMin={tsMin}
|
||||
tsMax={tsMax}
|
||||
plotArea={plotArea}
|
||||
hoveredMarkerKey={hoveredMarkerKey}
|
||||
onHoveredMarkerChange={onHoveredMarkerChange}
|
||||
pinnedMarkerKey={pinnedMarkerKey}
|
||||
onPinnedMarkerChange={onPinnedMarkerChange}
|
||||
noteInfos={noteInfos}
|
||||
loadingNoteIds={loadingNoteIds}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatsBar
|
||||
hasKnownWords={false}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stats Bar ──────────────────────────────────────────────────── */
|
||||
|
||||
function StatsBar({
|
||||
hasKnownWords,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
session,
|
||||
lookupRate,
|
||||
}: {
|
||||
hasKnownWords: boolean;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
session: SessionSummary;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px] pt-1">
|
||||
{/* Group 1: Legend */}
|
||||
{hasKnownWords && (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ background: 'rgba(166,218,149,0.4)', border: '1px solid #a6da95' }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">Known</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ background: 'rgba(198,160,246,0.2)', border: '1px solid #c6a0f6' }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">Unknown</span>
|
||||
</span>
|
||||
<span className="text-ctp-surface2">|</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group 2: Playback stats */}
|
||||
{pauseCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-peach">{pauseCount}</span> pause
|
||||
{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{seekCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
|
||||
|
||||
{/* Group 3: Learning events */}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-0.5 rounded"
|
||||
style={{ background: '#b7bdf8', opacity: 0.8 }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{session.yomitanLookupCount} Yomitan lookup
|
||||
{session.yomitanLookupCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
{lookupRate && (
|
||||
<span className="text-ctp-overlay2">
|
||||
lookup rate: <span className="text-ctp-sapphire">{lookupRate.shortValue}</span>{' '}
|
||||
<span className="text-ctp-subtext0">({lookupRate.longValue})</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[12px]">{'\u26CF'}</span>
|
||||
<span className="text-ctp-cards-mined">
|
||||
{Math.max(cardEventCount, session.cardsMined)} card
|
||||
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
stats/src/components/sessions/SessionEventOverlay.tsx
Normal file
219
stats/src/components/sessions/SessionEventOverlay.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useRef, type FocusEvent, type MouseEvent } from 'react';
|
||||
import {
|
||||
projectSessionMarkerLeftPx,
|
||||
resolveActiveSessionMarkerKey,
|
||||
togglePinnedSessionMarkerKey,
|
||||
type SessionChartMarker,
|
||||
type SessionEventNoteInfo,
|
||||
type SessionChartPlotArea,
|
||||
} from '../../lib/session-events';
|
||||
import { SessionEventPopover } from './SessionEventPopover';
|
||||
|
||||
interface SessionEventOverlayProps {
|
||||
markers: SessionChartMarker[];
|
||||
tsMin: number;
|
||||
tsMax: number;
|
||||
plotArea: SessionChartPlotArea | null;
|
||||
hoveredMarkerKey: string | null;
|
||||
onHoveredMarkerChange: (markerKey: string | null) => void;
|
||||
pinnedMarkerKey: string | null;
|
||||
onPinnedMarkerChange: (markerKey: string | null) => void;
|
||||
noteInfos: Map<number, SessionEventNoteInfo>;
|
||||
loadingNoteIds: Set<number>;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
}
|
||||
|
||||
function toPercent(tsMs: number, tsMin: number, tsMax: number): number {
|
||||
if (tsMax <= tsMin) return 50;
|
||||
const ratio = ((tsMs - tsMin) / (tsMax - tsMin)) * 100;
|
||||
return Math.max(0, Math.min(100, ratio));
|
||||
}
|
||||
|
||||
function markerLabel(marker: SessionChartMarker): string {
|
||||
switch (marker.kind) {
|
||||
case 'pause':
|
||||
return '||';
|
||||
case 'seek':
|
||||
return marker.direction === 'backward' ? '<<' : '>>';
|
||||
case 'card':
|
||||
return '\u26CF';
|
||||
}
|
||||
}
|
||||
|
||||
function markerColors(marker: SessionChartMarker): { border: string; bg: string; text: string } {
|
||||
switch (marker.kind) {
|
||||
case 'pause':
|
||||
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
||||
case 'seek':
|
||||
return marker.direction === 'backward'
|
||||
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
|
||||
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
|
||||
case 'card':
|
||||
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
||||
}
|
||||
}
|
||||
|
||||
function popupAlignment(percent: number): string {
|
||||
if (percent <= 15) return 'left-0 translate-x-0';
|
||||
if (percent >= 85) return 'right-0 translate-x-0';
|
||||
return 'left-1/2 -translate-x-1/2';
|
||||
}
|
||||
|
||||
function handleWrapperBlur(
|
||||
event: FocusEvent<HTMLDivElement>,
|
||||
onHoveredMarkerChange: (markerKey: string | null) => void,
|
||||
pinnedMarkerKey: string | null,
|
||||
markerKey: string,
|
||||
): void {
|
||||
if (pinnedMarkerKey === markerKey) return;
|
||||
const nextFocused = event.relatedTarget;
|
||||
if (nextFocused instanceof Node && event.currentTarget.contains(nextFocused)) {
|
||||
return;
|
||||
}
|
||||
onHoveredMarkerChange(null);
|
||||
}
|
||||
|
||||
function handleWrapperMouseLeave(
|
||||
event: MouseEvent<HTMLDivElement>,
|
||||
onHoveredMarkerChange: (markerKey: string | null) => void,
|
||||
pinnedMarkerKey: string | null,
|
||||
markerKey: string,
|
||||
): void {
|
||||
if (pinnedMarkerKey === markerKey) return;
|
||||
const nextHovered = event.relatedTarget;
|
||||
if (nextHovered instanceof Node && event.currentTarget.contains(nextHovered)) {
|
||||
return;
|
||||
}
|
||||
onHoveredMarkerChange(null);
|
||||
}
|
||||
|
||||
export function SessionEventOverlay({
|
||||
markers,
|
||||
tsMin,
|
||||
tsMax,
|
||||
plotArea,
|
||||
hoveredMarkerKey,
|
||||
onHoveredMarkerChange,
|
||||
pinnedMarkerKey,
|
||||
onPinnedMarkerChange,
|
||||
noteInfos,
|
||||
loadingNoteIds,
|
||||
onOpenNote,
|
||||
}: SessionEventOverlayProps) {
|
||||
if (markers.length === 0) return null;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pinnedMarkerKey) return;
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent): void {
|
||||
if (rootRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
onPinnedMarkerChange(null);
|
||||
onHoveredMarkerChange(null);
|
||||
}
|
||||
|
||||
function handleDocumentKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return;
|
||||
onPinnedMarkerChange(null);
|
||||
onHoveredMarkerChange(null);
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown);
|
||||
document.addEventListener('keydown', handleDocumentKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown);
|
||||
document.removeEventListener('keydown', handleDocumentKeyDown);
|
||||
};
|
||||
}, [pinnedMarkerKey, onHoveredMarkerChange, onPinnedMarkerChange]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="pointer-events-none absolute inset-0 z-30 overflow-visible">
|
||||
{markers.map((marker) => {
|
||||
const percent = toPercent(marker.anchorTsMs, tsMin, tsMax);
|
||||
const left = plotArea
|
||||
? `${projectSessionMarkerLeftPx({
|
||||
anchorTsMs: marker.anchorTsMs,
|
||||
tsMin,
|
||||
tsMax,
|
||||
plotLeftPx: plotArea.left,
|
||||
plotWidthPx: plotArea.width,
|
||||
})}px`
|
||||
: `${percent}%`;
|
||||
const colors = markerColors(marker);
|
||||
const isActive = marker.key === activeMarkerKey;
|
||||
const isPinned = marker.key === pinnedMarkerKey;
|
||||
const loading =
|
||||
marker.kind === 'card' && marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={marker.key}
|
||||
className="pointer-events-auto absolute top-0 -translate-x-1/2 pt-1"
|
||||
style={{ left }}
|
||||
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
|
||||
onMouseLeave={(event) =>
|
||||
handleWrapperMouseLeave(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key)
|
||||
}
|
||||
onFocusCapture={() => onHoveredMarkerChange(marker.key)}
|
||||
onBlurCapture={(event) =>
|
||||
handleWrapperBlur(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key)
|
||||
}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Show ${marker.kind} event details`}
|
||||
aria-pressed={isPinned}
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm"
|
||||
style={{
|
||||
borderColor: colors.border,
|
||||
background: colors.bg,
|
||||
color: colors.text,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onHoveredMarkerChange(marker.key);
|
||||
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
|
||||
}}
|
||||
>
|
||||
{markerLabel(marker)}
|
||||
</button>
|
||||
{isActive ? (
|
||||
<div
|
||||
className={`pointer-events-auto absolute top-5 z-50 pt-2 ${popupAlignment(percent)}`}
|
||||
onMouseDownCapture={() => {
|
||||
if (!isPinned) {
|
||||
onPinnedMarkerChange(marker.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={noteInfos}
|
||||
loading={loading}
|
||||
pinned={isPinned}
|
||||
onTogglePinned={() =>
|
||||
onPinnedMarkerChange(
|
||||
togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key),
|
||||
)
|
||||
}
|
||||
onClose={() => {
|
||||
onPinnedMarkerChange(null);
|
||||
onHoveredMarkerChange(null);
|
||||
}}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
stats/src/components/sessions/SessionEventPopover.test.tsx
Normal file
150
stats/src/components/sessions/SessionEventPopover.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { SessionChartMarker } from '../../lib/session-events';
|
||||
import { SessionEventPopover } from './SessionEventPopover';
|
||||
|
||||
test('SessionEventPopover renders formatted card-mine details with fetched note info', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'card-6000',
|
||||
kind: 'card',
|
||||
anchorTsMs: 6_000,
|
||||
eventTsMs: 6_000,
|
||||
noteIds: [11, 22],
|
||||
cardsDelta: 2,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={
|
||||
new Map([
|
||||
[11, { noteId: 11, expression: '冒険者', context: '駆け出しの冒険者だ', meaning: null }],
|
||||
[22, { noteId: 22, expression: '呪い', context: null, meaning: 'curse' }],
|
||||
])
|
||||
}
|
||||
loading={false}
|
||||
pinned={false}
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Card mined/);
|
||||
assert.match(markup, /\+2 cards/);
|
||||
assert.match(markup, /冒険者/);
|
||||
assert.match(markup, /呪い/);
|
||||
assert.match(markup, /駆け出しの冒険者だ/);
|
||||
assert.match(markup, /curse/);
|
||||
assert.match(markup, /Pin/);
|
||||
assert.match(markup, /Open in Anki/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover renders seek metadata compactly', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'seek-3000',
|
||||
kind: 'seek',
|
||||
anchorTsMs: 3_000,
|
||||
eventTsMs: 3_000,
|
||||
direction: 'backward',
|
||||
fromMs: 5_000,
|
||||
toMs: 1_500,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading={false}
|
||||
pinned={false}
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Seek backward/);
|
||||
assert.match(markup, /5\.0s/);
|
||||
assert.match(markup, /1\.5s/);
|
||||
assert.match(markup, /3\.5s/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'card-9000',
|
||||
kind: 'card',
|
||||
anchorTsMs: 9_000,
|
||||
eventTsMs: 9_000,
|
||||
noteIds: [91],
|
||||
cardsDelta: 1,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading={false}
|
||||
pinned={true}
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Pinned/);
|
||||
assert.match(markup, /Preview unavailable from AnkiConnect/);
|
||||
assert.doesNotMatch(markup, /No readable note fields returned/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover hides preview-unavailable fallback while note info is still loading', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'card-177',
|
||||
kind: 'card',
|
||||
anchorTsMs: 9_000,
|
||||
eventTsMs: 9_000,
|
||||
noteIds: [177],
|
||||
cardsDelta: 1,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading
|
||||
pinned
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Loading Anki note info/);
|
||||
assert.doesNotMatch(markup, /Preview unavailable/);
|
||||
});
|
||||
|
||||
test('SessionEventPopover keeps the loading state clean until note preview data arrives', () => {
|
||||
const marker: SessionChartMarker = {
|
||||
key: 'card-9001',
|
||||
kind: 'card',
|
||||
anchorTsMs: 9_001,
|
||||
eventTsMs: 9_001,
|
||||
noteIds: [1773808840964],
|
||||
cardsDelta: 1,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SessionEventPopover
|
||||
marker={marker}
|
||||
noteInfos={new Map()}
|
||||
loading={true}
|
||||
pinned={true}
|
||||
onTogglePinned={() => {}}
|
||||
onClose={() => {}}
|
||||
onOpenNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Loading Anki note info/);
|
||||
assert.doesNotMatch(markup, /Preview unavailable/);
|
||||
});
|
||||
161
stats/src/components/sessions/SessionEventPopover.tsx
Normal file
161
stats/src/components/sessions/SessionEventPopover.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
formatEventSeconds,
|
||||
type SessionChartMarker,
|
||||
type SessionEventNoteInfo,
|
||||
} from '../../lib/session-events';
|
||||
|
||||
interface SessionEventPopoverProps {
|
||||
marker: SessionChartMarker;
|
||||
noteInfos: Map<number, SessionEventNoteInfo>;
|
||||
loading: boolean;
|
||||
pinned: boolean;
|
||||
onTogglePinned: () => void;
|
||||
onClose: () => void;
|
||||
onOpenNote: (noteId: number) => void;
|
||||
}
|
||||
|
||||
function formatEventTime(tsMs: number): string {
|
||||
return new Date(tsMs).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function SessionEventPopover({
|
||||
marker,
|
||||
noteInfos,
|
||||
loading,
|
||||
pinned,
|
||||
onTogglePinned,
|
||||
onClose,
|
||||
onOpenNote,
|
||||
}: SessionEventPopoverProps) {
|
||||
const seekDurationLabel =
|
||||
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
|
||||
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-ctp-text">
|
||||
{marker.kind === 'pause' && 'Paused'}
|
||||
{marker.kind === 'seek' && `Seek ${marker.direction}`}
|
||||
{marker.kind === 'card' && 'Card mined'}
|
||||
</div>
|
||||
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{pinned ? (
|
||||
<span className="rounded-full border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-blue">
|
||||
Pinned
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePinned}
|
||||
className="rounded-md border border-ctp-surface2 px-1.5 py-0.5 text-[10px] text-ctp-overlay1 transition-colors hover:bg-ctp-surface1 hover:text-ctp-text"
|
||||
>
|
||||
{pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
{pinned ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close event popup"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-ctp-surface2 px-1.5 py-0.5 text-[10px] text-ctp-overlay1 transition-colors hover:bg-ctp-surface1 hover:text-ctp-text"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
<div className="text-sm">
|
||||
{marker.kind === 'pause' && '||'}
|
||||
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
|
||||
{marker.kind === 'card' && '\u26CF'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{marker.kind === 'pause' && (
|
||||
<div className="text-xs text-ctp-subtext0">
|
||||
Duration: <span className="text-ctp-peach">{formatEventSeconds(marker.durationMs)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marker.kind === 'seek' && (
|
||||
<div className="space-y-1 text-xs text-ctp-subtext0">
|
||||
<div>
|
||||
From{' '}
|
||||
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
|
||||
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
|
||||
</div>
|
||||
<div>
|
||||
Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marker.kind === 'card' && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-ctp-cards-mined">
|
||||
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
{marker.noteIds.length > 0 ? (
|
||||
marker.noteIds.map((noteId) => {
|
||||
const info = noteInfos.get(noteId);
|
||||
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
|
||||
const showUnavailableFallback = !loading && !hasPreview;
|
||||
return (
|
||||
<div
|
||||
key={noteId}
|
||||
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
|
||||
Note {noteId}
|
||||
</div>
|
||||
{showUnavailableFallback ? (
|
||||
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
|
||||
) : null}
|
||||
</div>
|
||||
{info?.expression ? (
|
||||
<div className="mb-1 text-sm font-medium text-ctp-text">
|
||||
{info.expression}
|
||||
</div>
|
||||
) : null}
|
||||
{info?.context ? (
|
||||
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
|
||||
) : null}
|
||||
{info?.meaning ? (
|
||||
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
|
||||
) : null}
|
||||
{showUnavailableFallback ? (
|
||||
<div className="mb-2 text-xs text-ctp-overlay1">
|
||||
Preview unavailable from AnkiConnect.
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(noteId)}
|
||||
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
|
||||
>
|
||||
Open in Anki
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
stats/src/components/sessions/SessionRow.tsx
Normal file
140
stats/src/components/sessions/SessionRow.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface SessionRowProps {
|
||||
session: SessionSummary;
|
||||
isExpanded: boolean;
|
||||
detailsId: string;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
deleteDisabled?: boolean;
|
||||
onNavigateToMediaDetail?: (videoId: number) => void;
|
||||
}
|
||||
|
||||
function CoverThumbnail({
|
||||
animeId,
|
||||
videoId,
|
||||
title,
|
||||
}: {
|
||||
animeId: number | null;
|
||||
videoId: number | null;
|
||||
title: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if ((!animeId && !videoId) || failed) {
|
||||
return (
|
||||
<div className="w-10 h-14 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-sm font-bold shrink-0">
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src =
|
||||
animeId != null
|
||||
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
|
||||
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionRow({
|
||||
session,
|
||||
isExpanded,
|
||||
detailsId,
|
||||
onToggle,
|
||||
onDelete,
|
||||
deleteDisabled = false,
|
||||
onNavigateToMediaDetail,
|
||||
}: SessionRowProps) {
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
const knownWordsSeen = session.knownWordsSeen;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={detailsId}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
<CoverThumbnail
|
||||
animeId={session.animeId}
|
||||
videoId={session.videoId}
|
||||
title={session.canonicalTitle ?? 'Unknown'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{session.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.cardsMined)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(displayWordCount)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(knownWordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">known words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{'\u25B8'}
|
||||
</div>
|
||||
</button>
|
||||
{onNavigateToMediaDetail != null && session.videoId != null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigateToMediaDetail(session.videoId!);
|
||||
}}
|
||||
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
|
||||
title="View anime overview"
|
||||
>
|
||||
{'\u2197'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleteDisabled}
|
||||
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Delete session"
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
stats/src/components/sessions/SessionsTab.tsx
Normal file
154
stats/src/components/sessions/SessionsTab.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessions } from '../../hooks/useSessions';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatSessionDayLabel } from '../../lib/formatters';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
const groups = new Map<string, SessionSummary[]>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const dayLabel = formatSessionDayLabel(session.startedAtMs);
|
||||
const group = groups.get(dayLabel);
|
||||
if (group) {
|
||||
group.push(session);
|
||||
} else {
|
||||
groups.set(dayLabel, [session]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
interface SessionsTabProps {
|
||||
initialSessionId?: number | null;
|
||||
onClearInitialSession?: () => void;
|
||||
onNavigateToMediaDetail?: (videoId: number) => void;
|
||||
}
|
||||
|
||||
export function SessionsTab({
|
||||
initialSessionId,
|
||||
onClearInitialSession,
|
||||
onNavigateToMediaDetail,
|
||||
}: SessionsTabProps = {}) {
|
||||
const { sessions, loading, error } = useSessions();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleSessions(sessions);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSessionId != null && sessions.length > 0) {
|
||||
let canceled = false;
|
||||
setExpandedId(initialSessionId);
|
||||
onClearInitialSession?.();
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (canceled) return;
|
||||
const el = document.getElementById(`session-details-${initialSessionId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
// Session row itself if detail hasn't rendered yet
|
||||
const row = document.querySelector(
|
||||
`[aria-controls="session-details-${initialSessionId}"]`,
|
||||
);
|
||||
row?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}
|
||||
}, [initialSessionId, sessions, onClearInitialSession]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return visibleSessions;
|
||||
return visibleSessions.filter((s) => s.canonicalTitle?.toLowerCase().includes(q));
|
||||
}, [visibleSessions, search]);
|
||||
|
||||
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingSessionId(session.sessionId);
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
|
||||
setExpandedId((prev) => (prev === session.sessionId ? null : prev));
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
setDeletingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search sessions by title"
|
||||
placeholder="Search by title..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
|
||||
<div key={dayLabel}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{daySessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-ctp-overlay2 text-sm">
|
||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
stats/src/components/trends/DateRangeSelector.tsx
Normal file
70
stats/src/components/trends/DateRangeSelector.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
range: TimeRange;
|
||||
groupBy: GroupBy;
|
||||
onRangeChange: (r: TimeRange) => void;
|
||||
onGroupByChange: (g: GroupBy) => void;
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
formatLabel,
|
||||
}: {
|
||||
label: string;
|
||||
options: T[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
formatLabel?: (v: T) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1">{label}</span>
|
||||
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => onChange(opt)}
|
||||
aria-pressed={value === opt}
|
||||
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
|
||||
value === opt
|
||||
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{formatLabel ? formatLabel(opt) : opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateRangeSelector({
|
||||
range,
|
||||
groupBy,
|
||||
onRangeChange,
|
||||
onGroupByChange,
|
||||
}: DateRangeSelectorProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<SegmentedControl
|
||||
label="Range"
|
||||
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Group by"
|
||||
options={['day', 'month'] as GroupBy[]}
|
||||
value={groupBy}
|
||||
onChange={onGroupByChange}
|
||||
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
stats/src/components/trends/StackedTrendChart.tsx
Normal file
133
stats/src/components/trends/StackedTrendChart.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
export interface PerAnimeDataPoint {
|
||||
epochDay: number;
|
||||
animeTitle: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface StackedTrendChartProps {
|
||||
title: string;
|
||||
data: PerAnimeDataPoint[];
|
||||
colorPalette?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_LINE_COLORS = [
|
||||
'#8aadf4',
|
||||
'#c6a0f6',
|
||||
'#a6da95',
|
||||
'#f5a97f',
|
||||
'#f5bde6',
|
||||
'#91d7e3',
|
||||
'#ee99a0',
|
||||
'#f4dbd6',
|
||||
];
|
||||
|
||||
function buildLineData(raw: PerAnimeDataPoint[]) {
|
||||
const totalByAnime = new Map<string, number>();
|
||||
for (const entry of raw) {
|
||||
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
|
||||
}
|
||||
|
||||
const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const topTitles = sorted.slice(0, 7).map(([title]) => title);
|
||||
const topSet = new Set(topTitles);
|
||||
|
||||
const byDay = new Map<number, Record<string, number>>();
|
||||
for (const entry of raw) {
|
||||
if (!topSet.has(entry.animeTitle)) continue;
|
||||
const row = byDay.get(entry.epochDay) ?? {};
|
||||
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
||||
byDay.set(entry.epochDay, row);
|
||||
}
|
||||
|
||||
const points = [...byDay.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([epochDay, values]) => {
|
||||
const row: Record<string, string | number> = {
|
||||
label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
};
|
||||
for (const title of topTitles) {
|
||||
row[title] = values[title] ?? 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return { points, seriesKeys: topTitles };
|
||||
}
|
||||
|
||||
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) {
|
||||
const { points, seriesKeys } = buildLineData(data);
|
||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<div className="text-xs text-ctp-overlay2">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<AreaChart data={points}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[i % colors.length]}
|
||||
fill={colors[i % colors.length]}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
|
||||
{seriesKeys.map((key, i) => (
|
||||
<span
|
||||
key={key}
|
||||
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
|
||||
title={key}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: colors[i % colors.length] }}
|
||||
/>
|
||||
<span className="truncate">{key}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
stats/src/components/trends/TrendChart.tsx
Normal file
82
stats/src/components/trends/TrendChart.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
data: Array<{ label: string; value: number }>;
|
||||
color: string;
|
||||
type: 'bar' | 'line';
|
||||
formatter?: (value: number) => string;
|
||||
onBarClick?: (label: string) => void;
|
||||
}
|
||||
|
||||
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={color}
|
||||
radius={[2, 2, 0, 0]}
|
||||
cursor={onBarClick ? 'pointer' : undefined}
|
||||
onClick={
|
||||
onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined
|
||||
}
|
||||
/>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
stats/src/components/trends/TrendsTab.tsx
Normal file
282
stats/src/components/trends/TrendsTab.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState } from 'react';
|
||||
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
|
||||
import { DateRangeSelector } from './DateRangeSelector';
|
||||
import { TrendChart } from './TrendChart';
|
||||
import { StackedTrendChart } from './StackedTrendChart';
|
||||
import {
|
||||
buildAnimeVisibilityOptions,
|
||||
filterHiddenAnimeData,
|
||||
pruneHiddenAnime,
|
||||
} from './anime-visibility';
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
|
||||
<h3 className="text-ctp-subtext0 text-xs font-semibold uppercase tracking-widest shrink-0">
|
||||
{children}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnimeVisibilityFilterProps {
|
||||
animeTitles: string[];
|
||||
hiddenAnime: ReadonlySet<string>;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
onToggleAnime: (title: string) => void;
|
||||
}
|
||||
|
||||
function AnimeVisibilityFilter({
|
||||
animeTitles,
|
||||
hiddenAnime,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
onToggleAnime,
|
||||
}: AnimeVisibilityFilterProps) {
|
||||
if (animeTitles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-full -mt-1 mb-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
||||
Anime Visibility
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||
Shared across all anime trend charts. Default: show everything.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onShowAll}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-peach hover:text-ctp-peach"
|
||||
onClick={onHideAll}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{animeTitles.map((title) => {
|
||||
const isVisible = !hiddenAnime.has(title);
|
||||
return (
|
||||
<button
|
||||
key={title}
|
||||
type="button"
|
||||
aria-pressed={isVisible}
|
||||
className={`max-w-full rounded-full border px-3 py-1 text-xs transition ${
|
||||
isVisible
|
||||
? 'border-ctp-blue/60 bg-ctp-blue/12 text-ctp-blue'
|
||||
: 'border-ctp-surface2 bg-transparent text-ctp-subtext0'
|
||||
}`}
|
||||
onClick={() => onToggleAnime(title)}
|
||||
title={title}
|
||||
>
|
||||
<span className="block truncate">{title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendsTab() {
|
||||
const [range, setRange] = useState<TimeRange>('30d');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
||||
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
|
||||
const { data, loading, error } = useTrends(range, groupBy);
|
||||
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
|
||||
const cardsMinedStackedColors = [
|
||||
cardsMinedColor,
|
||||
'#8aadf4',
|
||||
'#c6a0f6',
|
||||
'#f5a97f',
|
||||
'#f5bde6',
|
||||
'#91d7e3',
|
||||
'#ee99a0',
|
||||
'#f4dbd6',
|
||||
];
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
const animeTitles = buildAnimeVisibilityOptions([
|
||||
data.animePerDay.episodes,
|
||||
data.animePerDay.watchTime,
|
||||
data.animePerDay.cards,
|
||||
data.animePerDay.words,
|
||||
data.animePerDay.lookups,
|
||||
data.animeCumulative.episodes,
|
||||
data.animeCumulative.cards,
|
||||
data.animeCumulative.words,
|
||||
data.animeCumulative.watchTime,
|
||||
]);
|
||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||
|
||||
const filteredEpisodesPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.episodes,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredWatchTimePerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.watchTime,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
|
||||
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
|
||||
const filteredLookupsPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.lookups,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
|
||||
data.animePerDay.lookupsPerHundred,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredAnimeProgress = filterHiddenAnimeData(
|
||||
data.animeCumulative.episodes,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredCardsProgress = filterHiddenAnimeData(
|
||||
data.animeCumulative.cards,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredWordsProgress = filterHiddenAnimeData(
|
||||
data.animeCumulative.words,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
const filteredWatchTimeProgress = filterHiddenAnimeData(
|
||||
data.animeCumulative.watchTime,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
groupBy={groupBy}
|
||||
onRangeChange={setRange}
|
||||
onGroupByChange={setGroupBy}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SectionHeader>Activity</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time (min)"
|
||||
data={data.activity.watchTime}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Cards Mined"
|
||||
data={data.activity.cards}
|
||||
color={cardsMinedColor}
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||
|
||||
<SectionHeader>Period Trends</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time (min)"
|
||||
data={data.progress.watchTime}
|
||||
color="#8aadf4"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
|
||||
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
|
||||
<TrendChart
|
||||
title="New Words Seen"
|
||||
data={data.progress.newWords}
|
||||
color="#c6a0f6"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Cards Mined"
|
||||
data={data.progress.cards}
|
||||
color={cardsMinedColor}
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Episodes Watched"
|
||||
data={data.progress.episodes}
|
||||
color="#91d7e3"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
|
||||
<TrendChart
|
||||
title="Lookups / 100 Words"
|
||||
data={data.ratios.lookupsPerHundred}
|
||||
color="#f5a97f"
|
||||
type="line"
|
||||
/>
|
||||
|
||||
<SectionHeader>Anime — Per Day</SectionHeader>
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={animeTitles}
|
||||
hiddenAnime={activeHiddenAnime}
|
||||
onShowAll={() => setHiddenAnime(new Set())}
|
||||
onHideAll={() => setHiddenAnime(new Set(animeTitles))}
|
||||
onToggleAnime={(title) =>
|
||||
setHiddenAnime((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(title)) {
|
||||
next.delete(title);
|
||||
} else {
|
||||
next.add(title);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
|
||||
<StackedTrendChart
|
||||
title="Cards Mined per Anime"
|
||||
data={filteredCardsPerAnime}
|
||||
colorPalette={cardsMinedStackedColors}
|
||||
/>
|
||||
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
||||
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
||||
<StackedTrendChart
|
||||
title="Lookups/100w per Anime"
|
||||
data={filteredLookupsPerHundredPerAnime}
|
||||
/>
|
||||
|
||||
<SectionHeader>Anime — Cumulative</SectionHeader>
|
||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
||||
<StackedTrendChart
|
||||
title="Cards Mined Progress"
|
||||
data={filteredCardsProgress}
|
||||
colorPalette={cardsMinedStackedColors}
|
||||
/>
|
||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
||||
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={data.patterns.watchTimeByDayOfWeek}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={data.patterns.watchTimeByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
stats/src/components/trends/anime-visibility.test.ts
Normal file
47
stats/src/components/trends/anime-visibility.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { PerAnimeDataPoint } from './StackedTrendChart';
|
||||
import {
|
||||
buildAnimeVisibilityOptions,
|
||||
filterHiddenAnimeData,
|
||||
pruneHiddenAnime,
|
||||
} from './anime-visibility';
|
||||
|
||||
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
|
||||
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
|
||||
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
|
||||
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 6 },
|
||||
{ epochDay: 1, animeTitle: 'Trapped in a Dating Sim', value: 20 },
|
||||
];
|
||||
|
||||
test('buildAnimeVisibilityOptions sorts anime by combined contribution', () => {
|
||||
const titles = buildAnimeVisibilityOptions([
|
||||
SAMPLE_POINTS,
|
||||
[
|
||||
{ epochDay: 1, animeTitle: 'Little Witch Academia', value: 8 },
|
||||
{ epochDay: 1, animeTitle: 'KonoSuba', value: 1 },
|
||||
],
|
||||
]);
|
||||
|
||||
assert.deepEqual(titles, ['Trapped in a Dating Sim', 'KonoSuba', 'Little Witch Academia']);
|
||||
});
|
||||
|
||||
test('filterHiddenAnimeData removes globally hidden anime from chart data', () => {
|
||||
const filtered = filterHiddenAnimeData(SAMPLE_POINTS, new Set(['KonoSuba']));
|
||||
|
||||
assert.equal(
|
||||
filtered.some((point) => point.animeTitle === 'KonoSuba'),
|
||||
false,
|
||||
);
|
||||
assert.equal(filtered.length, 2);
|
||||
});
|
||||
|
||||
test('pruneHiddenAnime drops titles that are no longer available', () => {
|
||||
const hidden = pruneHiddenAnime(new Set(['KonoSuba', 'Ghost in the Shell']), [
|
||||
'KonoSuba',
|
||||
'Little Witch Academia',
|
||||
]);
|
||||
|
||||
assert.deepEqual([...hidden], ['KonoSuba']);
|
||||
});
|
||||
32
stats/src/components/trends/anime-visibility.ts
Normal file
32
stats/src/components/trends/anime-visibility.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { PerAnimeDataPoint } from './StackedTrendChart';
|
||||
|
||||
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const dataset of datasets) {
|
||||
for (const point of dataset) {
|
||||
totals.set(point.animeTitle, (totals.get(point.animeTitle) ?? 0) + point.value);
|
||||
}
|
||||
}
|
||||
|
||||
return [...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([title]) => title);
|
||||
}
|
||||
|
||||
export function filterHiddenAnimeData(
|
||||
data: PerAnimeDataPoint[],
|
||||
hiddenAnime: ReadonlySet<string>,
|
||||
): PerAnimeDataPoint[] {
|
||||
if (hiddenAnime.size === 0) {
|
||||
return data;
|
||||
}
|
||||
return data.filter((point) => !hiddenAnime.has(point.animeTitle));
|
||||
}
|
||||
|
||||
export function pruneHiddenAnime(
|
||||
hiddenAnime: ReadonlySet<string>,
|
||||
availableAnime: readonly string[],
|
||||
): Set<string> {
|
||||
const availableSet = new Set(availableAnime);
|
||||
return new Set([...hiddenAnime].filter((title) => availableSet.has(title)));
|
||||
}
|
||||
168
stats/src/components/vocabulary/CrossAnimeWordsTable.tsx
Normal file
168
stats/src/components/vocabulary/CrossAnimeWordsTable.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
import { fullReading } from '../../lib/reading-utils';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
interface CrossAnimeWordsTableProps {
|
||||
words: VocabularyEntry[];
|
||||
knownWords: Set<string>;
|
||||
onSelectWord?: (word: VocabularyEntry) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function CrossAnimeWordsTable({
|
||||
words,
|
||||
knownWords,
|
||||
onSelectWord,
|
||||
}: CrossAnimeWordsTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [hideKnown, setHideKnown] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const hasKnownData = knownWords.size > 0;
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
let filtered = words.filter((w) => w.animeCount >= 2);
|
||||
if (hideKnown && hasKnownData) {
|
||||
filtered = filtered.filter((w) => !knownWords.has(w.headword) && !knownWords.has(w.word));
|
||||
}
|
||||
|
||||
const byHeadword = new Map<string, VocabularyEntry>();
|
||||
for (const w of filtered) {
|
||||
const existing = byHeadword.get(w.headword);
|
||||
if (!existing) {
|
||||
byHeadword.set(w.headword, { ...w });
|
||||
} else {
|
||||
existing.frequency += w.frequency;
|
||||
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
|
||||
if (
|
||||
w.frequencyRank != null &&
|
||||
(existing.frequencyRank == null || w.frequencyRank < existing.frequencyRank)
|
||||
) {
|
||||
existing.frequencyRank = w.frequencyRank;
|
||||
}
|
||||
if (!existing.reading && w.reading) existing.reading = w.reading;
|
||||
if (!existing.partOfSpeech && w.partOfSpeech) existing.partOfSpeech = w.partOfSpeech;
|
||||
}
|
||||
}
|
||||
|
||||
return [...byHeadword.values()].sort((a, b) => {
|
||||
if (b.animeCount !== a.animeCount) return b.animeCount - a.animeCount;
|
||||
return b.frequency - a.frequency;
|
||||
});
|
||||
}, [words, knownWords, hideKnown, hasKnownData]);
|
||||
|
||||
const hasMultiAnimeWords = words.some((w) => w.animeCount >= 2);
|
||||
if (!hasMultiAnimeWords) return null;
|
||||
|
||||
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
|
||||
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
Words In Multiple Anime
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasKnownData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHideKnown(!hideKnown);
|
||||
setPage(0);
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
|
||||
hideKnown
|
||||
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
|
||||
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
Hide Known
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
|
||||
</div>
|
||||
</div>
|
||||
{collapsed ? null : ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||
{hideKnown
|
||||
? 'All multi-anime words are already known!'
|
||||
: 'No words found across multiple anime.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto mt-3">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||
<th className="text-right py-2 pr-3 font-medium w-16">Anime</th>
|
||||
<th className="text-right py-2 font-medium w-16">Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((w) => (
|
||||
<tr
|
||||
key={w.wordId}
|
||||
onClick={() => onSelectWord?.(w)}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
|
||||
>
|
||||
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
||||
{fullReading(w.headword, w.reading) || w.headword}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono tabular-nums text-ctp-green text-xs">
|
||||
{w.animeCount}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
|
||||
{w.frequency}x
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-ctp-overlay2">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
stats/src/components/vocabulary/ExclusionManager.tsx
Normal file
83
stats/src/components/vocabulary/ExclusionManager.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ExcludedWord } from '../../hooks/useExcludedWords';
|
||||
|
||||
interface ExclusionManagerProps {
|
||||
excluded: ExcludedWord[];
|
||||
onRemove: (w: ExcludedWord) => void;
|
||||
onClearAll: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExclusionManager({
|
||||
excluded,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
onClose,
|
||||
}: ExclusionManagerProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close exclusion manager"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-1/2 mx-auto max-w-lg -translate-y-1/2 rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-text">
|
||||
Excluded Words
|
||||
<span className="ml-2 text-ctp-overlay1 font-normal">({excluded.length})</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{excluded.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-red/30 px-3 py-1.5 text-xs font-medium text-ctp-red transition hover:bg-ctp-red/10"
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto px-5 py-3">
|
||||
{excluded.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-ctp-overlay2">
|
||||
No excluded words yet. Use the Exclude button on a word's detail panel to hide it from
|
||||
stats.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{excluded.map((w) => (
|
||||
<div
|
||||
key={`${w.headword}\0${w.word}\0${w.reading}`}
|
||||
className="flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-ctp-text">{w.headword}</span>
|
||||
{w.reading && w.reading !== w.headword && (
|
||||
<span className="ml-2 text-xs text-ctp-subtext0">{w.reading}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md border border-ctp-surface2 px-2 py-1 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={() => onRemove(w)}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
stats/src/components/vocabulary/FrequencyRankTable.tsx
Normal file
173
stats/src/components/vocabulary/FrequencyRankTable.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
import { fullReading } from '../../lib/reading-utils';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
interface FrequencyRankTableProps {
|
||||
words: VocabularyEntry[];
|
||||
knownWords: Set<string>;
|
||||
onSelectWord?: (word: VocabularyEntry) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [hideKnown, setHideKnown] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const hasKnownData = knownWords.size > 0;
|
||||
|
||||
const isWordKnown = (w: VocabularyEntry): boolean => {
|
||||
return knownWords.has(w.headword) || knownWords.has(w.word);
|
||||
};
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
|
||||
if (hideKnown && hasKnownData) {
|
||||
filtered = filtered.filter((w) => !isWordKnown(w));
|
||||
}
|
||||
|
||||
const byHeadword = new Map<string, VocabularyEntry>();
|
||||
for (const w of filtered) {
|
||||
const existing = byHeadword.get(w.headword);
|
||||
if (!existing) {
|
||||
byHeadword.set(w.headword, { ...w });
|
||||
} else {
|
||||
existing.frequency += w.frequency;
|
||||
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
|
||||
if (w.frequencyRank! < existing.frequencyRank!) {
|
||||
existing.frequencyRank = w.frequencyRank;
|
||||
}
|
||||
if (!existing.reading && w.reading) {
|
||||
existing.reading = w.reading;
|
||||
}
|
||||
if (!existing.partOfSpeech && w.partOfSpeech) {
|
||||
existing.partOfSpeech = w.partOfSpeech;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
|
||||
}, [words, knownWords, hideKnown, hasKnownData]);
|
||||
|
||||
if (words.every((w) => w.frequencyRank == null)) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
No frequency rank data available. Run the frequency backfill script or install a frequency
|
||||
dictionary.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(ranked.length / PAGE_SIZE);
|
||||
const paged = ranked.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasKnownData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHideKnown(!hideKnown);
|
||||
setPage(0);
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
|
||||
hideKnown
|
||||
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
|
||||
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
Hide Known
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
|
||||
</div>
|
||||
</div>
|
||||
{collapsed ? null : ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto mt-3">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="text-left py-2 pr-3 font-medium w-16">Rank</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Word</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Reading</th>
|
||||
<th className="text-left py-2 pr-3 font-medium w-20">POS</th>
|
||||
<th className="text-right py-2 font-medium w-20">Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((w) => (
|
||||
<tr
|
||||
key={w.wordId}
|
||||
onClick={() => onSelectWord?.(w)}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
|
||||
>
|
||||
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
||||
#{w.frequencyRank!.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
||||
{fullReading(w.headword, w.reading) || w.headword}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
|
||||
{w.frequency}x
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 mt-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-ctp-overlay2">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="px-2 py-1 rounded bg-ctp-surface1 text-ctp-text disabled:opacity-30 hover:bg-ctp-surface2 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
stats/src/components/vocabulary/KanjiBreakdown.tsx
Normal file
46
stats/src/components/vocabulary/KanjiBreakdown.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { KanjiEntry } from '../../types/stats';
|
||||
|
||||
interface KanjiBreakdownProps {
|
||||
kanji: KanjiEntry[];
|
||||
selectedKanjiId?: number | null;
|
||||
onSelectKanji?: (entry: KanjiEntry) => void;
|
||||
}
|
||||
|
||||
export function KanjiBreakdown({
|
||||
kanji,
|
||||
selectedKanjiId = null,
|
||||
onSelectKanji,
|
||||
}: KanjiBreakdownProps) {
|
||||
if (kanji.length === 0) return null;
|
||||
|
||||
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kanji.map((k) => {
|
||||
const ratio = k.frequency / maxFreq;
|
||||
const opacity = Math.max(0.3, ratio);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={k.kanji}
|
||||
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
|
||||
selectedKanjiId === k.kanjiId
|
||||
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
|
||||
: 'hover:bg-ctp-surface1/80'
|
||||
}`}
|
||||
style={{ opacity }}
|
||||
title={`${k.kanji} — seen ${k.frequency}x`}
|
||||
aria-label={`${k.kanji} — seen ${k.frequency} times`}
|
||||
onClick={() => onSelectKanji?.(k)}
|
||||
>
|
||||
{k.kanji}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
stats/src/components/vocabulary/KanjiDetailPanel.tsx
Normal file
267
stats/src/components/vocabulary/KanjiDetailPanel.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useKanjiDetail } from '../../hooks/useKanjiDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
|
||||
const OCCURRENCES_PAGE_SIZE = 50;
|
||||
|
||||
interface KanjiDetailPanelProps {
|
||||
kanjiId: number | null;
|
||||
onClose: () => void;
|
||||
onSelectWord?: (wordId: number) => void;
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
}
|
||||
|
||||
function formatSegment(ms: number | null): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function KanjiDetailPanel({
|
||||
kanjiId,
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onNavigateToAnime,
|
||||
}: KanjiDetailPanelProps) {
|
||||
const { data, loading, error } = useKanjiDetail(kanjiId);
|
||||
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||
const [occLoading, setOccLoading] = useState(false);
|
||||
const [occLoadingMore, setOccLoadingMore] = useState(false);
|
||||
const [occError, setOccError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [occLoaded, setOccLoaded] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setOccurrences([]);
|
||||
setOccLoaded(false);
|
||||
setOccLoading(false);
|
||||
setOccLoadingMore(false);
|
||||
setOccError(null);
|
||||
setHasMore(false);
|
||||
requestIdRef.current++;
|
||||
}, [kanjiId]);
|
||||
|
||||
if (kanjiId === null) return null;
|
||||
|
||||
const loadOccurrences = async (kanji: string, offset: number, append: boolean) => {
|
||||
const reqId = ++requestIdRef.current;
|
||||
if (append) {
|
||||
setOccLoadingMore(true);
|
||||
} else {
|
||||
setOccLoading(true);
|
||||
setOccError(null);
|
||||
}
|
||||
try {
|
||||
const rows = await apiClient.getKanjiOccurrences(kanji, OCCURRENCES_PAGE_SIZE, offset);
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
|
||||
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccError(err instanceof Error ? err.message : String(err));
|
||||
if (!append) {
|
||||
setOccurrences([]);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccLoading(false);
|
||||
setOccLoadingMore(false);
|
||||
setOccLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowOccurrences = () => {
|
||||
if (!data) return;
|
||||
void loadOccurrences(data.detail.kanji, 0, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!data || occLoadingMore || !hasMore) return;
|
||||
void loadOccurrences(data.detail.kanji, occurrences.length, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close kanji detail panel"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
Kanji Detail
|
||||
</div>
|
||||
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
|
||||
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
|
||||
{data && (
|
||||
<>
|
||||
<h2 className="mt-1 text-5xl font-semibold text-ctp-teal">{data.detail.kanji}</h2>
|
||||
<div className="mt-2 text-sm text-ctp-subtext0">
|
||||
{formatNumber(data.detail.frequency)} total occurrences
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-lg font-bold text-ctp-teal">
|
||||
{formatNumber(data.detail.frequency)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-green">
|
||||
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-mauve">
|
||||
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.animeAppearances.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Anime Appearances
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.animeAppearances.map((a) => (
|
||||
<button
|
||||
key={a.animeId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onNavigateToAnime?.(a.animeId);
|
||||
}}
|
||||
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-teal hover:ring-1 hover:ring-ctp-teal text-left"
|
||||
>
|
||||
<span className="truncate text-ctp-text">{a.animeTitle}</span>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-ctp-teal/10 px-2 py-0.5 text-[11px] font-medium text-ctp-teal">
|
||||
{formatNumber(a.occurrenceCount)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.words.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Words Using This Kanji
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.words.map((w) => (
|
||||
<button
|
||||
key={w.wordId}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
|
||||
onClick={() => onSelectWord?.(w.wordId)}
|
||||
>
|
||||
{w.headword}
|
||||
<span className="opacity-60">({formatNumber(w.frequency)})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Example Lines
|
||||
</h3>
|
||||
{!occLoaded && !occLoading && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal"
|
||||
onClick={handleShowOccurrences}
|
||||
>
|
||||
Load example lines
|
||||
</button>
|
||||
)}
|
||||
{occLoading && (
|
||||
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
|
||||
)}
|
||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||
{occLoaded && !occLoading && occurrences.length === 0 && (
|
||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
||||
)}
|
||||
{occurrences.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{occurrences.map((occ, idx) => (
|
||||
<article
|
||||
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
|
||||
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||
{occ.animeTitle ?? occ.videoTitle}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ctp-subtext0">
|
||||
{occ.videoTitle} · line {occ.lineIndex}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-ctp-teal/10 px-2 py-1 text-[11px] font-medium text-ctp-teal">
|
||||
{formatNumber(occ.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} ·
|
||||
session {occ.sessionId}
|
||||
</div>
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{occ.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{occLoaded && !occLoading && !occError && hasMore && (
|
||||
<div className="border-t border-ctp-surface1 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={handleLoadMore}
|
||||
disabled={occLoadingMore}
|
||||
>
|
||||
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx
Normal file
151
stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { KanjiEntry, VocabularyEntry, VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
|
||||
type VocabularyDrawerTarget =
|
||||
| {
|
||||
kind: 'word';
|
||||
entry: VocabularyEntry;
|
||||
}
|
||||
| {
|
||||
kind: 'kanji';
|
||||
entry: KanjiEntry;
|
||||
};
|
||||
|
||||
interface VocabularyOccurrencesDrawerProps {
|
||||
target: VocabularyDrawerTarget | null;
|
||||
occurrences: VocabularyOccurrenceEntry[];
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
error: string | null;
|
||||
hasMore: boolean;
|
||||
onClose: () => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
function formatSegment(ms: number | null): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function renderTitle(target: VocabularyDrawerTarget): string {
|
||||
return target.kind === 'word' ? target.entry.headword : target.entry.kanji;
|
||||
}
|
||||
|
||||
function renderSubtitle(target: VocabularyDrawerTarget): string {
|
||||
if (target.kind === 'word') {
|
||||
return target.entry.reading || target.entry.word;
|
||||
}
|
||||
return `${formatNumber(target.entry.frequency)} seen`;
|
||||
}
|
||||
|
||||
function renderFrequency(target: VocabularyDrawerTarget): string {
|
||||
return `${formatNumber(target.entry.frequency)} total`;
|
||||
}
|
||||
|
||||
export function VocabularyOccurrencesDrawer({
|
||||
target,
|
||||
occurrences,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
hasMore,
|
||||
onClose,
|
||||
onLoadMore,
|
||||
}: VocabularyOccurrencesDrawerProps) {
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close occurrence drawer"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
{target.kind === 'word' ? 'Word Occurrences' : 'Kanji Occurrences'}
|
||||
</div>
|
||||
<h2 className="mt-1 truncate text-2xl font-semibold text-ctp-text">
|
||||
{renderTitle(target)}
|
||||
</h2>
|
||||
<div className="mt-1 text-sm text-ctp-subtext0">{renderSubtitle(target)}</div>
|
||||
<div className="mt-2 text-xs text-ctp-overlay1">
|
||||
{renderFrequency(target)} · {formatNumber(occurrences.length)} loaded
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{loading ? (
|
||||
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
|
||||
) : null}
|
||||
{!loading && error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
|
||||
{!loading && !error && occurrences.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
||||
) : null}
|
||||
{!loading && !error ? (
|
||||
<div className="space-y-3">
|
||||
{occurrences.map((occurrence, index) => (
|
||||
<article
|
||||
key={`${occurrence.sessionId}-${occurrence.lineIndex}-${occurrence.segmentStartMs ?? index}`}
|
||||
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||
{occurrence.animeTitle ?? occurrence.videoTitle}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ctp-subtext0">
|
||||
{occurrence.videoTitle} · line {occurrence.lineIndex}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
|
||||
{formatNumber(occurrence.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occurrence.segmentStartMs)}-
|
||||
{formatSegment(occurrence.segmentEndMs)} · session {occurrence.sessionId}
|
||||
</div>
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{occurrence.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!loading && !error && hasMore ? (
|
||||
<div className="border-t border-ctp-surface1 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { VocabularyDrawerTarget };
|
||||
211
stats/src/components/vocabulary/VocabularyTab.tsx
Normal file
211
stats/src/components/vocabulary/VocabularyTab.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useVocabulary } from '../../hooks/useVocabulary';
|
||||
import { StatCard } from '../layout/StatCard';
|
||||
import { WordList } from './WordList';
|
||||
import { KanjiBreakdown } from './KanjiBreakdown';
|
||||
import { KanjiDetailPanel } from './KanjiDetailPanel';
|
||||
import { ExclusionManager } from './ExclusionManager';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
||||
import { CrossAnimeWordsTable } from './CrossAnimeWordsTable';
|
||||
import { buildVocabularySummary } from '../../lib/dashboard-data';
|
||||
import type { ExcludedWord } from '../../hooks/useExcludedWords';
|
||||
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
|
||||
|
||||
interface VocabularyTabProps {
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
onOpenWordDetail?: (wordId: number) => void;
|
||||
excluded: ExcludedWord[];
|
||||
isExcluded: (w: { headword: string; word: string; reading: string }) => boolean;
|
||||
onRemoveExclusion: (w: ExcludedWord) => void;
|
||||
onClearExclusions: () => void;
|
||||
}
|
||||
|
||||
function isProperNoun(w: VocabularyEntry): boolean {
|
||||
return w.pos2 === '固有名詞';
|
||||
}
|
||||
|
||||
export function VocabularyTab({
|
||||
onNavigateToAnime,
|
||||
onOpenWordDetail,
|
||||
excluded,
|
||||
isExcluded,
|
||||
onRemoveExclusion,
|
||||
onClearExclusions,
|
||||
}: VocabularyTabProps) {
|
||||
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [hideNames, setHideNames] = useState(false);
|
||||
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
||||
|
||||
const hasNames = useMemo(() => words.some(isProperNoun), [words]);
|
||||
const filteredWords = useMemo(() => {
|
||||
let result = words;
|
||||
if (hideNames) result = result.filter((w) => !isProperNoun(w));
|
||||
if (excluded.length > 0) result = result.filter((w) => !isExcluded(w));
|
||||
return result;
|
||||
}, [words, hideNames, excluded, isExcluded]);
|
||||
const summary = useMemo(
|
||||
() => buildVocabularySummary(filteredWords, kanji),
|
||||
[filteredWords, kanji],
|
||||
);
|
||||
const knownWordCount = useMemo(() => {
|
||||
if (knownWords.size === 0) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (const w of filteredWords) {
|
||||
if (knownWords.has(w.headword)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}, [filteredWords, knownWords]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-ctp-overlay2 p-4" role="status" aria-live="polite">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-ctp-red p-4" role="alert" aria-live="assertive">
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSelectWord = (entry: VocabularyEntry): void => {
|
||||
onOpenWordDetail?.(entry.wordId);
|
||||
};
|
||||
|
||||
const handleBarClick = (headword: string): void => {
|
||||
const match = filteredWords.find((w) => w.headword === headword);
|
||||
if (match) onOpenWordDetail?.(match.wordId);
|
||||
};
|
||||
|
||||
const openKanjiDetail = (entry: KanjiEntry): void => {
|
||||
setSelectedKanjiId(entry.kanjiId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
label="Unique Words"
|
||||
value={formatNumber(summary.uniqueWords)}
|
||||
color="text-ctp-blue"
|
||||
/>
|
||||
{knownWords.size > 0 && (
|
||||
<StatCard
|
||||
label="Known Words"
|
||||
value={`${formatNumber(knownWordCount)} (${summary.uniqueWords > 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`}
|
||||
color="text-ctp-green"
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
label="Unique Kanji"
|
||||
value={formatNumber(summary.uniqueKanji)}
|
||||
color="text-ctp-teal"
|
||||
/>
|
||||
<StatCard
|
||||
label="New This Week"
|
||||
value={`+${formatNumber(summary.newThisWeek)}`}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search words..."
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
{hasNames && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHideNames(!hideNames)}
|
||||
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
|
||||
hideNames
|
||||
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
|
||||
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
Hide Names
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExclusionManager(true)}
|
||||
className={`shrink-0 px-3 py-2 rounded-lg text-xs transition-colors border ${
|
||||
excluded.length > 0
|
||||
? 'bg-ctp-surface2 text-ctp-text border-ctp-red/50'
|
||||
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
Exclusions{excluded.length > 0 && ` (${excluded.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<TrendChart
|
||||
title="Top Repeated Words"
|
||||
data={summary.topWords}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
onBarClick={handleBarClick}
|
||||
/>
|
||||
<TrendChart
|
||||
title="New Words by Day"
|
||||
data={summary.newWordsTimeline}
|
||||
color="#c6a0f6"
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrequencyRankTable
|
||||
words={filteredWords}
|
||||
knownWords={knownWords}
|
||||
onSelectWord={handleSelectWord}
|
||||
/>
|
||||
|
||||
<CrossAnimeWordsTable
|
||||
words={filteredWords}
|
||||
knownWords={knownWords}
|
||||
onSelectWord={handleSelectWord}
|
||||
/>
|
||||
|
||||
<WordList
|
||||
words={filteredWords}
|
||||
selectedKey={null}
|
||||
onSelectWord={handleSelectWord}
|
||||
search={search}
|
||||
/>
|
||||
|
||||
<KanjiBreakdown
|
||||
kanji={kanji}
|
||||
selectedKanjiId={selectedKanjiId}
|
||||
onSelectKanji={openKanjiDetail}
|
||||
/>
|
||||
|
||||
<KanjiDetailPanel
|
||||
kanjiId={selectedKanjiId}
|
||||
onClose={() => setSelectedKanjiId(null)}
|
||||
onSelectWord={onOpenWordDetail}
|
||||
onNavigateToAnime={onNavigateToAnime}
|
||||
/>
|
||||
|
||||
{showExclusionManager && (
|
||||
<ExclusionManager
|
||||
excluded={excluded}
|
||||
onRemove={onRemoveExclusion}
|
||||
onClearAll={onClearExclusions}
|
||||
onClose={() => setShowExclusionManager(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
471
stats/src/components/vocabulary/WordDetailPanel.tsx
Normal file
471
stats/src/components/vocabulary/WordDetailPanel.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useWordDetail } from '../../hooks/useWordDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { fullReading } from '../../lib/reading-utils';
|
||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
|
||||
const INITIAL_PAGE_SIZE = 5;
|
||||
const LOAD_MORE_SIZE = 10;
|
||||
|
||||
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
|
||||
|
||||
interface WordDetailPanelProps {
|
||||
wordId: number | null;
|
||||
onClose: () => void;
|
||||
onSelectWord?: (wordId: number) => void;
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
isExcluded?: (w: { headword: string; word: string; reading: string }) => boolean;
|
||||
onToggleExclusion?: (w: { headword: string; word: string; reading: string }) => void;
|
||||
}
|
||||
|
||||
function highlightWord(text: string, words: string[]): React.ReactNode {
|
||||
const needles = words.filter(Boolean);
|
||||
if (needles.length === 0) return text;
|
||||
|
||||
const escaped = needles.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(`(${escaped.join('|')})`, 'g');
|
||||
const parts = text.split(pattern);
|
||||
const needleSet = new Set(needles);
|
||||
|
||||
return parts.map((part, i) =>
|
||||
needleSet.has(part) ? (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function formatSegment(ms: number | null): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function WordDetailPanel({
|
||||
wordId,
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onNavigateToAnime,
|
||||
isExcluded,
|
||||
onToggleExclusion,
|
||||
}: WordDetailPanelProps) {
|
||||
const { data, loading, error } = useWordDetail(wordId);
|
||||
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||
const [occLoading, setOccLoading] = useState(false);
|
||||
const [occLoadingMore, setOccLoadingMore] = useState(false);
|
||||
const [occError, setOccError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [occLoaded, setOccLoaded] = useState(false);
|
||||
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setOccurrences([]);
|
||||
setOccLoaded(false);
|
||||
setOccLoading(false);
|
||||
setOccLoadingMore(false);
|
||||
setOccError(null);
|
||||
setHasMore(false);
|
||||
setMineStatus({});
|
||||
requestIdRef.current++;
|
||||
}, [wordId]);
|
||||
|
||||
if (wordId === null) return null;
|
||||
|
||||
const loadOccurrences = async (
|
||||
detail: NonNullable<typeof data>['detail'],
|
||||
offset: number,
|
||||
limit: number,
|
||||
append: boolean,
|
||||
) => {
|
||||
const reqId = ++requestIdRef.current;
|
||||
if (append) {
|
||||
setOccLoadingMore(true);
|
||||
} else {
|
||||
setOccLoading(true);
|
||||
setOccError(null);
|
||||
}
|
||||
try {
|
||||
const rows = await apiClient.getWordOccurrences(
|
||||
detail.headword,
|
||||
detail.word,
|
||||
detail.reading,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
|
||||
setHasMore(rows.length === limit);
|
||||
} catch (err) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccError(err instanceof Error ? err.message : String(err));
|
||||
if (!append) {
|
||||
setOccurrences([]);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccLoading(false);
|
||||
setOccLoadingMore(false);
|
||||
setOccLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowOccurrences = () => {
|
||||
if (!data) return;
|
||||
void loadOccurrences(data.detail, 0, INITIAL_PAGE_SIZE, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!data || occLoadingMore || !hasMore) return;
|
||||
void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true);
|
||||
};
|
||||
|
||||
const handleMine = async (
|
||||
occ: VocabularyOccurrenceEntry,
|
||||
mode: 'word' | 'sentence' | 'audio',
|
||||
) => {
|
||||
if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
|
||||
try {
|
||||
const result = await apiClient.mineCard({
|
||||
sourcePath: occ.sourcePath!,
|
||||
startMs: occ.segmentStartMs!,
|
||||
endMs: occ.segmentEndMs!,
|
||||
sentence: occ.text,
|
||||
word: data!.detail.headword,
|
||||
secondaryText: occ.secondaryText,
|
||||
videoTitle: occ.videoTitle,
|
||||
mode,
|
||||
});
|
||||
if (result.error) {
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } }));
|
||||
} else {
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
|
||||
const label =
|
||||
mode === 'audio'
|
||||
? 'Audio card'
|
||||
: mode === 'word'
|
||||
? data!.detail.headword
|
||||
: occ.text.slice(0, 30);
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
|
||||
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
|
||||
Notification.requestPermission().then((p) => {
|
||||
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setMineStatus((prev) => ({
|
||||
...prev,
|
||||
[key]: { error: err instanceof Error ? err.message : String(err) },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close word detail panel"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
Word Detail
|
||||
</div>
|
||||
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
|
||||
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
|
||||
{data && (
|
||||
<>
|
||||
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">
|
||||
{data.detail.headword}
|
||||
</h2>
|
||||
<div className="mt-1 text-sm text-ctp-subtext0">
|
||||
{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
|
||||
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
|
||||
{data.detail.pos1}
|
||||
</span>
|
||||
)}
|
||||
{data.detail.pos2 && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
|
||||
{data.detail.pos2}
|
||||
</span>
|
||||
)}
|
||||
{data.detail.pos3 && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
|
||||
{data.detail.pos3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{data && onToggleExclusion && (
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition ${
|
||||
isExcluded?.(data.detail)
|
||||
? 'border-ctp-red/50 bg-ctp-red/10 text-ctp-red hover:bg-ctp-red/20'
|
||||
: 'border-ctp-surface2 text-ctp-subtext0 hover:border-ctp-red hover:text-ctp-red'
|
||||
}`}
|
||||
onClick={() => onToggleExclusion(data.detail)}
|
||||
>
|
||||
{isExcluded?.(data.detail) ? 'Excluded' : 'Exclude'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-lg font-bold text-ctp-blue">
|
||||
{formatNumber(data.detail.frequency)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-green">
|
||||
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.firstSeen))}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-mauve">
|
||||
{formatRelativeDate(epochMsFromDbTimestamp(data.detail.lastSeen))}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.animeAppearances.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Anime Appearances
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.animeAppearances.map((a) => (
|
||||
<button
|
||||
key={a.animeId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onNavigateToAnime?.(a.animeId);
|
||||
}}
|
||||
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-blue hover:ring-1 hover:ring-ctp-blue text-left"
|
||||
>
|
||||
<span className="truncate text-ctp-text">{a.animeTitle}</span>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-ctp-blue/10 px-2 py-0.5 text-[11px] font-medium text-ctp-blue">
|
||||
{formatNumber(a.occurrenceCount)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.similarWords.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Similar Words
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.similarWords.map((sw) => (
|
||||
<button
|
||||
key={sw.wordId}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
|
||||
onClick={() => onSelectWord?.(sw.wordId)}
|
||||
>
|
||||
{sw.headword}
|
||||
<span className="opacity-60">({formatNumber(sw.frequency)})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Example Lines
|
||||
</h3>
|
||||
{!occLoaded && !occLoading && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={handleShowOccurrences}
|
||||
>
|
||||
Load example lines
|
||||
</button>
|
||||
)}
|
||||
{occLoading && (
|
||||
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
|
||||
)}
|
||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||
{occLoaded && !occLoading && occurrences.length === 0 && (
|
||||
<div className="text-sm text-ctp-overlay2">
|
||||
No example lines tracked yet. Lines are stored for sessions recorded after the
|
||||
subtitle tracking update.
|
||||
</div>
|
||||
)}
|
||||
{occurrences.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{occurrences.map((occ, idx) => (
|
||||
<article
|
||||
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
|
||||
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||
{occ.animeTitle ?? occ.videoTitle}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ctp-subtext0">
|
||||
{occ.videoTitle} · line {occ.lineIndex}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
|
||||
{formatNumber(occ.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||
<span>
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
|
||||
· session {occ.sessionId}
|
||||
</span>
|
||||
{(() => {
|
||||
const canMine =
|
||||
!!occ.sourcePath &&
|
||||
occ.segmentStartMs != null &&
|
||||
occ.segmentEndMs != null;
|
||||
const unavailableReason = canMine
|
||||
? null
|
||||
: occ.sourcePath
|
||||
? 'This line is missing segment timing.'
|
||||
: 'This source has no local file path.';
|
||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||
const wordStatus = mineStatus[`${baseKey}-word`];
|
||||
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
|
||||
const audioStatus = mineStatus[`${baseKey}-audio`];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={unavailableReason ?? 'Mine this word from video clip'}
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={wordStatus?.loading || !!unavailableReason}
|
||||
onClick={() => void handleMine(occ, 'word')}
|
||||
>
|
||||
{wordStatus?.loading
|
||||
? 'Mining...'
|
||||
: wordStatus?.success
|
||||
? 'Mined!'
|
||||
: unavailableReason
|
||||
? 'Unavailable'
|
||||
: 'Mine Word'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
unavailableReason ?? 'Mine this sentence from video clip'
|
||||
}
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading || !!unavailableReason}
|
||||
onClick={() => void handleMine(occ, 'sentence')}
|
||||
>
|
||||
{sentenceStatus?.loading
|
||||
? 'Mining...'
|
||||
: sentenceStatus?.success
|
||||
? 'Mined!'
|
||||
: unavailableReason
|
||||
? 'Unavailable'
|
||||
: 'Mine Sentence'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={unavailableReason ?? 'Mine this line as audio-only card'}
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={audioStatus?.loading || !!unavailableReason}
|
||||
onClick={() => void handleMine(occ, 'audio')}
|
||||
>
|
||||
{audioStatus?.loading
|
||||
? 'Mining...'
|
||||
: audioStatus?.success
|
||||
? 'Mined!'
|
||||
: unavailableReason
|
||||
? 'Unavailable'
|
||||
: 'Mine Audio'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||
const errors = ['word', 'sentence', 'audio']
|
||||
.map((m) => mineStatus[`${baseKey}-${m}`]?.error)
|
||||
.filter(Boolean);
|
||||
return errors.length > 0 ? (
|
||||
<div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div>
|
||||
) : null;
|
||||
})()}
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={handleLoadMore}
|
||||
disabled={occLoadingMore}
|
||||
>
|
||||
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
stats/src/components/vocabulary/WordList.tsx
Normal file
130
stats/src/components/vocabulary/WordList.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
|
||||
interface WordListProps {
|
||||
words: VocabularyEntry[];
|
||||
selectedKey?: string | null;
|
||||
onSelectWord?: (word: VocabularyEntry) => void;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
type SortKey = 'frequency' | 'lastSeen' | 'firstSeen';
|
||||
|
||||
function toWordKey(word: VocabularyEntry): string {
|
||||
return `${word.headword}\u0000${word.word}\u0000${word.reading}`;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function WordList({ words, selectedKey = null, onSelectWord, search = '' }: WordListProps) {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('frequency');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const titleBySort: Record<SortKey, string> = {
|
||||
frequency: 'Most Seen Words',
|
||||
lastSeen: 'Recently Seen Words',
|
||||
firstSeen: 'First Seen Words',
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return words;
|
||||
return words.filter(
|
||||
(w) =>
|
||||
w.headword.toLowerCase().includes(needle) ||
|
||||
w.word.toLowerCase().includes(needle) ||
|
||||
w.reading.toLowerCase().includes(needle),
|
||||
);
|
||||
}, [words, search]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
if (sortBy === 'frequency') copy.sort((a, b) => b.frequency - a.frequency);
|
||||
else if (sortBy === 'lastSeen') copy.sort((a, b) => b.lastSeen - a.lastSeen);
|
||||
else copy.sort((a, b) => b.firstSeen - a.firstSeen);
|
||||
return copy;
|
||||
}, [filtered, sortBy]);
|
||||
|
||||
const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
|
||||
const paged = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const maxFreq = words.reduce((max, word) => Math.max(max, word.frequency), 1);
|
||||
|
||||
const getFrequencyColor = (freq: number) => {
|
||||
const ratio = freq / maxFreq;
|
||||
if (ratio > 0.5) return 'text-ctp-blue bg-ctp-blue/10';
|
||||
if (ratio > 0.2) return 'text-ctp-green bg-ctp-green/10';
|
||||
return 'text-ctp-mauve bg-ctp-mauve/10';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">
|
||||
{titleBySort[sortBy]}
|
||||
{search && (
|
||||
<span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>
|
||||
)}
|
||||
</h3>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as SortKey);
|
||||
setPage(0);
|
||||
}}
|
||||
className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1"
|
||||
>
|
||||
<option value="frequency">Frequency</option>
|
||||
<option value="lastSeen">Last Seen</option>
|
||||
<option value="firstSeen">First Seen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{paged.map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={toWordKey(w)}
|
||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${getFrequencyColor(
|
||||
w.frequency,
|
||||
)} ${
|
||||
selectedKey === toWordKey(w)
|
||||
? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0'
|
||||
: 'hover:ring-1 hover:ring-ctp-surface2'
|
||||
}`}
|
||||
title={`${w.word} (${w.reading}) — seen ${w.frequency}x`}
|
||||
onClick={() => onSelectWord?.(w)}
|
||||
>
|
||||
{w.headword}
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
<span className="opacity-60">({w.frequency})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-ctp-overlay1">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { toWordKey };
|
||||
38
stats/src/components/vocabulary/pos-helpers.tsx
Normal file
38
stats/src/components/vocabulary/pos-helpers.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
const POS_COLORS: Record<string, string> = {
|
||||
noun: 'bg-ctp-blue/15 text-ctp-blue',
|
||||
verb: 'bg-ctp-green/15 text-ctp-green',
|
||||
adjective: 'bg-ctp-mauve/15 text-ctp-mauve',
|
||||
adverb: 'bg-ctp-peach/15 text-ctp-peach',
|
||||
particle: 'bg-ctp-overlay0/15 text-ctp-overlay0',
|
||||
auxiliary_verb: 'bg-ctp-overlay0/15 text-ctp-overlay0',
|
||||
conjunction: 'bg-ctp-overlay0/15 text-ctp-overlay0',
|
||||
prenominal: 'bg-ctp-yellow/15 text-ctp-yellow',
|
||||
suffix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
|
||||
prefix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
|
||||
interjection: 'bg-ctp-rosewater/15 text-ctp-rosewater',
|
||||
};
|
||||
|
||||
const DEFAULT_POS_COLOR = 'bg-ctp-surface1 text-ctp-subtext0';
|
||||
|
||||
export function posColor(pos: string): string {
|
||||
return POS_COLORS[pos] ?? DEFAULT_POS_COLOR;
|
||||
}
|
||||
|
||||
export function PosBadge({ pos }: { pos: string }) {
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}>
|
||||
{pos.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const PARTICLE_POS = new Set(['particle', 'auxiliary_verb', 'conjunction']);
|
||||
|
||||
export function isFilterable(entry: VocabularyEntry): boolean {
|
||||
if (PARTICLE_POS.has(entry.partOfSpeech ?? '')) return true;
|
||||
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user