mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user