mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix(stats): use yomitan tokens for subtitle counts
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,6 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
totalWatchedMs: 3_600_000,
|
||||
activeWatchedMs: 3_000_000,
|
||||
linesSeen: 20,
|
||||
wordsSeen: 100,
|
||||
tokensSeen: 80,
|
||||
cardsMined: 2,
|
||||
lookupCount: 10,
|
||||
@@ -45,33 +44,32 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
totalSessions: 1,
|
||||
totalActiveMin: 50,
|
||||
totalLinesSeen: 20,
|
||||
totalWordsSeen: 100,
|
||||
totalTokensSeen: 80,
|
||||
totalCards: 2,
|
||||
cardsPerHour: 2.4,
|
||||
wordsPerMin: 2,
|
||||
tokensPerMin: 2,
|
||||
lookupHitRate: 0.8,
|
||||
},
|
||||
];
|
||||
const overview: OverviewData = {
|
||||
sessions,
|
||||
rollups,
|
||||
hints: {
|
||||
totalSessions: 15,
|
||||
activeSessions: 0,
|
||||
episodesToday: 2,
|
||||
activeAnimeCount: 3,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
totalActiveMin: 50,
|
||||
activeDays: 2,
|
||||
totalCards: 9,
|
||||
totalLookupCount: 100,
|
||||
totalLookupHits: 80,
|
||||
newWordsToday: 5,
|
||||
newWordsThisWeek: 20,
|
||||
},
|
||||
};
|
||||
const overview: OverviewData = {
|
||||
sessions,
|
||||
rollups,
|
||||
hints: {
|
||||
totalSessions: 15,
|
||||
activeSessions: 0,
|
||||
episodesToday: 2,
|
||||
activeAnimeCount: 3,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
totalActiveMin: 50,
|
||||
activeDays: 2,
|
||||
totalCards: 9,
|
||||
totalLookupCount: 100,
|
||||
totalLookupHits: 80,
|
||||
newWordsToday: 5,
|
||||
newWordsThisWeek: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildOverviewSummary(overview, now);
|
||||
assert.equal(summary.todayCards, 2);
|
||||
@@ -88,8 +86,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
||||
const now = Date.UTC(2026, 2, 13, 12);
|
||||
const today = Math.floor(now / 86_400_000);
|
||||
const overview: OverviewData = {
|
||||
sessions: [
|
||||
const overview: OverviewData = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 2,
|
||||
canonicalTitle: 'B',
|
||||
@@ -101,7 +99,6 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
totalWatchedMs: 60_000,
|
||||
activeWatchedMs: 60_000,
|
||||
linesSeen: 10,
|
||||
wordsSeen: 10,
|
||||
tokensSeen: 10,
|
||||
cardsMined: 10,
|
||||
lookupCount: 1,
|
||||
@@ -116,11 +113,10 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
|
||||
totalSessions: 1,
|
||||
totalActiveMin: 1,
|
||||
totalLinesSeen: 10,
|
||||
totalWordsSeen: 10,
|
||||
totalTokensSeen: 10,
|
||||
totalCards: 10,
|
||||
cardsPerHour: 600,
|
||||
wordsPerMin: 10,
|
||||
tokensPerMin: 10,
|
||||
lookupHitRate: 1,
|
||||
},
|
||||
],
|
||||
@@ -182,11 +178,10 @@ test('buildTrendDashboard derives dense chart series', () => {
|
||||
totalSessions: 2,
|
||||
totalActiveMin: 60,
|
||||
totalLinesSeen: 30,
|
||||
totalWordsSeen: 120,
|
||||
totalTokensSeen: 100,
|
||||
totalCards: 3,
|
||||
cardsPerHour: 3,
|
||||
wordsPerMin: 2,
|
||||
tokensPerMin: 2,
|
||||
lookupHitRate: 0.5,
|
||||
},
|
||||
{
|
||||
@@ -195,18 +190,17 @@ test('buildTrendDashboard derives dense chart series', () => {
|
||||
totalSessions: 1,
|
||||
totalActiveMin: 30,
|
||||
totalLinesSeen: 10,
|
||||
totalWordsSeen: 40,
|
||||
totalTokensSeen: 30,
|
||||
totalCards: 1,
|
||||
cardsPerHour: 2,
|
||||
wordsPerMin: 1.33,
|
||||
tokensPerMin: 1.33,
|
||||
lookupHitRate: 0.75,
|
||||
},
|
||||
];
|
||||
|
||||
const dashboard = buildTrendDashboard(rollups);
|
||||
assert.equal(dashboard.watchTime.length, 2);
|
||||
assert.equal(dashboard.words[1]?.value, 40);
|
||||
assert.equal(dashboard.words[1]?.value, 30);
|
||||
assert.equal(dashboard.sessions[0]?.value, 2);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface OverviewSummary {
|
||||
activeDays: number;
|
||||
totalSessions: number;
|
||||
lookupRate: number | null;
|
||||
todayWords: number;
|
||||
todayTokens: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
recentWatchTime: ChartPoint[];
|
||||
@@ -100,7 +100,7 @@ function buildAggregatedDailyRows(rollups: DailyRollup[]) {
|
||||
|
||||
existing.activeMin += rollup.totalActiveMin;
|
||||
existing.cards += rollup.totalCards;
|
||||
existing.words += rollup.totalWordsSeen;
|
||||
existing.words += rollup.totalTokensSeen;
|
||||
existing.sessions += rollup.totalSessions;
|
||||
if (rollup.lookupHitRate != null) {
|
||||
const weight = Math.max(rollup.totalSessions, 1);
|
||||
@@ -185,9 +185,9 @@ export function buildOverviewSummary(
|
||||
overview.hints.totalLookupCount > 0
|
||||
? Math.round((overview.hints.totalLookupHits / overview.hints.totalLookupCount) * 100)
|
||||
: null,
|
||||
todayWords: Math.max(
|
||||
todayTokens: Math.max(
|
||||
todayRow?.words ?? 0,
|
||||
sumBy(todaySessions, (session) => session.wordsSeen),
|
||||
sumBy(todaySessions, (session) => session.tokensSeen),
|
||||
),
|
||||
newWordsToday: overview.hints.newWordsToday ?? 0,
|
||||
newWordsThisWeek: overview.hints.newWordsThisWeek ?? 0,
|
||||
|
||||
@@ -18,7 +18,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
|
||||
totalWatchedMs: 1_000,
|
||||
activeWatchedMs: 900,
|
||||
linesSeen: 12,
|
||||
wordsSeen: 24,
|
||||
tokensSeen: 24,
|
||||
cardsMined: 2,
|
||||
lookupCount: 3,
|
||||
@@ -34,6 +33,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
|
||||
assert.match(markup, /Session History/);
|
||||
assert.match(markup, /aria-expanded="true"/);
|
||||
assert.match(markup, /Delete session Episode 7/);
|
||||
assert.match(markup, /Total words/);
|
||||
assert.match(markup, /1 Yomitan lookup/);
|
||||
assert.match(markup, /tokens/);
|
||||
assert.match(markup, /No token data for this session/);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SessionDetail } from '../components/sessions/SessionDetail';
|
||||
import { buildSessionChartEvents } from './session-events';
|
||||
import { EventType } from '../types/stats';
|
||||
|
||||
test('SessionDetail omits the misleading new words metric', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
@@ -17,7 +19,6 @@ test('SessionDetail omits the misleading new words metric', () => {
|
||||
totalWatchedMs: 0,
|
||||
activeWatchedMs: 0,
|
||||
linesSeen: 12,
|
||||
wordsSeen: 24,
|
||||
tokensSeen: 24,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
@@ -27,6 +28,33 @@ test('SessionDetail omits the misleading new words metric', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Total words/);
|
||||
assert.match(markup, /No token data/);
|
||||
assert.doesNotMatch(markup, /New words/);
|
||||
});
|
||||
|
||||
test('buildSessionChartEvents keeps only chart-relevant events and pairs pause ranges', () => {
|
||||
const chartEvents = buildSessionChartEvents([
|
||||
{ eventType: EventType.SUBTITLE_LINE, tsMs: 1_000, payload: '{"line":"ignored"}' },
|
||||
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
|
||||
{ eventType: EventType.SEEK_FORWARD, tsMs: 3_000, payload: null },
|
||||
{ eventType: EventType.PAUSE_END, tsMs: 4_000, payload: null },
|
||||
{ eventType: EventType.CARD_MINED, tsMs: 5_000, payload: '{"cardsMined":1}' },
|
||||
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 6_000, payload: null },
|
||||
{ eventType: EventType.SEEK_BACKWARD, tsMs: 7_000, payload: null },
|
||||
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
chartEvents.seekEvents.map((event) => event.eventType),
|
||||
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
|
||||
);
|
||||
assert.deepEqual(
|
||||
chartEvents.cardEvents.map((event) => event.tsMs),
|
||||
[5_000],
|
||||
);
|
||||
assert.deepEqual(
|
||||
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
|
||||
[6_000],
|
||||
);
|
||||
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
type SessionWordCountLike = {
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
};
|
||||
|
||||
export function getSessionDisplayWordCount(value: SessionWordCountLike): number {
|
||||
return value.tokensSeen > 0 ? value.tokensSeen : value.wordsSeen;
|
||||
return value.tokensSeen;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ test('EpisodeList renders explicit episode detail button alongside quick peek ro
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1,
|
||||
totalCards: 1,
|
||||
totalWordsSeen: 350,
|
||||
totalTokensSeen: 350,
|
||||
totalYomitanLookupCount: 7,
|
||||
lastWatchedMs: 0,
|
||||
},
|
||||
|
||||
@@ -8,10 +8,10 @@ import { SessionRow } from '../components/sessions/SessionRow';
|
||||
import { EventType, type SessionEvent } from '../types/stats';
|
||||
import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup';
|
||||
|
||||
test('buildLookupRateDisplay formats lookups per 100 words in short and long forms', () => {
|
||||
test('buildLookupRateDisplay formats lookups per 100 tokens in short and long forms', () => {
|
||||
assert.deepEqual(buildLookupRateDisplay(23, 1000), {
|
||||
shortValue: '2.3 / 100 words',
|
||||
longValue: '2.3 lookups per 100 words',
|
||||
shortValue: '2.3 / 100 tokens',
|
||||
longValue: '2.3 lookups per 100 tokens',
|
||||
});
|
||||
assert.equal(buildLookupRateDisplay(0, 0), null);
|
||||
});
|
||||
@@ -38,7 +38,7 @@ test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
|
||||
totalSessions: 4,
|
||||
totalActiveMs: 90_000,
|
||||
totalCards: 12,
|
||||
totalWordsSeen: 1000,
|
||||
totalTokensSeen: 1000,
|
||||
totalLinesSeen: 120,
|
||||
totalLookupCount: 30,
|
||||
totalLookupHits: 21,
|
||||
@@ -48,11 +48,11 @@ test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
|
||||
);
|
||||
|
||||
assert.match(markup, /23/);
|
||||
assert.match(markup, /2\.3 \/ 100 words/);
|
||||
assert.match(markup, /2\.3 lookups per 100 words/);
|
||||
assert.match(markup, /2\.3 \/ 100 tokens/);
|
||||
assert.match(markup, /2\.3 lookups per 100 tokens/);
|
||||
});
|
||||
|
||||
test('MediaHeader distinguishes word occurrences from known unique words', () => {
|
||||
test('MediaHeader distinguishes token occurrences from known unique words', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MediaHeader
|
||||
detail={{
|
||||
@@ -61,7 +61,7 @@ test('MediaHeader distinguishes word occurrences from known unique words', () =>
|
||||
totalSessions: 4,
|
||||
totalActiveMs: 90_000,
|
||||
totalCards: 12,
|
||||
totalWordsSeen: 30,
|
||||
totalTokensSeen: 30,
|
||||
totalLinesSeen: 120,
|
||||
totalLookupCount: 30,
|
||||
totalLookupHits: 21,
|
||||
@@ -74,7 +74,7 @@ test('MediaHeader distinguishes word occurrences from known unique words', () =>
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /word occurrences/);
|
||||
assert.match(markup, /token occurrences/);
|
||||
assert.match(markup, /known unique words \(50%\)/);
|
||||
assert.match(markup, /17 \/ 34/);
|
||||
});
|
||||
@@ -93,7 +93,7 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1,
|
||||
totalCards: 1,
|
||||
totalWordsSeen: 350,
|
||||
totalTokensSeen: 350,
|
||||
totalYomitanLookupCount: 7,
|
||||
lastWatchedMs: 0,
|
||||
},
|
||||
@@ -102,7 +102,7 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
|
||||
);
|
||||
|
||||
assert.match(markup, /Lookup Rate/);
|
||||
assert.match(markup, /2\.0 \/ 100 words/);
|
||||
assert.match(markup, /2\.0 \/ 100 tokens/);
|
||||
});
|
||||
|
||||
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
@@ -119,7 +119,7 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
totalSessions: 5,
|
||||
totalActiveMs: 100_000,
|
||||
totalCards: 8,
|
||||
totalWordsSeen: 800,
|
||||
totalTokensSeen: 800,
|
||||
totalLinesSeen: 100,
|
||||
totalLookupCount: 50,
|
||||
totalLookupHits: 30,
|
||||
@@ -134,8 +134,8 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
|
||||
assert.match(markup, /Lookups/);
|
||||
assert.match(markup, /16/);
|
||||
assert.match(markup, /2\.0 \/ 100 words/);
|
||||
assert.match(markup, /2\.0 lookups per 100 words/);
|
||||
assert.match(markup, /2\.0 \/ 100 tokens/);
|
||||
assert.match(markup, /Yomitan lookups per 100 tokens seen/);
|
||||
});
|
||||
|
||||
test('SessionRow prefers token-based word count when available', () => {
|
||||
@@ -152,7 +152,6 @@ test('SessionRow prefers token-based word count when available', () => {
|
||||
totalWatchedMs: 0,
|
||||
activeWatchedMs: 0,
|
||||
linesSeen: 12,
|
||||
wordsSeen: 12,
|
||||
tokensSeen: 42,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
|
||||
@@ -8,15 +8,15 @@ export interface LookupRateDisplay {
|
||||
|
||||
export function buildLookupRateDisplay(
|
||||
yomitanLookupCount: number,
|
||||
wordsSeen: number,
|
||||
tokensSeen: number,
|
||||
): LookupRateDisplay | null {
|
||||
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(wordsSeen) || wordsSeen <= 0) {
|
||||
if (!Number.isFinite(yomitanLookupCount) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) {
|
||||
return null;
|
||||
}
|
||||
const per100 = ((Math.max(0, yomitanLookupCount) / wordsSeen) * 100).toFixed(1);
|
||||
const per100 = ((Math.max(0, yomitanLookupCount) / tokensSeen) * 100).toFixed(1);
|
||||
return {
|
||||
shortValue: `${per100} / 100 words`,
|
||||
longValue: `${per100} lookups per 100 words`,
|
||||
shortValue: `${per100} / 100 tokens`,
|
||||
longValue: `${per100} lookups per 100 tokens`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface SessionSummary {
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
lookupCount: number;
|
||||
@@ -23,11 +22,10 @@ export interface DailyRollup {
|
||||
totalSessions: number;
|
||||
totalActiveMin: number;
|
||||
totalLinesSeen: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
totalCards: number;
|
||||
cardsPerHour: number | null;
|
||||
wordsPerMin: number | null;
|
||||
tokensPerMin: number | null;
|
||||
lookupHitRate: number | null;
|
||||
}
|
||||
|
||||
@@ -38,7 +36,6 @@ export interface SessionTimelinePoint {
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
}
|
||||
@@ -114,7 +111,7 @@ export interface MediaLibraryItem {
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
lastWatchedMs: number;
|
||||
hasCoverArt: number;
|
||||
}
|
||||
@@ -126,7 +123,7 @@ export interface MediaDetailData {
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
totalLinesSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
@@ -157,7 +154,7 @@ export interface AnimeLibraryItem {
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
episodeCount: number;
|
||||
episodesTotal: number | null;
|
||||
lastWatchedMs: number;
|
||||
@@ -182,7 +179,7 @@ export interface AnimeDetailData {
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
totalLinesSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
@@ -204,7 +201,7 @@ export interface AnimeEpisode {
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
totalYomitanLookupCount: number;
|
||||
lastWatchedMs: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user