feat(stats): redesign session timeline and clean up vocabulary tab

- Replace cumulative line chart with activity-focused area chart showing per-interval new words
- Add total words as a blue line on a secondary right Y-axis
- Add pause shaded regions, seek markers, and card mined markers with numeric x-axis for reliable rendering
- Add clickable header logo with proper aspect ratio
- Remove unused "Hide particles & single kana" checkbox from vocabulary tab
This commit is contained in:
2026-03-14 23:07:05 -07:00
parent ff2d9141bc
commit 536f0a1315
3 changed files with 197 additions and 82 deletions

View File

@@ -30,7 +30,14 @@ export function App() {
return ( return (
<div className="min-h-screen flex flex-col bg-ctp-base"> <div className="min-h-screen flex flex-col bg-ctp-base">
<header className="px-4 pt-3 pb-0"> <header className="px-4 pt-3 pb-0">
<h1 className="text-lg font-semibold text-ctp-text mb-2">SubMiner Stats</h1> <button
type="button"
onClick={() => handleTabChange('overview')}
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
>
<img src="/favicon.png" alt="" className="h-6 object-contain" />
<h1 className="text-lg font-semibold text-ctp-text">SubMiner Stats</h1>
</button>
<TabBar activeTab={activeTab} onTabChange={handleTabChange} /> <TabBar activeTab={activeTab} onTabChange={handleTabChange} />
</header> </header>
<main className="flex-1 overflow-y-auto p-4"> <main className="flex-1 overflow-y-auto p-4">

View File

@@ -1,10 +1,11 @@
import { import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine, ReferenceArea, ReferenceLine,
} from 'recharts'; } from 'recharts';
import { useSessionDetail } from '../../hooks/useSessions'; import { useSessionDetail } from '../../hooks/useSessions';
import { CHART_THEME } from '../../lib/chart-theme'; import { CHART_THEME } from '../../lib/chart-theme';
import { EventType } from '../../types/stats'; import { EventType } from '../../types/stats';
import type { SessionEvent } from '../../types/stats';
interface SessionDetailProps { interface SessionDetailProps {
sessionId: number; sessionId: number;
@@ -27,10 +28,29 @@ function formatTime(ms: number): string {
}); });
} }
const EVENT_COLORS: Partial<Record<number, { color: string; label: string }>> = { interface PauseRegion { startMs: number; endMs: number }
[EventType.CARD_MINED]: { color: '#a6da95', label: 'Card mined' },
[EventType.PAUSE_START]: { color: '#f5a97f', label: 'Pause' }, 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;
}
interface ChartPoint {
tsMs: number;
activity: number;
totalWords: number;
paused: boolean;
}
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) { export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
const { timeline, events, loading, error } = useSessionDetail(sessionId); const { timeline, events, loading, error } = useSessionDetail(sessionId);
@@ -38,84 +58,188 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>; 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 (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
const chartData = [...timeline] const sorted = [...timeline].reverse();
.reverse() const pauseRegions = buildPauseRegions(events);
.map((t) => ({
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, tsMs: t.sampleMs,
time: formatTime(t.sampleMs), activity: delta,
words: t.wordsSeen, totalWords: t.wordsSeen,
cards: t.cardsMined, paused,
})); };
});
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 pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
const seekCount = events.filter( const seekCount = seekEvents.length;
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD, const cardEventCount = cardEvents.length;
).length;
const cardEventCount = events.filter((e) => e.eventType === EventType.CARD_MINED).length;
const markerEvents = events.filter((e) => EVENT_COLORS[e.eventType]); 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;
return ( return (
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3"> <div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
{chartData.length > 0 && ( {chartData.length > 0 && (
<ResponsiveContainer width="100%" height={120}> <ResponsiveContainer width="100%" height={150}>
<LineChart data={chartData}> <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 <XAxis
dataKey="time" dataKey="tsMs"
type="number"
domain={[tsMin, tsMax]}
tick={{ fontSize: 9, fill: CHART_THEME.tick }} tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tickFormatter={formatTime}
interval="preserveStartEnd"
/> />
<YAxis <YAxis
yAxisId="left"
tick={{ fontSize: 9, fill: CHART_THEME.tick }} tick={{ fontSize: 9, fill: CHART_THEME.tick }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
width={28} width={24}
domain={[0, yMax]}
allowDecimals={false}
/> />
<Tooltip contentStyle={tooltipStyle} /> <YAxis
<Line dataKey="words" stroke="#c6a0f6" strokeWidth={1.5} dot={false} name="Words" /> yAxisId="right"
<Line dataKey="cards" stroke="#a6da95" strokeWidth={1.5} dot={false} name="Cards" /> orientation="right"
{markerEvents.map((e, i) => { tick={{ fontSize: 9, fill: CHART_THEME.tick }}
const cfg = EVENT_COLORS[e.eventType]!; axisLine={false}
const matchIdx = chartData.findIndex((d) => d.tsMs >= e.tsMs); tickLine={false}
const x = matchIdx >= 0 ? chartData[matchIdx]!.time : null; width={30}
if (!x) return null; allowDecimals={false}
return ( />
<ReferenceLine <Tooltip
key={`${e.eventType}-${i}`} contentStyle={tooltipStyle}
x={x} labelFormatter={formatTime}
stroke={cfg.color} formatter={(value: number, name: string) => {
if (name === 'New words') return [`${value}`, 'New words'];
if (name === 'Total words') return [`${value}`, 'Total words'];
return [value, name];
}}
/>
{/* 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" strokeDasharray="3 3"
strokeOpacity={0.6} strokeWidth={1}
label=""
/> />
); ))}
})}
</LineChart> {/* 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}
/>
))}
{/* 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,
}}
/>
))}
<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> </ResponsiveContainer>
)} )}
<div className="flex flex-wrap gap-4 text-xs text-ctp-subtext0"> <div className="flex flex-wrap items-center gap-4 text-[11px]">
<span>{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span> <span className="flex items-center gap-1.5">
<span>{seekCount} seek{seekCount !== 1 ? 's' : ''}</span> <span className="inline-block w-3 h-2 rounded-sm" style={{ background: 'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))' }} />
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span> <span className="text-ctp-overlay2">New words</span>
</div> </span>
<span className="flex items-center gap-1.5">
{markerEvents.length > 0 && ( <span className="inline-block w-3 h-0.5 rounded" style={{ background: '#8aadf4' }} />
<div className="flex flex-wrap gap-3 text-[10px]"> <span className="text-ctp-overlay2">Total words</span>
{Object.entries(EVENT_COLORS).map(([type, cfg]) => { </span>
if (!cfg) return null; {pauseCount > 0 && (
const count = markerEvents.filter((e) => e.eventType === Number(type)).length; <span className="flex items-center gap-1.5">
if (count === 0) return null; <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)' }} />
return ( <span className="text-ctp-overlay2">{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span>
<span key={type} className="flex items-center gap-1">
<span className="inline-block w-2.5 h-0.5 rounded" style={{ background: cfg.color }} />
<span className="text-ctp-overlay2">{cfg.label} ({count})</span>
</span> </span>
);
})}
</div>
)} )}
{seekCount > 0 && (
<span className="flex items-center gap-1.5">
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#91d7e3', opacity: 0.7 }} />
<span className="text-ctp-overlay2">{seekCount} seek{seekCount !== 1 ? 's' : ''}</span>
</span>
)}
<span className="flex items-center gap-1.5">
<span className="text-[12px]"></span>
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span>
</span>
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { useVocabulary } from '../../hooks/useVocabulary'; import { useVocabulary } from '../../hooks/useVocabulary';
import { StatCard } from '../layout/StatCard'; import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList'; import { WordList } from './WordList';
@@ -7,7 +7,6 @@ import { KanjiDetailPanel } from './KanjiDetailPanel';
import { formatNumber } from '../../lib/formatters'; import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart'; import { TrendChart } from '../trends/TrendChart';
import { buildVocabularySummary } from '../../lib/dashboard-data'; import { buildVocabularySummary } from '../../lib/dashboard-data';
import { isFilterable } from './pos-helpers';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats'; import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
interface VocabularyTabProps { interface VocabularyTabProps {
@@ -18,18 +17,12 @@ interface VocabularyTabProps {
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) { export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
const { words, kanji, loading, error } = useVocabulary(); const { words, kanji, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null); const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [hideParticles, setHideParticles] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const filteredWords = useMemo(
() => hideParticles ? words.filter(w => !isFilterable(w)) : words,
[words, hideParticles],
);
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>; if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>; if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
const summary = buildVocabularySummary(filteredWords, kanji); const summary = buildVocabularySummary(words, kanji);
const handleSelectWord = (entry: VocabularyEntry): void => { const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId); onOpenWordDetail?.(entry.wordId);
@@ -52,15 +45,6 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs text-ctp-subtext0 select-none cursor-pointer">
<input
type="checkbox"
checked={hideParticles}
onChange={(e) => setHideParticles(e.target.checked)}
className="rounded border-ctp-surface2 bg-ctp-surface1 text-ctp-blue focus:ring-ctp-blue"
/>
Hide particles & single kana
</label>
<input <input
type="text" type="text"
value={search} value={search}
@@ -86,7 +70,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
</div> </div>
<WordList <WordList
words={filteredWords} words={words}
selectedKey={null} selectedKey={null}
onSelectWord={handleSelectWord} onSelectWord={handleSelectWord}
search={search} search={search}