mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
32
stats/src/components/library/CoverImage.tsx
Normal file
32
stats/src/components/library/CoverImage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
|
||||
interface CoverImageProps {
|
||||
videoId: number;
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
|
||||
alt={title}
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
stats/src/components/library/LibraryTab.tsx
Normal file
67
stats/src/components/library/LibraryTab.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
interface LibraryTabProps {
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
}
|
||||
|
||||
export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
const { media, loading, error } = useMediaLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return media;
|
||||
const q = search.toLowerCase();
|
||||
return media.filter((m) => m.canonicalTitle.toLowerCase().includes(q));
|
||||
}, [media, search]);
|
||||
|
||||
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return (
|
||||
<MediaDetailView
|
||||
videoId={selectedVideoId}
|
||||
onBack={() => setSelectedVideoId(null)}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search titles..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filtered.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
stats/src/components/library/MediaCard.tsx
Normal file
33
stats/src/components/library/MediaCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import type { MediaLibraryItem } from '../../types/stats';
|
||||
|
||||
interface MediaCardProps {
|
||||
item: MediaLibraryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-surface2 transition-colors text-left w-full"
|
||||
>
|
||||
<CoverImage
|
||||
videoId={item.videoId}
|
||||
title={item.canonicalTitle}
|
||||
className="w-full aspect-[3/4] rounded-t-lg"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
105
stats/src/components/library/MediaDetailView.tsx
Normal file
105
stats/src/components/library/MediaDetailView.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { MediaHeader } from './MediaHeader';
|
||||
import { MediaSessionList } from './MediaSessionList';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface MediaDetailViewProps {
|
||||
videoId: number;
|
||||
initialExpandedSessionId?: number | null;
|
||||
onConsumeInitialExpandedSession?: () => void;
|
||||
onBack: () => void;
|
||||
backLabel?: string;
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
}
|
||||
|
||||
export function MediaDetailView({
|
||||
videoId,
|
||||
initialExpandedSessionId = null,
|
||||
onConsumeInitialExpandedSession,
|
||||
onBack,
|
||||
backLabel = 'Back to Library',
|
||||
onNavigateToAnime,
|
||||
}: MediaDetailViewProps) {
|
||||
const { data, loading, error } = useMediaDetail(videoId);
|
||||
const [localSessions, setLocalSessions] = useState<SessionSummary[] | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSessions(data?.sessions ?? null);
|
||||
}, [data?.sessions]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Media not found</div>;
|
||||
|
||||
const sessions = localSessions ?? data.sessions;
|
||||
const animeId = data.detail.animeId;
|
||||
const detail = {
|
||||
...data.detail,
|
||||
totalSessions: sessions.length,
|
||||
totalActiveMs: sessions.reduce((sum, session) => sum + session.activeWatchedMs, 0),
|
||||
totalCards: sessions.reduce((sum, session) => sum + session.cardsMined, 0),
|
||||
totalTokensSeen: sessions.reduce(
|
||||
(sum, session) => sum + getSessionDisplayWordCount(session),
|
||||
0,
|
||||
),
|
||||
totalLinesSeen: sessions.reduce((sum, session) => sum + session.linesSeen, 0),
|
||||
totalLookupCount: sessions.reduce((sum, session) => sum + session.lookupCount, 0),
|
||||
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
||||
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!confirmSessionDelete()) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingSessionId(session.sessionId);
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setLocalSessions((prev) =>
|
||||
(prev ?? data.sessions).filter((item) => item.sessionId !== session.sessionId),
|
||||
);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
setDeletingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
← {backLabel}
|
||||
</button>
|
||||
{onNavigateToAnime != null && animeId != null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateToAnime(animeId)}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View Anime →
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<MediaHeader detail={detail} />
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
<MediaSessionList
|
||||
sessions={sessions}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
deletingSessionId={deletingSessionId}
|
||||
initialExpandedSessionId={initialExpandedSessionId}
|
||||
onConsumeInitialExpandedSession={onConsumeInitialExpandedSession}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
stats/src/components/library/MediaHeader.tsx
Normal file
113
stats/src/components/library/MediaHeader.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import type { MediaDetailData } from '../../types/stats';
|
||||
|
||||
interface MediaHeaderProps {
|
||||
detail: NonNullable<MediaDetailData['detail']>;
|
||||
initialKnownWordsSummary?: {
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHeaderProps) {
|
||||
const knownTokenRate =
|
||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||
const avgSessionMs =
|
||||
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
|
||||
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
|
||||
|
||||
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
||||
totalUniqueWords: number;
|
||||
knownWordCount: number;
|
||||
} | null>(initialKnownWordsSummary);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getMediaKnownWordsSummary(detail.videoId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setKnownWordsSummary(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setKnownWordsSummary(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detail.videoId]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<CoverImage
|
||||
videoId={detail.videoId}
|
||||
title={detail.canonicalTitle}
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">total watch time</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-cards-mined font-medium">
|
||||
{formatNumber(detail.totalCards)}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">cards mined</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalTokensSeen)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">word occurrences</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-lavender font-medium">
|
||||
{formatNumber(detail.totalYomitanLookupCount)}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">Yomitan lookups</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-sapphire font-medium">
|
||||
{lookupRate?.shortValue ?? '\u2014'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{lookupRate?.longValue ?? 'lookup rate'}
|
||||
</div>
|
||||
</div>
|
||||
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? (
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">
|
||||
{formatNumber(knownWordsSummary.knownWordCount)} /{' '}
|
||||
{formatNumber(knownWordsSummary.totalUniqueWords)}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
known unique words (
|
||||
{Math.round(
|
||||
(knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100,
|
||||
)}
|
||||
%)
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-ctp-peach font-medium">{formatPercent(knownTokenRate)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">known word match rate</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-ctp-text font-medium">{detail.totalSessions}</div>
|
||||
<div className="text-xs text-ctp-overlay2">sessions</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-text font-medium">{formatDuration(avgSessionMs)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">avg session</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
stats/src/components/library/MediaSessionList.tsx
Normal file
64
stats/src/components/library/MediaSessionList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SessionDetail } from '../sessions/SessionDetail';
|
||||
import { SessionRow } from '../sessions/SessionRow';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface MediaSessionListProps {
|
||||
sessions: SessionSummary[];
|
||||
onDeleteSession: (session: SessionSummary) => void;
|
||||
deletingSessionId?: number | null;
|
||||
initialExpandedSessionId?: number | null;
|
||||
onConsumeInitialExpandedSession?: () => void;
|
||||
}
|
||||
|
||||
export function MediaSessionList({
|
||||
sessions,
|
||||
onDeleteSession,
|
||||
deletingSessionId = null,
|
||||
initialExpandedSessionId = null,
|
||||
onConsumeInitialExpandedSession,
|
||||
}: MediaSessionListProps) {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(initialExpandedSessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialExpandedSessionId == null) return;
|
||||
if (!sessions.some((session) => session.sessionId === initialExpandedSessionId)) return;
|
||||
setExpandedId(initialExpandedSessionId);
|
||||
onConsumeInitialExpandedSession?.();
|
||||
}, [initialExpandedSessionId, onConsumeInitialExpandedSession, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expandedId == null) return;
|
||||
if (sessions.some((session) => session.sessionId === expandedId)) return;
|
||||
setExpandedId(null);
|
||||
}, [expandedId, sessions]);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return <div className="text-sm text-ctp-overlay2">No sessions recorded</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Session History</h3>
|
||||
{sessions.map((s) => (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={`media-session-details-${s.sessionId}`}
|
||||
onToggle={() =>
|
||||
setExpandedId((current) => (current === s.sessionId ? null : s.sessionId))
|
||||
}
|
||||
onDelete={() => onDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
/>
|
||||
{expandedId === s.sessionId ? (
|
||||
<div id={`media-session-details-${s.sessionId}`}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
stats/src/components/library/MediaWatchChart.tsx
Normal file
89
stats/src/components/library/MediaWatchChart.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface MediaWatchChartProps {
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
export function MediaWatchChart({ rollups }: MediaWatchChartProps) {
|
||||
const [range, setRange] = useState<Range>(30);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user