chore: apply remaining workspace formatting and updates

This commit is contained in:
2026-03-16 01:54:35 -07:00
parent 77c35c770d
commit a9e33618e7
82 changed files with 1530 additions and 736 deletions

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />

View File

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

View File

@@ -18,7 +18,12 @@ interface AnilistSelectorProps {
onLinked: () => void;
}
export function AnilistSelector({ animeId, initialQuery, onClose, onLinked }: AnilistSelectorProps) {
export function AnilistSelector({
animeId,
initialQuery,
onClose,
onLinked,
}: AnilistSelectorProps) {
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<AnilistMedia[]>([]);
const [loading, setLoading] = useState(false);
@@ -32,7 +37,10 @@ export function AnilistSelector({ animeId, initialQuery, onClose, onLinked }: An
}, []);
const doSearch = async (q: string) => {
if (!q.trim()) { setResults([]); return; }
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const data = await apiClient.searchAnilist(q.trim());

View File

@@ -37,7 +37,9 @@ export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
{withCards.map((ep) => (
<Fragment key={ep.videoId}>
<tr
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)}
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">

View File

@@ -13,7 +13,9 @@ export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverIm
if (failed) {
return (
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}>
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);

View File

@@ -32,9 +32,15 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
let cancelled = false;
getStatsClient()
.getAnimeRollups(animeId, 90)
.then((data) => { if (!cancelled) setRollups(data); })
.catch(() => { if (!cancelled) setRollups([]); });
return () => { cancelled = true; };
.then((data) => {
if (!cancelled) setRollups(data);
})
.catch(() => {
if (!cancelled) setRollups([]);
});
return () => {
cancelled = true;
};
}, [animeId]);
const byDay = new Map<number, number>();
@@ -75,8 +81,18 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
</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} />
<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,
@@ -104,9 +120,8 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data;
const avgSessionMs = detail.totalSessions > 0
? Math.round(detail.totalActiveMs / detail.totalSessions)
: 0;
const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
return (
<div className="space-y-4">
@@ -123,9 +138,17 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
onChangeAnilist={() => setShowAnilistSelector(true)}
/>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
<StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" />
<StatCard
label="Watch Time"
value={formatDuration(detail.totalActiveMs)}
color="text-ctp-blue"
/>
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
<StatCard label="Words" value={formatNumber(detail.totalWordsSeen)} color="text-ctp-mauve" />
<StatCard
label="Words"
value={formatNumber(detail.totalWordsSeen)}
color="text-ctp-mauve"
/>
<StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" />
<StatCard label="Avg Session" value={formatDuration(avgSessionMs)} />
</div>

View File

@@ -8,9 +8,10 @@ interface AnimeHeaderProps {
}
function AnilistButton({ entry }: { entry: AnilistEntry }) {
const label = entry.season != null
? `Season ${entry.season}`
: entry.titleEnglish ?? entry.titleRomaji ?? 'AniList';
const label =
entry.season != null
? `Season ${entry.season}`
: (entry.titleEnglish ?? entry.titleRomaji ?? 'AniList');
return (
<a
@@ -26,8 +27,9 @@ function AnilistButton({ entry }: { entry: AnilistEntry }) {
}
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 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;
@@ -52,9 +54,7 @@ export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHe
<div className="flex flex-wrap gap-1.5 mt-2">
{anilistEntries.length > 0 ? (
hasMultipleEntries ? (
anilistEntries.map((entry) => (
<AnilistButton key={entry.anilistId} entry={entry} />
))
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
) : (
<a
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
@@ -82,7 +82,9 @@ export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHe
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'}
{anilistEntries.length > 0 || detail.anilistId
? 'Change AniList Entry'
: 'Link to AniList'}
</button>
)}
</div>

View File

@@ -23,10 +23,14 @@ const SORT_OPTIONS: { key: SortKey; label: string }[] = [
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;
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;
}
});
}
@@ -89,7 +93,9 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
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>
<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">

View File

@@ -18,10 +18,18 @@ export function AnimeWordList({ animeId, onNavigateToWord }: AnimeWordListProps)
setLoading(true);
getStatsClient()
.getAnimeWords(animeId, 50)
.then((data) => { if (!cancelled) setWords(data); })
.catch(() => { if (!cancelled) setWords([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
.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>;

View File

@@ -6,7 +6,11 @@ interface CollapsibleSectionProps {
children: React.ReactNode;
}
export function CollapsibleSection({ title, defaultOpen = true, children }: CollapsibleSectionProps) {
export function CollapsibleSection({
title,
defaultOpen = true,
children,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
const contentId = useId();
@@ -20,9 +24,15 @@ export function CollapsibleSection({ title, defaultOpen = true, children }: Coll
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>
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">
{open ? '▲' : '▼'}
</span>
</button>
{open && <div id={contentId} className="px-4 pb-4">{children}</div>}
{open && (
<div id={contentId} className="px-4 pb-4">
{children}
</div>
)}
</div>
);
}

View File

@@ -23,19 +23,28 @@ const COLOR_TO_BORDER: Record<string, string> = {
'text-ctp-text': 'border-l-ctp-surface2',
};
export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) {
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={`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>
)}
{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
className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}
>
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'}{' '}
{trend.text}
</div>
)}
</div>

View File

@@ -13,7 +13,9 @@ export function CoverImage({ videoId, title, className = '' }: CoverImageProps)
if (failed) {
return (
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}>
<div
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
>
{fallbackChar}
</div>
);

View File

@@ -7,12 +7,10 @@ interface MediaHeaderProps {
}
export function MediaHeader({ detail }: MediaHeaderProps) {
const hitRate = detail.totalLookupCount > 0
? detail.totalLookupHits / detail.totalLookupCount
: null;
const avgSessionMs = detail.totalSessions > 0
? Math.round(detail.totalActiveMs / detail.totalSessions)
: 0;
const hitRate =
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
return (
<div className="flex gap-4">

View File

@@ -58,8 +58,18 @@ export function MediaWatchChart({ rollups }: MediaWatchChartProps) {
</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} />
<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,

View File

@@ -10,9 +10,7 @@ interface HeroStatsProps {
export function HeroStats({ summary, sessions }: HeroStatsProps) {
const today = todayLocalDay();
const sessionsToday = sessions.filter(
(s) => localDayFromMs(s.startedAtMs) === today,
).length;
const sessionsToday = sessions.filter((s) => localDayFromMs(s.startedAtMs) === today).length;
return (
<div className="grid grid-cols-2 xl:grid-cols-6 gap-3">
@@ -36,11 +34,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
value={formatNumber(summary.episodesToday)}
color="text-ctp-teal"
/>
<StatCard
label="Current Streak"
value={`${summary.streakDays}d`}
color="text-ctp-peach"
/>
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
<StatCard
label="Active Anime"
value={formatNumber(summary.activeAnimeCount)}

View File

@@ -37,7 +37,8 @@ export function OverviewTab() {
<h3 className="text-sm font-semibold text-ctp-text mb-3">Tracking Snapshot</h3>
{showTrackedCardNote && (
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
No tracked card-add events in the current immersion DB yet. New cards mined after this fix will show here.
No tracked card-add events in the current immersion DB yet. New cards mined after this
fix will show here.
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm">
@@ -72,7 +73,9 @@ export function OverviewTab() {
</div>
</div>
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Completed</div>
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
Episodes Completed
</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
{formatNumber(summary.totalEpisodesWatched)}
</div>

View File

@@ -1,5 +1,11 @@
import { useState } from 'react';
import { formatDuration, formatRelativeDate, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
import {
formatDuration,
formatRelativeDate,
formatNumber,
todayLocalDay,
localDayFromMs,
} from '../../lib/formatters';
import { BASE_URL } from '../../lib/api-client';
import type { SessionSummary } from '../../types/stats';
@@ -50,11 +56,12 @@ 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 key =
session.animeId != null
? `anime-${session.animeId}`
: session.videoId != null
? `video-${session.videoId}`
: `session-${session.sessionId}`;
const existing = map.get(key);
if (existing) {
@@ -99,7 +106,8 @@ function CoverThumbnail({ videoId, title }: { videoId: number | null; title: str
const target = e.currentTarget;
target.style.display = 'none';
const placeholder = document.createElement('div');
placeholder.className = 'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0';
placeholder.className =
'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0';
placeholder.textContent = fallbackChar;
target.parentElement?.insertBefore(placeholder, target);
}}
@@ -116,16 +124,21 @@ function SessionItem({ session }: { session: SessionSummary }) {
{session.canonicalTitle ?? 'Unknown Media'}
</div>
<div className="text-xs text-ctp-overlay2">
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)} active
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(session.cardsMined)}</div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(session.wordsSeen)}</div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(session.wordsSeen)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
@@ -152,20 +165,22 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
>
<CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ctp-text truncate">
{displayTitle}
</div>
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
<div className="text-xs text-ctp-overlay2">
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(group.totalCards)}</div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(group.totalWords)}</div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>
@@ -193,11 +208,15 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(s.cardsMined)}</div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(s.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(s.wordsSeen)}</div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(s.wordsSeen)}
</div>
<div className="text-ctp-overlay2">words</div>
</div>
</div>

View File

@@ -1,6 +1,13 @@
import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceArea, ReferenceLine,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceArea,
ReferenceLine,
} from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme';
@@ -28,7 +35,10 @@ function formatTime(ms: number): string {
});
}
interface PauseRegion { startMs: number; endMs: number }
interface PauseRegion {
startMs: number;
endMs: number;
}
function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
const regions: PauseRegion[] = [];
@@ -216,7 +226,13 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
<div className="flex flex-wrap items-center gap-4 text-[11px]">
<span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-2 rounded-sm" style={{ background: 'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))' }} />
<span
className="inline-block w-3 h-2 rounded-sm"
style={{
background:
'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))',
}}
/>
<span className="text-ctp-overlay2">New words</span>
</span>
<span className="flex items-center gap-1.5">
@@ -225,19 +241,35 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
</span>
{pauseCount > 0 && (
<span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-2 rounded-sm" style={{ background: 'rgba(245,169,127,0.2)', border: '1px solid rgba(245,169,127,0.5)' }} />
<span className="text-ctp-overlay2">{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span>
<span
className="inline-block w-3 h-2 rounded-sm"
style={{
background: 'rgba(245,169,127,0.2)',
border: '1px solid rgba(245,169,127,0.5)',
}}
/>
<span className="text-ctp-overlay2">
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
</span>
</span>
)}
{seekCount > 0 && (
<span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#91d7e3', opacity: 0.7 }} />
<span className="text-ctp-overlay2">{seekCount} seek{seekCount !== 1 ? 's' : ''}</span>
<span
className="inline-block w-3 h-0.5 rounded"
style={{ background: '#91d7e3', opacity: 0.7 }}
/>
<span className="text-ctp-overlay2">
{seekCount} seek{seekCount !== 1 ? 's' : ''}
</span>
</span>
)}
<span className="flex items-center gap-1.5">
<span className="text-[12px]"></span>
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span>
<span className="text-ctp-green">
{Math.max(cardEventCount, cardsMined)} card
{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
</span>
</span>
</div>
</div>

View File

@@ -56,7 +56,7 @@ export function DateRangeSelector({
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
value={range}
onChange={onRangeChange}
formatLabel={(r) => r === 'all' ? 'All' : r}
formatLabel={(r) => (r === 'all' ? 'All' : r)}
/>
<SegmentedControl
label="Group by"

View File

@@ -1,6 +1,4 @@
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from 'recharts';
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { epochDayToDate } from '../../lib/formatters';
export interface PerAnimeDataPoint {
@@ -15,8 +13,14 @@ interface StackedTrendChartProps {
}
const LINE_COLORS = [
'#8aadf4', '#c6a0f6', '#a6da95', '#f5a97f', '#f5bde6',
'#91d7e3', '#ee99a0', '#f4dbd6',
'#8aadf4',
'#c6a0f6',
'#a6da95',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
function buildLineData(raw: PerAnimeDataPoint[]) {
@@ -41,7 +45,10 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
.sort(([a], [b]) => a - b)
.map(([epochDay, values]) => {
const row: Record<string, string | number> = {
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
};
for (const title of topTitles) {
row[title] = values[title] ?? 0;
@@ -56,7 +63,11 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
const { points, seriesKeys } = buildLineData(data);
const tooltipStyle = {
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
if (points.length === 0) {
@@ -73,8 +84,18 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
<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} />
<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

View File

@@ -1,6 +1,12 @@
import {
BarChart, Bar, LineChart, Line,
XAxis, YAxis, Tooltip, ResponsiveContainer,
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
interface TrendChartProps {
@@ -14,10 +20,14 @@ interface TrendChartProps {
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
const tooltipStyle = {
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
background: '#363a4f',
border: '1px solid #494d64',
borderRadius: 6,
color: '#cad3f5',
fontSize: 12,
};
const formatValue = (v: number) => formatter ? [formatter(v), title] : [String(v), title];
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">
@@ -25,21 +35,43 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
<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} />
<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}
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} />
<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>

View File

@@ -129,7 +129,9 @@ export function TrendsTab() {
const watchByHour = buildWatchTimeByHour(data.sessions);
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
epochDay: e.epochDay, animeTitle: e.animeTitle, value: e.totalActiveMin,
epochDay: e.epochDay,
animeTitle: e.animeTitle,
value: e.totalActiveMin,
}));
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
@@ -149,7 +151,12 @@ export function TrendsTab() {
/>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<SectionHeader>Activity</SectionHeader>
<TrendChart title="Watch Time (min)" data={dashboard.watchTime} color="#8aadf4" type="bar" />
<TrendChart
title="Watch Time (min)"
data={dashboard.watchTime}
color="#8aadf4"
type="bar"
/>
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
@@ -172,8 +179,18 @@ export function TrendsTab() {
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} />
<SectionHeader>Patterns</SectionHeader>
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" />
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" />
<TrendChart
title="Watch Time by Day of Week (min)"
data={watchByDow}
color="#8aadf4"
type="bar"
/>
<TrendChart
title="Watch Time by Hour (min)"
data={watchByHour}
color="#c6a0f6"
type="bar"
/>
</div>
</div>
);

View File

@@ -7,7 +7,12 @@ interface ExclusionManagerProps {
onClose: () => void;
}
export function ExclusionManager({ excluded, onRemove, onClearAll, onClose }: ExclusionManagerProps) {
export function ExclusionManager({
excluded,
onRemove,
onClearAll,
onClose,
}: ExclusionManagerProps) {
return (
<div className="fixed inset-0 z-50">
<button
@@ -44,11 +49,12 @@ export function ExclusionManager({ excluded, onRemove, onClearAll, onClose }: Ex
<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.
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 => (
{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"

View File

@@ -56,7 +56,8 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<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.
No frequency rank data available. Run the frequency backfill script or install a frequency
dictionary.
</div>
</div>
);
@@ -73,14 +74,21 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
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>
<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); }}
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'
@@ -90,9 +98,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
Hide Known
</button>
)}
<span className="text-xs text-ctp-overlay2">
{ranked.length} words
</span>
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
</div>
</div>
{collapsed ? null : ranked.length === 0 ? (
@@ -122,9 +128,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<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-text font-medium">{w.headword}</td>
<td className="py-1.5 pr-3 text-ctp-subtext0">
{fullReading(w.headword, w.reading) || w.headword}
</td>
@@ -149,7 +153,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
>
Prev
</button>
<span className="text-ctp-overlay2">{page + 1} / {totalPages}</span>
<span className="text-ctp-overlay2">
{page + 1} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages - 1}

View File

@@ -21,7 +21,12 @@ function formatSegment(ms: number | null): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToAnime }: KanjiDetailPanelProps) {
export function KanjiDetailPanel({
kanjiId,
onClose,
onSelectWord,
onNavigateToAnime,
}: KanjiDetailPanelProps) {
const { data, loading, error } = useKanjiDetail(kanjiId);
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
const [occLoading, setOccLoading] = useState(false);
@@ -44,7 +49,7 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
try {
const rows = await apiClient.getKanjiOccurrences(kanji, OCCURRENCES_PAGE_SIZE, offset);
if (reqId !== requestIdRef.current) return;
setOccurrences(prev => append ? [...prev, ...rows] : rows);
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
} catch (err) {
if (reqId !== requestIdRef.current) return;
@@ -83,7 +88,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
<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>
<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 && (
@@ -109,28 +116,39 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
<>
<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-lg font-bold text-ctp-teal">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div>
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div>
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>
</div>
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3>
<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 => (
{data.animeAppearances.map((a) => (
<button
key={a.animeId}
type="button"
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }}
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>
@@ -145,9 +163,11 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
{data.words.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Words Using This Kanji</h3>
<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 => (
{data.words.map((w) => (
<button
key={w.wordId}
type="button"
@@ -163,7 +183,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
)}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && (
<button
type="button"
@@ -173,7 +195,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
Load example lines
</button>
)}
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
{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>
@@ -199,7 +223,8 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
</div>
</div>
<div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}
{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}

View File

@@ -90,7 +90,9 @@ export function VocabularyOccurrencesDrawer({
</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 ? (
<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>
@@ -116,8 +118,8 @@ export function VocabularyOccurrencesDrawer({
</div>
</div>
<div className="mt-3 text-xs text-ctp-overlay1">
{formatSegment(occurrence.segmentStartMs)}-{formatSegment(occurrence.segmentEndMs)} · session{' '}
{occurrence.sessionId}
{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}

View File

@@ -26,7 +26,14 @@ function isProperNoun(w: VocabularyEntry): boolean {
return w.pos2 === '固有名詞';
}
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, isExcluded, onRemoveExclusion, onClearExclusions }: VocabularyTabProps) {
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('');
@@ -63,7 +70,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
};
const handleBarClick = (headword: string): void => {
const match = filteredWords.find(w => w.headword === headword);
const match = filteredWords.find((w) => w.headword === headword);
if (match) onOpenWordDetail?.(match.wordId);
};
@@ -74,8 +81,16 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
return (
<div className="space-y-4">
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
<StatCard label="Unique Words" value={formatNumber(summary.uniqueWords)} color="text-ctp-blue" />
<StatCard label="Unique Kanji" value={formatNumber(summary.uniqueKanji)} color="text-ctp-green" />
<StatCard
label="Unique Words"
value={formatNumber(summary.uniqueWords)}
color="text-ctp-blue"
/>
<StatCard
label="Unique Kanji"
value={formatNumber(summary.uniqueKanji)}
color="text-ctp-green"
/>
<StatCard
label="New This Week"
value={`+${formatNumber(summary.newThisWeek)}`}
@@ -133,9 +148,17 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
/>
</div>
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
<FrequencyRankTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<CrossAnimeWordsTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
<CrossAnimeWordsTable
words={filteredWords}
knownWords={knownWords}
onSelectWord={handleSelectWord}
/>
<WordList
words={filteredWords}

View File

@@ -24,15 +24,22 @@ 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 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
needleSet.has(part) ? (
<mark
key={i}
className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2"
>
{part}
</mark>
) : (
part
),
);
}
@@ -44,7 +51,14 @@ function formatSegment(ms: number | null): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime, isExcluded, onToggleExclusion }: WordDetailPanelProps) {
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);
@@ -68,7 +82,12 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
if (wordId === null) return null;
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: number, limit: number, append: boolean) => {
const loadOccurrences = async (
detail: NonNullable<typeof data>['detail'],
offset: number,
limit: number,
append: boolean,
) => {
const reqId = ++requestIdRef.current;
if (append) {
setOccLoadingMore(true);
@@ -78,11 +97,14 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
}
try {
const rows = await apiClient.getWordOccurrences(
detail.headword, detail.word, detail.reading,
limit, offset,
detail.headword,
detail.word,
detail.reading,
limit,
offset,
);
if (reqId !== requestIdRef.current) return;
setOccurrences(prev => append ? [...prev, ...rows] : rows);
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
setHasMore(rows.length === limit);
} catch (err) {
if (reqId !== requestIdRef.current) return;
@@ -109,9 +131,12 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true);
};
const handleMine = async (occ: VocabularyOccurrenceEntry, mode: 'word' | 'sentence' | 'audio') => {
const handleMine = async (
occ: VocabularyOccurrenceEntry,
mode: 'word' | 'sentence' | 'audio',
) => {
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
setMineStatus(prev => ({ ...prev, [key]: { loading: true } }));
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try {
const result = await apiClient.mineCard({
sourcePath: occ.sourcePath!,
@@ -124,20 +149,28 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
mode,
});
if (result.error) {
setMineStatus(prev => ({ ...prev, [key]: { error: 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);
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 => {
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) } }));
setMineStatus((prev) => ({
...prev,
[key]: { error: err instanceof Error ? err.message : String(err) },
}));
}
};
@@ -153,23 +186,35 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
<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>
<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>
<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>
<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>
<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>
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
{data.detail.pos3}
</span>
)}
</div>
</>
@@ -204,28 +249,39 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
<>
<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-lg font-bold text-ctp-blue">
{formatNumber(data.detail.frequency)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div>
<div className="text-sm font-medium text-ctp-green">
{formatRelativeDate(data.detail.firstSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
</div>
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div>
<div className="text-sm font-medium text-ctp-mauve">
{formatRelativeDate(data.detail.lastSeen)}
</div>
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
</div>
</div>
{data.animeAppearances.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3>
<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 => (
{data.animeAppearances.map((a) => (
<button
key={a.animeId}
type="button"
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }}
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>
@@ -240,9 +296,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
{data.similarWords.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Similar Words</h3>
<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 => (
{data.similarWords.map((sw) => (
<button
key={sw.wordId}
type="button"
@@ -258,7 +316,9 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
)}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Example Lines
</h3>
{!occLoaded && !occLoading && (
<button
type="button"
@@ -268,10 +328,15 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
Load example lines
</button>
)}
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
{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>
<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">
@@ -294,48 +359,68 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
</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>
{occ.sourcePath && occ.segmentStartMs != null && occ.segmentEndMs != null && (() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
const audioStatus = mineStatus[`${baseKey}-audio`];
return (
<>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
disabled={wordStatus?.loading}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading ? 'Mining...' : wordStatus?.success ? 'Mined!' : 'Mine Word'}
</button>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
disabled={sentenceStatus?.loading}
onClick={() => void handleMine(occ, 'sentence')}
>
{sentenceStatus?.loading ? 'Mining...' : sentenceStatus?.success ? 'Mined!' : 'Mine Sentence'}
</button>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
disabled={audioStatus?.loading}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading ? 'Mining...' : audioStatus?.success ? 'Mined!' : 'Mine Audio'}
</button>
</>
);
})()}
<span>
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
· session {occ.sessionId}
</span>
{occ.sourcePath &&
occ.segmentStartMs != null &&
occ.segmentEndMs != null &&
(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const wordStatus = mineStatus[`${baseKey}-word`];
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
const audioStatus = mineStatus[`${baseKey}-audio`];
return (
<>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
disabled={wordStatus?.loading}
onClick={() => void handleMine(occ, 'word')}
>
{wordStatus?.loading
? 'Mining...'
: wordStatus?.success
? 'Mined!'
: 'Mine Word'}
</button>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
disabled={sentenceStatus?.loading}
onClick={() => void handleMine(occ, 'sentence')}
>
{sentenceStatus?.loading
? 'Mining...'
: sentenceStatus?.success
? 'Mined!'
: 'Mine Sentence'}
</button>
<button
type="button"
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
disabled={audioStatus?.loading}
onClick={() => void handleMine(occ, 'audio')}
>
{audioStatus?.loading
? 'Mining...'
: audioStatus?.success
? 'Mined!'
: 'Mine Audio'}
</button>
</>
);
})()}
</div>
{(() => {
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
const errors = ['word', 'sentence', 'audio']
.map(m => mineStatus[`${baseKey}-${m}`]?.error)
.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;
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])}

View File

@@ -31,9 +31,10 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
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),
(w) =>
w.headword.toLowerCase().includes(needle) ||
w.word.toLowerCase().includes(needle) ||
w.reading.toLowerCase().includes(needle),
);
}, [words, search]);
@@ -61,11 +62,16 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
<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>}
{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); }}
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>
@@ -78,9 +84,9 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
<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)
} ${
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'
@@ -89,9 +95,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
onClick={() => onSelectWord?.(w)}
>
{w.headword}
{w.partOfSpeech && (
<PosBadge pos={w.partOfSpeech} />
)}
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
<span className="opacity-60">({w.frequency})</span>
</button>
))}
@@ -102,7 +106,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
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)}
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
@@ -113,7 +117,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
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)}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>

View File

@@ -32,6 +32,7 @@ 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;
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword))
return true;
return false;
}

View File

@@ -11,10 +11,18 @@ export function useAnimeLibrary() {
let cancelled = false;
getStatsClient()
.getAnimeLibrary()
.then((data) => { if (!cancelled) setAnime(data); })
.catch((err: Error) => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
.then((data) => {
if (!cancelled) setAnime(data);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { anime, loading, error };

View File

@@ -57,25 +57,19 @@ export function useExcludedWords() {
[excluded],
);
const toggleExclusion = useCallback(
(w: ExcludedWord) => {
const key = toKey(w);
const current = load();
if (getKeySet().has(key)) {
persist(current.filter(e => toKey(e) !== key));
} else {
persist([...current, w]);
}
},
[],
);
const toggleExclusion = useCallback((w: ExcludedWord) => {
const key = toKey(w);
const current = load();
if (getKeySet().has(key)) {
persist(current.filter((e) => toKey(e) !== key));
} else {
persist([...current, w]);
}
}, []);
const removeExclusion = useCallback(
(w: ExcludedWord) => {
persist(load().filter(e => toKey(e) !== toKey(w)));
},
[],
);
const removeExclusion = useCallback((w: ExcludedWord) => {
persist(load().filter((e) => toKey(e) !== toKey(w)));
}, []);
const clearAll = useCallback(() => persist([]), []);

View File

@@ -11,10 +11,18 @@ export function useStreakCalendar(days = 90) {
let cancelled = false;
getStatsClient()
.getStreakCalendar(days)
.then((data) => { if (!cancelled) setCalendar(data); })
.catch((err: Error) => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
.then((data) => {
if (!cancelled) setCalendar(data);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [days]);
return { calendar, loading, error };

View File

@@ -1,6 +1,14 @@
import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { DailyRollup, MonthlyRollup, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime, SessionSummary, AnimeLibraryItem } from '../types/stats';
import type {
DailyRollup,
MonthlyRollup,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
SessionSummary,
AnimeLibraryItem,
} from '../types/stats';
export type TimeRange = '7d' | '30d' | '90d' | 'all';
export type GroupBy = 'day' | 'month';
@@ -35,9 +43,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
const monthlyLimit = Math.max(1, Math.ceil(limit / 30));
const rollupFetcher =
groupBy === 'month'
? client.getMonthlyRollups(monthlyLimit)
: client.getDailyRollups(limit);
groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit);
Promise.all([
rollupFetcher,
@@ -47,9 +53,18 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
client.getSessions(500),
client.getAnimeLibrary(),
])
.then(([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
setData({ rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary });
})
.then(
([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
setData({
rollups,
episodesPerDay,
newAnimePerDay,
watchTimePerAnime,
sessions,
animeLibrary,
});
},
)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [range, groupBy]);

View File

@@ -1,7 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DailyRollup, OverviewData, SessionSummary, StreakCalendarDay, VocabularyEntry } from '../types/stats';
import type {
DailyRollup,
OverviewData,
SessionSummary,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import {
buildOverviewSummary,
buildStreakCalendar,
@@ -49,7 +55,14 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
const overview: OverviewData = {
sessions,
rollups,
hints: { totalSessions: 1, activeSessions: 0, episodesToday: 2, activeAnimeCount: 3, totalEpisodesWatched: 5, totalAnimeCompleted: 1 },
hints: {
totalSessions: 1,
activeSessions: 0,
episodesToday: 2,
activeAnimeCount: 3,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
},
};
const summary = buildOverviewSummary(overview, now);

View File

@@ -1,4 +1,10 @@
import type { DailyRollup, KanjiEntry, OverviewData, StreakCalendarDay, VocabularyEntry } from '../types/stats';
import type {
DailyRollup,
KanjiEntry,
OverviewData,
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import { epochDayToDate, localDayFromMs } from './formatters';
export interface ChartPoint {
@@ -110,7 +116,9 @@ function buildAggregatedDailyRows(rollups: DailyRollup[]) {
averageSessionMinutes:
value.sessions > 0 ? +(value.activeMin / value.sessions).toFixed(1) : 0,
lookupHitRate:
value.lookupWeight > 0 ? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100) : 0,
value.lookupWeight > 0
? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100)
: 0,
}));
}
@@ -142,7 +150,10 @@ export function buildOverviewSummary(
return {
todayActiveMs: Math.max(todayActiveFromRollup, todayActiveFromSessions),
todayCards: Math.max(todayRow?.cards ?? 0, sumBy(todaySessions, (session) => session.cardsMined)),
todayCards: Math.max(
todayRow?.cards ?? 0,
sumBy(todaySessions, (session) => session.cardsMined),
),
streakDays,
allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60),
totalTrackedCards: Math.max(sessionCards, rollupCards),
@@ -152,17 +163,21 @@ export function buildOverviewSummary(
totalAnimeCompleted: overview.hints.totalAnimeCompleted ?? 0,
averageSessionMinutes:
overview.sessions.length > 0
? Math.round(sumBy(overview.sessions, (session) => session.activeWatchedMs) / overview.sessions.length / 60_000)
? Math.round(
sumBy(overview.sessions, (session) => session.activeWatchedMs) /
overview.sessions.length /
60_000,
)
: 0,
totalSessions: overview.hints.totalSessions,
activeDays: daysWithActivity.size,
recentWatchTime: aggregated.slice(-14).map((row) => ({ label: row.label, value: row.activeMin })),
recentWatchTime: aggregated
.slice(-14)
.map((row) => ({ label: row.label, value: row.activeMin })),
};
}
export function buildTrendDashboard(
rollups: DailyRollup[],
): TrendDashboard {
export function buildTrendDashboard(rollups: DailyRollup[]): TrendDashboard {
const aggregated = buildAggregatedDailyRows(rollups);
return {
watchTime: aggregated.map((row) => ({ label: row.label, value: row.activeMin })),

View File

@@ -1,12 +1,24 @@
import type {
OverviewData, DailyRollup, MonthlyRollup,
SessionSummary, SessionTimelinePoint, SessionEvent,
VocabularyEntry, KanjiEntry,
OverviewData,
DailyRollup,
MonthlyRollup,
SessionSummary,
SessionTimelinePoint,
SessionEvent,
VocabularyEntry,
KanjiEntry,
VocabularyOccurrenceEntry,
MediaLibraryItem, MediaDetailData,
AnimeLibraryItem, AnimeDetailData, AnimeWord,
StreakCalendarDay, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime,
WordDetailData, KanjiDetailData,
MediaLibraryItem,
MediaDetailData,
AnimeLibraryItem,
AnimeDetailData,
AnimeWord,
StreakCalendarDay,
EpisodesPerDay,
NewAnimePerDay,
WatchTimePerAnime,
WordDetailData,
KanjiDetailData,
EpisodeDetailData,
} from '../types/stats';
@@ -47,7 +59,9 @@ interface StatsElectronAPI {
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
ankiNotesInfo: (
noteIds: number[],
) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
hideOverlay: () => void;
};
}

View File

@@ -15,6 +15,6 @@ if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
</StrictMode>,
);
}

View File

@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
@theme {
--color-ctp-base: #24273a;
@@ -28,7 +28,8 @@
--color-ctp-maroon: #ee99a0;
--color-ctp-pink: #f5bde6;
--font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-sans:
'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
}