mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
chore: apply remaining workspace formatting and updates
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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([]), []);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user