mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
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:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user