fix(stats): use yomitan tokens for subtitle counts

This commit is contained in:
2026-03-17 22:33:08 -07:00
parent ecb41a490b
commit 8f39416ff5
35 changed files with 991 additions and 507 deletions

View File

@@ -37,20 +37,12 @@ function Metric({ label, value, unit, color, tooltip, sub }: MetricProps) {
);
}
export function AnimeOverviewStats({
detail,
knownWordsSummary,
}: AnimeOverviewStatsProps) {
const lookupRate = buildLookupRateDisplay(
detail.totalYomitanLookupCount,
detail.totalWordsSeen,
);
export function AnimeOverviewStats({ detail, knownWordsSummary }: AnimeOverviewStatsProps) {
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
const knownPct =
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
? Math.round(
(knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100,
)
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
: null;
return (
@@ -76,10 +68,10 @@ export function AnimeOverviewStats({
tooltip="Number of completed episodes for this anime"
/>
<Metric
label="Words Seen"
value={formatNumber(detail.totalWordsSeen)}
label="Tokens Seen"
value={formatNumber(detail.totalTokensSeen)}
color="text-ctp-mauve"
tooltip="Total word occurrences across all sessions"
tooltip="Total token occurrences across all sessions"
/>
</div>
@@ -88,7 +80,7 @@ export function AnimeOverviewStats({
<Metric
label="Cards Mined"
value={formatNumber(detail.totalCards)}
color="text-ctp-green"
color="text-ctp-cards-mined"
tooltip="Anki cards created from subtitle lines in this anime"
/>
<Metric
@@ -102,7 +94,7 @@ export function AnimeOverviewStats({
label="Lookup Rate"
value={lookupRate.shortValue}
color="text-ctp-sapphire"
tooltip="Yomitan lookups per 100 words seen"
tooltip="Yomitan lookups per 100 tokens seen"
/>
) : (
<Metric
@@ -124,7 +116,7 @@ export function AnimeOverviewStats({
label="Known Words"
value="—"
color="text-ctp-overlay2"
tooltip="No word data available yet"
tooltip="No token data available yet"
/>
)}
</div>

View File

@@ -89,9 +89,9 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
</span>
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
<span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-cards-mined">{formatNumber(s.cardsMined)} cards</span>
<span className="text-ctp-peach">
{formatNumber(getSessionDisplayWordCount(s))} words
{formatNumber(getSessionDisplayWordCount(s))} tokens
</span>
<button
type="button"
@@ -141,7 +141,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
);
})
) : (
<span className="text-ctp-green">
<span className="text-ctp-cards-mined">
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
</span>
)}

View File

@@ -80,7 +80,7 @@ export function EpisodeList({
{sorted.map((ep, idx) => {
const lookupRate = buildLookupRateDisplay(
ep.totalYomitanLookupCount,
ep.totalWordsSeen,
ep.totalTokensSeen,
);
return (
@@ -118,7 +118,7 @@ export function EpisodeList({
<td className="py-2 pr-3 text-right text-ctp-blue">
{formatDuration(ep.totalActiveMs)}
</td>
<td className="py-2 pr-3 text-right text-ctp-green">
<td className="py-2 pr-3 text-right text-ctp-cards-mined">
{formatNumber(ep.totalCards)}
</td>
<td className="py-2 pr-3 text-right">

View File

@@ -41,7 +41,10 @@ export function MediaDetailView({
totalSessions: sessions.length,
totalActiveMs: sessions.reduce((sum, session) => sum + session.activeWatchedMs, 0),
totalCards: sessions.reduce((sum, session) => sum + session.cardsMined, 0),
totalWordsSeen: sessions.reduce((sum, session) => sum + getSessionDisplayWordCount(session), 0),
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),

View File

@@ -18,7 +18,7 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
const avgSessionMs =
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalWordsSeen);
const lookupRate = buildLookupRateDisplay(detail.totalYomitanLookupCount, detail.totalTokensSeen);
const [knownWordsSummary, setKnownWordsSummary] = useState<{
totalUniqueWords: number;
@@ -55,12 +55,12 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
<div className="text-xs text-ctp-overlay2">total watch time</div>
</div>
<div>
<div className="text-ctp-green font-medium">{formatNumber(detail.totalCards)}</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.totalWordsSeen)}</div>
<div className="text-xs text-ctp-overlay2">word occurrences</div>
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalTokensSeen)}</div>
<div className="text-xs text-ctp-overlay2">token occurrences</div>
</div>
<div>
<div className="text-ctp-lavender font-medium">
@@ -79,10 +79,15 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
{knownWordsSummary && knownWordsSummary.totalUniqueWords > 0 ? (
<div>
<div className="text-ctp-green font-medium">
{formatNumber(knownWordsSummary.knownWordCount)} / {formatNumber(knownWordsSummary.totalUniqueWords)}
{formatNumber(knownWordsSummary.knownWordCount)} /{' '}
{formatNumber(knownWordsSummary.totalUniqueWords)}
</div>
<div className="text-xs text-ctp-overlay2">
known unique words ({Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)}%)
known unique words (
{Math.round(
(knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100,
)}
%)
</div>
</div>
) : (

View File

@@ -122,6 +122,10 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
const summary = buildOverviewSummary(data);
const streakData = buildStreakCalendar(calendar);
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.activeDays > 0;
const knownWordPercent =
knownWordsSummary && knownWordsSummary.totalUniqueWords > 0
? Math.round((knownWordsSummary.knownWordCount / knownWordsSummary.totalUniqueWords) * 100)
: null;
return (
<div className="space-y-4">
@@ -203,7 +207,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
<Tooltip text="Total Anki cards mined from subtitle lines across all sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-green">
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-cards-mined">
{formatNumber(summary.totalTrackedCards)}
</div>
</div>
@@ -216,11 +220,11 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
</div>
</div>
</Tooltip>
<Tooltip text="Total word occurrences encountered in today's sessions">
<Tooltip text="Total token occurrences encountered in today's sessions">
<div className="rounded-lg bg-ctp-surface1/60 p-3">
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Words Today</div>
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Tokens Today</div>
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-sky">
{formatNumber(summary.todayWords)}
{formatNumber(summary.todayTokens)}
</div>
</div>
</Tooltip>
@@ -254,6 +258,9 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
<span className="text-sm text-ctp-overlay2 ml-1">
/ {formatNumber(knownWordsSummary.totalUniqueWords)}
</span>
{knownWordPercent != null ? (
<span className="text-sm text-ctp-overlay2 ml-1">({knownWordPercent}%)</span>
) : null}
</div>
</div>
</Tooltip>

View File

@@ -162,7 +162,7 @@ function SessionItem({
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
@@ -171,7 +171,7 @@ function SessionItem({
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(displayWordCount)}
</div>
<div className="text-ctp-overlay2">words</div>
<div className="text-ctp-overlay2">tokens</div>
</div>
</div>
</button>
@@ -245,18 +245,18 @@ function AnimeGroupRow({
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
</div>
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
{formatNumber(group.totalCards)}
</div>
<div className="text-ctp-overlay2">cards</div>
</div>
<div>
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(group.totalWords)}
</div>
<div className="text-ctp-overlay2">words</div>
<div className="text-ctp-overlay2">tokens</div>
</div>
</div>
<div
@@ -293,10 +293,7 @@ function AnimeGroupRow({
type="button"
onClick={() => {
if (navigationTarget.type === 'media-detail') {
onNavigateToMediaDetail(
navigationTarget.videoId,
navigationTarget.sessionId,
);
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
return;
}
onNavigateToSession(navigationTarget.sessionId);
@@ -319,7 +316,7 @@ function AnimeGroupRow({
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
{formatNumber(s.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
@@ -328,7 +325,7 @@ function AnimeGroupRow({
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(getSessionDisplayWordCount(s))}
</div>
<div className="text-ctp-overlay2">words</div>
<div className="text-ctp-overlay2">tokens</div>
</div>
</div>
</button>

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
AreaChart,
Area,
@@ -12,12 +13,19 @@ import {
CartesianGrid,
} from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions';
import { getStatsClient } from '../../hooks/useStatsApi';
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme';
import { buildLookupRateDisplay, getYomitanLookupEvents } from '../../lib/yomitan-lookup';
import {
buildSessionChartEvents,
type SessionChartMarker,
type SessionEventNoteInfo,
} from '../../lib/session-events';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { EventType } from '../../types/stats';
import type { SessionEvent, SessionSummary } from '../../types/stats';
import { SessionEventOverlay } from './SessionEventOverlay';
interface SessionDetailProps {
session: SessionSummary;
@@ -40,9 +48,7 @@ function formatTime(ms: number): string {
}
/** Build a lookup: linesSeen → knownWordsSeen */
function buildKnownWordsLookup(
knownWordsTimeline: KnownWordsTimelinePoint[],
): Map<number, number> {
function buildKnownWordsLookup(knownWordsTimeline: KnownWordsTimelinePoint[]): Map<number, number> {
const map = new Map<number, number>();
for (const pt of knownWordsTimeline) {
map.set(pt.linesSeen, pt.knownWordsSeen);
@@ -63,24 +69,17 @@ function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
return best > 0 ? map.get(best)! : 0;
}
interface PauseRegion {
startMs: number;
endMs: number;
}
function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
const regions: PauseRegion[] = [];
const starts = events.filter((e) => e.eventType === EventType.PAUSE_START);
const ends = events.filter((e) => e.eventType === EventType.PAUSE_END);
for (const start of starts) {
const end = ends.find((e) => e.tsMs > start.tsMs);
regions.push({
startMs: start.tsMs,
endMs: end ? end.tsMs : start.tsMs + 2000,
});
}
return regions;
function extractNoteExpression(note: {
noteId: number;
fields: Record<string, { value: string }>;
}): SessionEventNoteInfo {
const expression =
note.fields?.Expression?.value ??
note.fields?.expression?.value ??
note.fields?.Word?.value ??
note.fields?.word?.value ??
'';
return { noteId: note.noteId, expression };
}
interface RatioChartPoint {
@@ -100,7 +99,6 @@ interface FallbackChartPoint {
type TimelineEntry = {
sampleMs: number;
linesSeen: number;
wordsSeen: number;
tokensSeen: number;
};
@@ -108,19 +106,17 @@ export function SessionDetail({ session }: SessionDetailProps) {
const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail(
session.sessionId,
);
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
const [activeMarkerKey, setActiveMarkerKey] = useState<string | null>(null);
const [noteInfos, setNoteInfos] = useState<Map<number, SessionEventNoteInfo>>(new Map());
const [loadingNoteIds, setLoadingNoteIds] = useState<Set<number>>(new Set());
const requestedNoteIdsRef = useRef<Set<number>>(new Set());
const sorted = [...timeline].reverse();
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
const cardEvents = events.filter((e) => e.eventType === EventType.CARD_MINED);
const seekEvents = events.filter(
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
);
const yomitanLookupEvents = getYomitanLookupEvents(events);
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
buildSessionChartEvents(events);
const lookupRate = buildLookupRateDisplay(
session.yomitanLookupCount,
getSessionDisplayWordCount(session),
@@ -128,7 +124,76 @@ export function SessionDetail({ session }: SessionDetailProps) {
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = seekEvents.length;
const cardEventCount = cardEvents.length;
const pauseRegions = buildPauseRegions(events);
const activeMarker = useMemo<SessionChartMarker | null>(
() => markers.find((marker) => marker.key === activeMarkerKey) ?? null,
[markers, activeMarkerKey],
);
useEffect(() => {
if (!activeMarker || activeMarker.kind !== 'card' || activeMarker.noteIds.length === 0) {
return;
}
const missingNoteIds = activeMarker.noteIds.filter(
(noteId) => !requestedNoteIdsRef.current.has(noteId) && !noteInfos.has(noteId),
);
if (missingNoteIds.length === 0) {
return;
}
for (const noteId of missingNoteIds) {
requestedNoteIdsRef.current.add(noteId);
}
let cancelled = false;
setLoadingNoteIds((prev) => {
const next = new Set(prev);
for (const noteId of missingNoteIds) {
next.add(noteId);
}
return next;
});
getStatsClient()
.ankiNotesInfo(missingNoteIds)
.then((notes) => {
if (cancelled) return;
setNoteInfos((prev) => {
const next = new Map(prev);
for (const note of notes) {
const info = extractNoteExpression(note);
next.set(info.noteId, info);
}
return next;
});
})
.catch((err) => {
if (!cancelled) {
console.warn('Failed to fetch session event Anki note info:', err);
}
})
.finally(() => {
if (cancelled) return;
setLoadingNoteIds((prev) => {
const next = new Set(prev);
for (const noteId of missingNoteIds) {
next.delete(noteId);
}
return next;
});
});
return () => {
cancelled = true;
};
}, [activeMarker, noteInfos]);
const handleOpenNote = (noteId: number) => {
void getStatsClient().ankiBrowse(noteId);
};
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
if (hasKnownWords) {
return (
@@ -136,8 +201,15 @@ export function SessionDetail({ session }: SessionDetailProps) {
sorted={sorted}
knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
activeMarkerKey={activeMarkerKey}
onActiveMarkerChange={setActiveMarkerKey}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
@@ -151,8 +223,15 @@ export function SessionDetail({ session }: SessionDetailProps) {
<FallbackView
sorted={sorted}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
activeMarkerKey={activeMarkerKey}
onActiveMarkerChange={setActiveMarkerKey}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
@@ -168,8 +247,15 @@ function RatioView({
sorted,
knownWordsMap,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
activeMarkerKey,
onActiveMarkerChange,
noteInfos,
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
@@ -179,8 +265,15 @@ function RatioView({
sorted: TimelineEntry[];
knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
activeMarkerKey: string | null;
onActiveMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
@@ -205,7 +298,7 @@ function RatioView({
}
if (chartData.length === 0) {
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
return <div className="text-ctp-overlay2 text-xs p-2">No token data for this session.</div>;
}
const tsMin = chartData[0]!.tsMs;
@@ -217,8 +310,9 @@ function RatioView({
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-1">
{/* ── Top: Percentage area chart ── */}
<ResponsiveContainer width="100%" height={130}>
<AreaChart data={chartData}>
<div className="relative">
<ResponsiveContainer width="100%" height={130}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
@@ -297,36 +391,45 @@ function RatioView({
))}
{/* Card mine markers */}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '\u26CF',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}}
/>
))}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
/>
))}
{seekEvents.map((e, i) => {
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
return (
<ReferenceLine
key={`seek-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke={stroke}
strokeWidth={1.5}
strokeOpacity={0.75}
strokeDasharray="4 3"
/>
);
})}
{/* Yomitan lookup markers */}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
))}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
yAxisId="pct"
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
))}
<Area
yAxisId="pct"
@@ -352,12 +455,23 @@ function RatioView({
type="monotone"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</AreaChart>
</ResponsiveContainer>
<SessionEventOverlay
markers={markers}
tsMin={tsMin}
tsMax={tsMax}
activeMarkerKey={activeMarkerKey}
onActiveMarkerChange={onActiveMarkerChange}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={onOpenNote}
/>
</div>
{/* ── Bottom: Word accumulation sparkline ── */}
{/* ── Bottom: Token accumulation sparkline ── */}
<div className="flex items-center gap-2 border-t border-ctp-surface1 pt-1">
<span className="text-[9px] text-ctp-overlay0 whitespace-nowrap">total words</span>
<span className="text-[9px] text-ctp-overlay0 whitespace-nowrap">total tokens</span>
<div className="flex-1 h-[28px]">
<ResponsiveContainer width="100%" height={28}>
<LineChart data={sparkData}>
@@ -398,8 +512,15 @@ function RatioView({
function FallbackView({
sorted,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
activeMarkerKey,
onActiveMarkerChange,
noteInfos,
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
@@ -408,8 +529,15 @@ function FallbackView({
}: {
sorted: TimelineEntry[];
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
activeMarkerKey: string | null;
onActiveMarkerChange: (markerKey: string | null) => void;
noteInfos: Map<number, SessionEventNoteInfo>;
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
@@ -424,7 +552,7 @@ function FallbackView({
}
if (chartData.length === 0) {
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
return <div className="text-ctp-overlay2 text-xs p-2">No token data for this session.</div>;
}
const tsMin = chartData[0]!.tsMs;
@@ -432,8 +560,9 @@ function FallbackView({
return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
<ResponsiveContainer width="100%" height={130}>
<LineChart data={chartData}>
<div className="relative">
<ResponsiveContainer width="100%" height={130}>
<LineChart data={chartData}>
<XAxis
dataKey="tsMs"
type="number"
@@ -454,7 +583,7 @@ function FallbackView({
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={formatTime}
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total words']}
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total tokens']}
/>
{pauseRegions.map((r, i) => (
@@ -471,32 +600,39 @@ function FallbackView({
/>
))}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
label={{
value: '\u26CF',
position: 'top',
fill: '#a6da95',
fontSize: 14,
fontWeight: 700,
}}
/>
))}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
))}
{cardEvents.map((e, i) => (
<ReferenceLine
key={`card-${i}`}
x={e.tsMs}
stroke="#a6da95"
strokeWidth={2}
strokeOpacity={0.8}
/>
))}
{seekEvents.map((e, i) => {
const isBackward = e.eventType === EventType.SEEK_BACKWARD;
const stroke = isBackward ? '#f5bde6' : '#8bd5ca';
return (
<ReferenceLine
key={`seek-${i}`}
x={e.tsMs}
stroke={stroke}
strokeWidth={1.5}
strokeOpacity={0.75}
strokeDasharray="4 3"
/>
);
})}
{yomitanLookupEvents.map((e, i) => (
<ReferenceLine
key={`yomitan-${i}`}
x={e.tsMs}
stroke="#b7bdf8"
strokeWidth={1.5}
strokeDasharray="2 3"
strokeOpacity={0.7}
/>
))}
<Line
dataKey="totalWords"
@@ -504,12 +640,23 @@ function FallbackView({
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
name="Total words"
name="Total tokens"
type="monotone"
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</LineChart>
</ResponsiveContainer>
<SessionEventOverlay
markers={markers}
tsMin={tsMin}
tsMax={tsMax}
activeMarkerKey={activeMarkerKey}
onActiveMarkerChange={onActiveMarkerChange}
noteInfos={noteInfos}
loadingNoteIds={loadingNoteIds}
onOpenNote={onOpenNote}
/>
</div>
<StatsBar
hasKnownWords={false}
@@ -596,7 +743,7 @@ function StatsBar({
)}
<span className="flex items-center gap-1.5">
<span className="text-[12px]">{'\u26CF'}</span>
<span className="text-ctp-green">
<span className="text-ctp-cards-mined">
{Math.max(cardEventCount, session.cardsMined)} card
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
</span>

View File

@@ -84,7 +84,7 @@ export function SessionRow({
</div>
<div className="flex gap-4 text-xs text-center shrink-0">
<div>
<div className="text-ctp-green font-medium font-mono tabular-nums">
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
{formatNumber(session.cardsMined)}
</div>
<div className="text-ctp-overlay2">cards</div>
@@ -93,7 +93,7 @@ export function SessionRow({
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
{formatNumber(displayWordCount)}
</div>
<div className="text-ctp-overlay2">words</div>
<div className="text-ctp-overlay2">tokens</div>
</div>
</div>
<div

View File

@@ -97,6 +97,17 @@ export function TrendsTab() {
const [groupBy, setGroupBy] = useState<GroupBy>('day');
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
const { data, loading, error } = useTrends(range, groupBy);
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
const cardsMinedStackedColors = [
cardsMinedColor,
'#8aadf4',
'#c6a0f6',
'#f5a97f',
'#f5bde6',
'#91d7e3',
'#ee99a0',
'#f4dbd6',
];
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
@@ -115,19 +126,40 @@ export function TrendsTab() {
]);
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
const filteredEpisodesPerAnime = filterHiddenAnimeData(data.animePerDay.episodes, activeHiddenAnime);
const filteredWatchTimePerAnime = filterHiddenAnimeData(data.animePerDay.watchTime, activeHiddenAnime);
const filteredEpisodesPerAnime = filterHiddenAnimeData(
data.animePerDay.episodes,
activeHiddenAnime,
);
const filteredWatchTimePerAnime = filterHiddenAnimeData(
data.animePerDay.watchTime,
activeHiddenAnime,
);
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(
data.animePerDay.lookups,
activeHiddenAnime,
);
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
data.animePerDay.lookupsPerHundred,
activeHiddenAnime,
);
const filteredAnimeProgress = filterHiddenAnimeData(data.animeCumulative.episodes, activeHiddenAnime);
const filteredCardsProgress = filterHiddenAnimeData(data.animeCumulative.cards, activeHiddenAnime);
const filteredWordsProgress = filterHiddenAnimeData(data.animeCumulative.words, activeHiddenAnime);
const filteredWatchTimeProgress = filterHiddenAnimeData(data.animeCumulative.watchTime, activeHiddenAnime);
const filteredAnimeProgress = filterHiddenAnimeData(
data.animeCumulative.episodes,
activeHiddenAnime,
);
const filteredCardsProgress = filterHiddenAnimeData(
data.animeCumulative.cards,
activeHiddenAnime,
);
const filteredWordsProgress = filterHiddenAnimeData(
data.animeCumulative.words,
activeHiddenAnime,
);
const filteredWatchTimeProgress = filterHiddenAnimeData(
data.animeCumulative.watchTime,
activeHiddenAnime,
);
return (
<div className="space-y-4">
@@ -145,19 +177,39 @@ export function TrendsTab() {
color="#8aadf4"
type="bar"
/>
<TrendChart title="Cards Mined" data={data.activity.cards} color="#a6da95" type="bar" />
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Cards Mined" data={data.activity.cards} color={cardsMinedColor} type="bar" />
<TrendChart title="Tokens Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Period Trends</SectionHeader>
<TrendChart title="Watch Time (min)" data={data.progress.watchTime} color="#8aadf4" type="line" />
<TrendChart
title="Watch Time (min)"
data={data.progress.watchTime}
color="#8aadf4"
type="line"
/>
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart title="New Words Seen" data={data.progress.newWords} color="#c6a0f6" type="line" />
<TrendChart title="Cards Mined" data={data.progress.cards} color="#a6da95" type="line" />
<TrendChart title="Episodes Watched" data={data.progress.episodes} color="#91d7e3" type="line" />
<TrendChart title="Tokens Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart
title="New Words Seen"
data={data.progress.newWords}
color="#c6a0f6"
type="line"
/>
<TrendChart title="Cards Mined" data={data.progress.cards} color={cardsMinedColor} type="line" />
<TrendChart
title="Episodes Watched"
data={data.progress.episodes}
color="#91d7e3"
type="line"
/>
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart title="Lookups / 100 Words" data={data.ratios.lookupsPerHundred} color="#f5a97f" type="line" />
<TrendChart
title="Lookups / 100 Tokens"
data={data.ratios.lookupsPerHundred}
color="#f5a97f"
type="line"
/>
<SectionHeader>Anime Per Day</SectionHeader>
<AnimeVisibilityFilter
@@ -179,16 +231,27 @@ export function TrendsTab() {
/>
<StackedTrendChart title="Episodes per Anime" data={filteredEpisodesPerAnime} />
<StackedTrendChart title="Watch Time per Anime (min)" data={filteredWatchTimePerAnime} />
<StackedTrendChart title="Cards Mined per Anime" data={filteredCardsPerAnime} />
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
<StackedTrendChart
title="Cards Mined per Anime"
data={filteredCardsPerAnime}
colorPalette={cardsMinedStackedColors}
/>
<StackedTrendChart title="Tokens Seen per Anime" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
<StackedTrendChart title="Lookups/100w per Anime" data={filteredLookupsPerHundredPerAnime} />
<StackedTrendChart
title="Lookups/100w per Anime"
data={filteredLookupsPerHundredPerAnime}
/>
<SectionHeader>Anime Cumulative</SectionHeader>
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
<StackedTrendChart title="Cards Mined Progress" data={filteredCardsProgress} />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
<StackedTrendChart
title="Cards Mined Progress"
data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors}
/>
<StackedTrendChart title="Tokens Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader>
<TrendChart