Loading timeline...
;
if (error) return Error: {error}
;
- const chartData = [...timeline]
- .reverse()
- .map((t) => ({
+ 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,
- time: formatTime(t.sampleMs),
- words: t.wordsSeen,
- cards: t.cardsMined,
- }));
+ activity: delta,
+ totalWords: t.wordsSeen,
+ 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 seekCount = events.filter(
- (e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
- ).length;
- const cardEventCount = events.filter((e) => e.eventType === EventType.CARD_MINED).length;
+ const seekCount = seekEvents.length;
+ const cardEventCount = cardEvents.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 (
{chartData.length > 0 && (
-
-
+
+
+
+
+
+
+
+
-
-
-
- {markerEvents.map((e, i) => {
- const cfg = EVENT_COLORS[e.eventType]!;
- const matchIdx = chartData.findIndex((d) => d.tsMs >= e.tsMs);
- const x = matchIdx >= 0 ? chartData[matchIdx]!.time : null;
- if (!x) return null;
- return (
-
- );
- })}
-
+
+ {
+ 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) => (
+
+ ))}
+
+ {/* Seek markers */}
+ {seekEvents.map((e, i) => (
+
+ ))}
+
+ {/* Card mined markers */}
+ {cardEvents.map((e, i) => (
+
+ ))}
+
+
+
+
)}
- (null);
- const [hideParticles, setHideParticles] = useState(true);
const [search, setSearch] = useState('');
- const filteredWords = useMemo(
- () => hideParticles ? words.filter(w => !isFilterable(w)) : words,
- [words, hideParticles],
- );
-
if (loading) return
- {pauseCount} pause{pauseCount !== 1 ? 's' : ''}
- {seekCount} seek{seekCount !== 1 ? 's' : ''}
- {Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
+
);
}
diff --git a/stats/src/components/vocabulary/VocabularyTab.tsx b/stats/src/components/vocabulary/VocabularyTab.tsx
index a8602a7..03e855d 100644
--- a/stats/src/components/vocabulary/VocabularyTab.tsx
+++ b/stats/src/components/vocabulary/VocabularyTab.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useState } from 'react';
import { useVocabulary } from '../../hooks/useVocabulary';
import { StatCard } from '../layout/StatCard';
import { WordList } from './WordList';
@@ -7,7 +7,6 @@ import { KanjiDetailPanel } from './KanjiDetailPanel';
import { formatNumber } from '../../lib/formatters';
import { TrendChart } from '../trends/TrendChart';
import { buildVocabularySummary } from '../../lib/dashboard-data';
-import { isFilterable } from './pos-helpers';
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
interface VocabularyTabProps {
@@ -18,18 +17,12 @@ interface VocabularyTabProps {
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
const { words, kanji, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState
+
+
+ New words
+
+
+
+ Total words
+
+ {pauseCount > 0 && (
+
+
+ {pauseCount} pause{pauseCount !== 1 ? 's' : ''}
+
+ )}
+ {seekCount > 0 && (
+
+
+ {seekCount} seek{seekCount !== 1 ? 's' : ''}
+
+ )}
+
+ ⛏
+ {Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
+
-
- {markerEvents.length > 0 && (
-
- {Object.entries(EVENT_COLORS).map(([type, cfg]) => {
- if (!cfg) return null;
- const count = markerEvents.filter((e) => e.eventType === Number(type)).length;
- if (count === 0) return null;
- return (
-
-
- {cfg.label} ({count})
-
- );
- })}
-
- )}
Loading...
;
if (error) return Error: {error}
;
- const summary = buildVocabularySummary(filteredWords, kanji);
+ const summary = buildVocabularySummary(words, kanji);
const handleSelectWord = (entry: VocabularyEntry): void => {
onOpenWordDetail?.(entry.wordId);
@@ -52,15 +45,6 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: Vocabular
-