feat(stats): drop seek markers from session timeline

Seek-forward / seek-backward events cluttered sessions with lots of
rewinds — a single episode could show dozens of << and >> icons
overlapping the card and pause markers. Stop requesting them from the
backend, drop them from buildSessionChartEvents, remove the seek
variant from SessionChartMarker, and strip the matching ReferenceLines,
overlay marker, popover branch, and legend entry from SessionDetail.
This commit is contained in:
2026-04-09 21:28:20 -07:00
parent c5e778d7d2
commit 42cc35dcd6
8 changed files with 10 additions and 150 deletions

View File

@@ -8,3 +8,4 @@ area: stats
- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
- Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
- Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
- Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.

View File

@@ -125,14 +125,13 @@ export function SessionDetail({ session }: SessionDetailProps) {
const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline);
const hasKnownWords = knownWordsMap.size > 0;
const { cardEvents, seekEvents, yomitanLookupEvents, pauseRegions, markers } =
const { cardEvents, yomitanLookupEvents, pauseRegions, markers } =
buildSessionChartEvents(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 activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey);
const activeMarker = useMemo<SessionChartMarker | null>(
@@ -230,7 +229,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
sorted={sorted}
knownWordsMap={knownWordsMap}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
@@ -242,7 +240,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
@@ -254,7 +251,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
<FallbackView
sorted={sorted}
cardEvents={cardEvents}
seekEvents={seekEvents}
yomitanLookupEvents={yomitanLookupEvents}
pauseRegions={pauseRegions}
markers={markers}
@@ -266,7 +262,6 @@ export function SessionDetail({ session }: SessionDetailProps) {
loadingNoteIds={loadingNoteIds}
onOpenNote={handleOpenNote}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
lookupRate={lookupRate}
session={session}
@@ -280,7 +275,6 @@ function RatioView({
sorted,
knownWordsMap,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
@@ -292,7 +286,6 @@ function RatioView({
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
@@ -300,7 +293,6 @@ function RatioView({
sorted: TimelineEntry[];
knownWordsMap: Map<number, number>;
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
@@ -312,7 +304,6 @@ function RatioView({
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
@@ -450,22 +441,6 @@ function RatioView({
/>
))}
{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
@@ -549,7 +524,6 @@ function RatioView({
<StatsBar
hasKnownWords
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
@@ -563,7 +537,6 @@ function RatioView({
function FallbackView({
sorted,
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,
@@ -575,14 +548,12 @@ function FallbackView({
loadingNoteIds,
onOpenNote,
pauseCount,
seekCount,
cardEventCount,
lookupRate,
session,
}: {
sorted: TimelineEntry[];
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: Array<{ startMs: number; endMs: number }>;
markers: SessionChartMarker[];
@@ -594,7 +565,6 @@ function FallbackView({
loadingNoteIds: Set<number>;
onOpenNote: (noteId: number) => void;
pauseCount: number;
seekCount: number;
cardEventCount: number;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
session: SessionSummary;
@@ -680,20 +650,6 @@ function FallbackView({
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}`}
@@ -735,7 +691,6 @@ function FallbackView({
<StatsBar
hasKnownWords={false}
pauseCount={pauseCount}
seekCount={seekCount}
cardEventCount={cardEventCount}
session={session}
lookupRate={lookupRate}
@@ -749,14 +704,12 @@ function FallbackView({
function StatsBar({
hasKnownWords,
pauseCount,
seekCount,
cardEventCount,
session,
lookupRate,
}: {
hasKnownWords: boolean;
pauseCount: number;
seekCount: number;
cardEventCount: number;
session: SessionSummary;
lookupRate: ReturnType<typeof buildLookupRateDisplay>;
@@ -791,12 +744,7 @@ function StatsBar({
{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>}
{pauseCount > 0 && <span className="text-ctp-surface2">|</span>}
{/* Group 3: Learning events */}
<span className="flex items-center gap-1.5">

View File

@@ -33,8 +33,6 @@ function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) {
case 'pause':
return '||';
case 'seek':
return marker.direction === 'backward' ? '<<' : '>>';
case 'card':
return '\u26CF';
}
@@ -44,10 +42,6 @@ function markerColors(marker: SessionChartMarker): { border: string; bg: string;
switch (marker.kind) {
case 'pause':
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
case 'seek':
return marker.direction === 'backward'
? { border: '#f5bde6', bg: 'rgba(245,189,230,0.16)', text: '#f5bde6' }
: { border: '#8bd5ca', bg: 'rgba(139,213,202,0.16)', text: '#8bd5ca' };
case 'card':
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
}

View File

@@ -41,35 +41,6 @@ test('SessionEventPopover renders formatted card-mine details with fetched note
assert.match(markup, /Open in Anki/);
});
test('SessionEventPopover renders seek metadata compactly', () => {
const marker: SessionChartMarker = {
key: 'seek-3000',
kind: 'seek',
anchorTsMs: 3_000,
eventTsMs: 3_000,
direction: 'backward',
fromMs: 5_000,
toMs: 1_500,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={new Map()}
loading={false}
pinned={false}
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Seek backward/);
assert.match(markup, /5\.0s/);
assert.match(markup, /1\.5s/);
assert.match(markup, /3\.5s/);
});
test('SessionEventPopover renders a cleaner fallback when AnkiConnect provides no preview fields', () => {
const marker: SessionChartMarker = {
key: 'card-9000',

View File

@@ -31,18 +31,12 @@ export function SessionEventPopover({
onClose,
onOpenNote,
}: SessionEventPopoverProps) {
const seekDurationLabel =
marker.kind === 'seek' && marker.fromMs !== null && marker.toMs !== null
? formatEventSeconds(Math.abs(marker.toMs - marker.fromMs))?.replace(/\.0s$/, 's')
: null;
return (
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
<div className="mb-2 flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-ctp-text">
{marker.kind === 'pause' && 'Paused'}
{marker.kind === 'seek' && `Seek ${marker.direction}`}
{marker.kind === 'card' && 'Card mined'}
</div>
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
@@ -72,7 +66,6 @@ export function SessionEventPopover({
) : null}
<div className="text-sm">
{marker.kind === 'pause' && '||'}
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
{marker.kind === 'card' && '\u26CF'}
</div>
</div>
@@ -84,19 +77,6 @@ export function SessionEventPopover({
</div>
)}
{marker.kind === 'seek' && (
<div className="space-y-1 text-xs text-ctp-subtext0">
<div>
From{' '}
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
</div>
<div>
Length <span className="text-ctp-peach">{seekDurationLabel ?? '\u2014'}</span>
</div>
</div>
)}
{marker.kind === 'card' && (
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">

View File

@@ -46,9 +46,10 @@ test('buildSessionChartEvents keeps only chart-relevant events and pairs pause r
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
]);
// Seek events are intentionally dropped from the chart — they were too noisy.
assert.deepEqual(
chartEvents.seekEvents.map((event) => event.eventType),
[EventType.SEEK_FORWARD, EventType.SEEK_BACKWARD],
chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
[],
);
assert.deepEqual(
chartEvents.cardEvents.map((event) => event.tsMs),

View File

@@ -29,25 +29,20 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
]);
// Seek events are intentionally dropped — too noisy on the session chart.
assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind),
['seek', 'pause', 'card'],
['pause', 'card'],
);
const seekMarker = chartEvents.markers[0]!;
assert.equal(seekMarker.kind, 'seek');
assert.equal(seekMarker.direction, 'forward');
assert.equal(seekMarker.fromMs, 1_000);
assert.equal(seekMarker.toMs, 5_500);
const pauseMarker = chartEvents.markers[1]!;
const pauseMarker = chartEvents.markers[0]!;
assert.equal(pauseMarker.kind, 'pause');
assert.equal(pauseMarker.startMs, 2_000);
assert.equal(pauseMarker.endMs, 5_000);
assert.equal(pauseMarker.durationMs, 3_000);
assert.equal(pauseMarker.anchorTsMs, 3_500);
const cardMarker = chartEvents.markers[2]!;
const cardMarker = chartEvents.markers[1]!;
assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]);
assert.equal(cardMarker.cardsDelta, 2);

View File

@@ -2,8 +2,6 @@ import { EventType, type SessionEvent } from '../types/stats';
export const SESSION_CHART_EVENT_TYPES = [
EventType.CARD_MINED,
EventType.SEEK_FORWARD,
EventType.SEEK_BACKWARD,
EventType.PAUSE_START,
EventType.PAUSE_END,
EventType.YOMITAN_LOOKUP,
@@ -16,7 +14,6 @@ export interface PauseRegion {
export interface SessionChartEvents {
cardEvents: SessionEvent[];
seekEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
markers: SessionChartMarker[];
@@ -58,15 +55,6 @@ export type SessionChartMarker =
endMs: number;
durationMs: number;
}
| {
key: string;
kind: 'seek';
anchorTsMs: number;
eventTsMs: number;
direction: 'forward' | 'backward';
fromMs: number | null;
toMs: number | null;
}
| {
key: string;
kind: 'card';
@@ -295,7 +283,6 @@ export function projectSessionMarkerLeftPx({
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = [];
const seekEvents: SessionEvent[] = [];
const yomitanLookupEvents: SessionEvent[] = [];
const pauseRegions: PauseRegion[] = [];
const markers: SessionChartMarker[] = [];
@@ -317,22 +304,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
});
}
break;
case EventType.SEEK_FORWARD:
case EventType.SEEK_BACKWARD:
seekEvents.push(event);
{
const payload = parsePayload(event.payload);
markers.push({
key: `seek-${event.tsMs}-${event.eventType}`,
kind: 'seek',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
direction: event.eventType === EventType.SEEK_BACKWARD ? 'backward' : 'forward',
fromMs: readNumberField(payload?.fromMs),
toMs: readNumberField(payload?.toMs),
});
}
break;
case EventType.YOMITAN_LOOKUP:
yomitanLookupEvents.push(event);
break;
@@ -376,7 +347,6 @@ export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEve
return {
cardEvents,
seekEvents,
yomitanLookupEvents,
pauseRegions,
markers,