mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
feat: overhaul stats dashboard with navigation, trends, and anime views
Add navigation state machine for tab/detail routing, anime overview stats with Yomitan lookup rates, session word count accuracy fixes, vocabulary tab hook order fix, simplified trends data fetching from backend-aggregated endpoints, and improved session detail charts.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
AreaChart,
|
||||
Area,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
@@ -8,15 +9,18 @@ import {
|
||||
ResponsiveContainer,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
CartesianGrid,
|
||||
} from 'recharts';
|
||||
import { useSessionDetail } from '../../hooks/useSessions';
|
||||
import type { KnownWordsTimelinePoint } from '../../hooks/useSessions';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import { buildLookupRateDisplay, getYomitanLookupEvents } from '../../lib/yomitan-lookup';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { EventType } from '../../types/stats';
|
||||
import type { SessionEvent } from '../../types/stats';
|
||||
import type { SessionEvent, SessionSummary } from '../../types/stats';
|
||||
|
||||
interface SessionDetailProps {
|
||||
sessionId: number;
|
||||
cardsMined: number;
|
||||
session: SessionSummary;
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
@@ -35,6 +39,30 @@ function formatTime(ms: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a lookup: linesSeen → knownWordsSeen */
|
||||
function buildKnownWordsLookup(
|
||||
knownWordsTimeline: KnownWordsTimelinePoint[],
|
||||
): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
for (const pt of knownWordsTimeline) {
|
||||
map.set(pt.linesSeen, pt.knownWordsSeen);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** For a given linesSeen value, find the closest known words count (floor lookup). */
|
||||
function lookupKnownWords(map: Map<number, number>, linesSeen: number): number {
|
||||
if (map.size === 0) return 0;
|
||||
if (map.has(linesSeen)) return map.get(linesSeen)!;
|
||||
let best = 0;
|
||||
for (const k of map.keys()) {
|
||||
if (k <= linesSeen && k > best) {
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
return best > 0 ? map.get(best)! : 0;
|
||||
}
|
||||
|
||||
interface PauseRegion {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
@@ -55,223 +83,524 @@ function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
|
||||
return regions;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
interface RatioChartPoint {
|
||||
tsMs: number;
|
||||
activity: number;
|
||||
knownPct: number;
|
||||
unknownPct: number;
|
||||
knownWords: number;
|
||||
unknownWords: number;
|
||||
totalWords: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
|
||||
const { timeline, events, loading, error } = useSessionDetail(sessionId);
|
||||
interface FallbackChartPoint {
|
||||
tsMs: number;
|
||||
totalWords: number;
|
||||
}
|
||||
|
||||
type TimelineEntry = {
|
||||
sampleMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
};
|
||||
|
||||
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 sorted = [...timeline].reverse();
|
||||
const pauseRegions = buildPauseRegions(events);
|
||||
|
||||
const chartData: ChartPoint[] = sorted.map((t, i) => {
|
||||
const prevWords = i > 0 ? sorted[i - 1]!.wordsSeen : 0;
|
||||
const delta = Math.max(0, t.wordsSeen - prevWords);
|
||||
const paused = pauseRegions.some((r) => t.sampleMs >= r.startMs && t.sampleMs <= r.endMs);
|
||||
return {
|
||||
tsMs: t.sampleMs,
|
||||
activity: delta,
|
||||
totalWords: t.wordsSeen,
|
||||
paused,
|
||||
};
|
||||
});
|
||||
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 lookupRate = buildLookupRateDisplay(
|
||||
session.yomitanLookupCount,
|
||||
getSessionDisplayWordCount(session),
|
||||
);
|
||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||
const seekCount = seekEvents.length;
|
||||
const cardEventCount = cardEvents.length;
|
||||
const pauseRegions = buildPauseRegions(events);
|
||||
|
||||
const maxActivity = Math.max(...chartData.map((d) => d.activity), 1);
|
||||
const yMax = Math.ceil(maxActivity * 1.3);
|
||||
|
||||
const tsMin = chartData.length > 0 ? chartData[0]!.tsMs : 0;
|
||||
const tsMax = chartData.length > 0 ? chartData[chartData.length - 1]!.tsMs : 0;
|
||||
if (hasKnownWords) {
|
||||
return (
|
||||
<RatioView
|
||||
sorted={sorted}
|
||||
knownWordsMap={knownWordsMap}
|
||||
cardEvents={cardEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
|
||||
{chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<ComposedChart data={chartData} barCategoryGap={0} barGap={0}>
|
||||
<defs>
|
||||
<linearGradient id={`actGrad-${sessionId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="tsMs"
|
||||
type="number"
|
||||
domain={[tsMin, tsMax]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatTime}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={24}
|
||||
domain={[0, yMax]}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={formatTime}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'New words') return [`${value}`, 'New words'];
|
||||
if (name === 'Total words') return [`${value}`, 'Total words'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<FallbackView
|
||||
sorted={sorted}
|
||||
cardEvents={cardEvents}
|
||||
yomitanLookupEvents={yomitanLookupEvents}
|
||||
pauseRegions={pauseRegions}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
lookupRate={lookupRate}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Pause shaded regions */}
|
||||
{pauseRegions.map((r, i) => (
|
||||
<ReferenceArea
|
||||
key={`pause-${i}`}
|
||||
yAxisId="left"
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
y1={0}
|
||||
y2={yMax}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
/* ── Ratio View (primary design) ────────────────────────────────── */
|
||||
|
||||
{/* Seek markers */}
|
||||
{seekEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`seek-${i}`}
|
||||
yAxisId="left"
|
||||
x={e.tsMs}
|
||||
stroke="#91d7e3"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 4"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
))}
|
||||
function RatioView({
|
||||
sorted,
|
||||
knownWordsMap,
|
||||
cardEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
}: {
|
||||
sorted: TimelineEntry[];
|
||||
knownWordsMap: Map<number, number>;
|
||||
cardEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: PauseRegion[];
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
}) {
|
||||
const chartData: RatioChartPoint[] = [];
|
||||
for (const t of sorted) {
|
||||
const totalWords = getSessionDisplayWordCount(t);
|
||||
if (totalWords === 0) continue;
|
||||
const knownWords = Math.min(lookupKnownWords(knownWordsMap, t.linesSeen), totalWords);
|
||||
const unknownWords = totalWords - knownWords;
|
||||
const knownPct = (knownWords / totalWords) * 100;
|
||||
chartData.push({
|
||||
tsMs: t.sampleMs,
|
||||
knownPct,
|
||||
unknownPct: 100 - knownPct,
|
||||
knownWords,
|
||||
unknownWords,
|
||||
totalWords,
|
||||
});
|
||||
}
|
||||
|
||||
{/* Card mined markers */}
|
||||
{cardEvents.map((e, i) => (
|
||||
<ReferenceLine
|
||||
key={`card-${i}`}
|
||||
yAxisId="left"
|
||||
x={e.tsMs}
|
||||
stroke="#a6da95"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.8}
|
||||
label={{
|
||||
value: '⛏',
|
||||
position: 'top',
|
||||
fill: '#a6da95',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
if (chartData.length === 0) {
|
||||
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
|
||||
}
|
||||
|
||||
<Area
|
||||
yAxisId="left"
|
||||
dataKey="activity"
|
||||
stroke="#c6a0f6"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#actGrad-${sessionId})`}
|
||||
name="New words"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#c6a0f6', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
dataKey="totalWords"
|
||||
stroke="#8aadf4"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
name="Total words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
const tsMin = chartData[0]!.tsMs;
|
||||
const tsMax = chartData[chartData.length - 1]!.tsMs;
|
||||
const finalTotal = chartData[chartData.length - 1]!.totalWords;
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-2 rounded-sm"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))',
|
||||
}}
|
||||
const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords }));
|
||||
|
||||
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}>
|
||||
<defs>
|
||||
<linearGradient id={`knownGrad-${session.sessionId}`} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="#a6da95" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#a6da95" stopOpacity={0.15} />
|
||||
</linearGradient>
|
||||
<linearGradient id={`unknownGrad-${session.sessionId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#c6a0f6" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#c6a0f6" stopOpacity={0.08} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid
|
||||
horizontal
|
||||
vertical={false}
|
||||
stroke="#494d64"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.4}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">New words</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#8aadf4' }} />
|
||||
<span className="text-ctp-overlay2">Total words</span>
|
||||
</span>
|
||||
{pauseCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-2 rounded-sm"
|
||||
style={{
|
||||
background: 'rgba(245,169,127,0.2)',
|
||||
border: '1px solid rgba(245,169,127,0.5)',
|
||||
|
||||
<XAxis
|
||||
dataKey="tsMs"
|
||||
type="number"
|
||||
domain={[tsMin, tsMax]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatTime}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="pct"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 50, 100]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={formatTime}
|
||||
formatter={(_value: number, name: string, props: { payload?: RatioChartPoint }) => {
|
||||
const d = props.payload;
|
||||
if (!d) return [_value, name];
|
||||
if (name === 'Known')
|
||||
return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known'];
|
||||
if (name === 'Unknown')
|
||||
return [
|
||||
`${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`,
|
||||
'Unknown',
|
||||
];
|
||||
return [_value, name];
|
||||
}}
|
||||
itemSorter={() => -1}
|
||||
/>
|
||||
|
||||
{/* Pause shaded regions */}
|
||||
{pauseRegions.map((r, i) => (
|
||||
<ReferenceArea
|
||||
key={`pause-${i}`}
|
||||
yAxisId="pct"
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
y1={0}
|
||||
y2={100}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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,
|
||||
}}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{seekCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-0.5 rounded"
|
||||
style={{ background: '#91d7e3', opacity: 0.7 }}
|
||||
))}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{seekCount} seek{seekCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[12px]">⛏</span>
|
||||
<span className="text-ctp-green">
|
||||
{Math.max(cardEventCount, cardsMined)} card
|
||||
{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
|
||||
</span>
|
||||
))}
|
||||
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="knownPct"
|
||||
stackId="ratio"
|
||||
stroke="#a6da95"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#knownGrad-${session.sessionId})`}
|
||||
name="Known"
|
||||
type="monotone"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#a6da95', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="pct"
|
||||
dataKey="unknownPct"
|
||||
stackId="ratio"
|
||||
stroke="#c6a0f6"
|
||||
strokeWidth={0}
|
||||
fill={`url(#unknownGrad-${session.sessionId})`}
|
||||
name="Unknown"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* ── Bottom: Word 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>
|
||||
<div className="flex-1 h-[28px]">
|
||||
<ResponsiveContainer width="100%" height={28}>
|
||||
<LineChart data={sparkData}>
|
||||
<XAxis dataKey="tsMs" type="number" domain={[tsMin, tsMax]} hide />
|
||||
<YAxis hide />
|
||||
<Line
|
||||
dataKey="totalWords"
|
||||
stroke="#8aadf4"
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.8}
|
||||
dot={false}
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<span className="text-[10px] text-ctp-blue font-semibold whitespace-nowrap tabular-nums">
|
||||
{finalTotal.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Stats bar ── */}
|
||||
<StatsBar
|
||||
hasKnownWords
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Fallback View (no known words data) ────────────────────────── */
|
||||
|
||||
function FallbackView({
|
||||
sorted,
|
||||
cardEvents,
|
||||
yomitanLookupEvents,
|
||||
pauseRegions,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
lookupRate,
|
||||
session,
|
||||
}: {
|
||||
sorted: TimelineEntry[];
|
||||
cardEvents: SessionEvent[];
|
||||
yomitanLookupEvents: SessionEvent[];
|
||||
pauseRegions: PauseRegion[];
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
session: SessionSummary;
|
||||
}) {
|
||||
const chartData: FallbackChartPoint[] = [];
|
||||
for (const t of sorted) {
|
||||
const totalWords = getSessionDisplayWordCount(t);
|
||||
if (totalWords === 0) continue;
|
||||
chartData.push({ tsMs: t.sampleMs, totalWords });
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <div className="text-ctp-overlay2 text-xs p-2">No word data for this session.</div>;
|
||||
}
|
||||
|
||||
const tsMin = chartData[0]!.tsMs;
|
||||
const tsMax = chartData[chartData.length - 1]!.tsMs;
|
||||
|
||||
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}>
|
||||
<XAxis
|
||||
dataKey="tsMs"
|
||||
type="number"
|
||||
domain={[tsMin, tsMax]}
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatTime}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={formatTime}
|
||||
formatter={(value: number) => [`${value.toLocaleString()}`, 'Total words']}
|
||||
/>
|
||||
|
||||
{pauseRegions.map((r, i) => (
|
||||
<ReferenceArea
|
||||
key={`pause-${i}`}
|
||||
x1={r.startMs}
|
||||
x2={r.endMs}
|
||||
fill="#f5a97f"
|
||||
fillOpacity={0.15}
|
||||
stroke="#f5a97f"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="3 3"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Line
|
||||
dataKey="totalWords"
|
||||
stroke="#8aadf4"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#8aadf4', stroke: '#1e2030', strokeWidth: 1 }}
|
||||
name="Total words"
|
||||
type="monotone"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<StatsBar
|
||||
hasKnownWords={false}
|
||||
pauseCount={pauseCount}
|
||||
seekCount={seekCount}
|
||||
cardEventCount={cardEventCount}
|
||||
session={session}
|
||||
lookupRate={lookupRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stats Bar ──────────────────────────────────────────────────── */
|
||||
|
||||
function StatsBar({
|
||||
hasKnownWords,
|
||||
pauseCount,
|
||||
seekCount,
|
||||
cardEventCount,
|
||||
session,
|
||||
lookupRate,
|
||||
}: {
|
||||
hasKnownWords: boolean;
|
||||
pauseCount: number;
|
||||
seekCount: number;
|
||||
cardEventCount: number;
|
||||
session: SessionSummary;
|
||||
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px] pt-1">
|
||||
{/* Group 1: Legend */}
|
||||
{hasKnownWords && (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ background: 'rgba(166,218,149,0.4)', border: '1px solid #a6da95' }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">Known</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm"
|
||||
style={{ background: 'rgba(198,160,246,0.2)', border: '1px solid #c6a0f6' }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">Unknown</span>
|
||||
</span>
|
||||
<span className="text-ctp-surface2">|</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group 2: Playback stats */}
|
||||
{pauseCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-peach">{pauseCount}</span> pause
|
||||
{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{seekCount > 0 && (
|
||||
<span className="text-ctp-overlay2">
|
||||
<span className="text-ctp-teal">{seekCount}</span> seek{seekCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{(pauseCount > 0 || seekCount > 0) && <span className="text-ctp-surface2">|</span>}
|
||||
|
||||
{/* Group 3: Learning events */}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-0.5 rounded"
|
||||
style={{ background: '#b7bdf8', opacity: 0.8 }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{session.yomitanLookupCount} Yomitan lookup
|
||||
{session.yomitanLookupCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
{lookupRate && (
|
||||
<span className="text-ctp-overlay2">
|
||||
lookup rate: <span className="text-ctp-sapphire">{lookupRate.shortValue}</span>{' '}
|
||||
<span className="text-ctp-subtext0">({lookupRate.longValue})</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[12px]">{'\u26CF'}</span>
|
||||
<span className="text-ctp-green">
|
||||
{Math.max(cardEventCount, session.cardsMined)} card
|
||||
{Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user