From a69254f976b46759abdce25d6fd49d58f4f09738 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 17 Mar 2026 23:38:45 -0700 Subject: [PATCH] feat(stats): show seek length in session event tooltip --- .../sessions/SessionEventPopover.test.tsx | 98 +++++++++++ .../sessions/SessionEventPopover.tsx | 153 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 stats/src/components/sessions/SessionEventPopover.test.tsx create mode 100644 stats/src/components/sessions/SessionEventPopover.tsx diff --git a/stats/src/components/sessions/SessionEventPopover.test.tsx b/stats/src/components/sessions/SessionEventPopover.test.tsx new file mode 100644 index 0000000..5b0a136 --- /dev/null +++ b/stats/src/components/sessions/SessionEventPopover.test.tsx @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { SessionChartMarker } from '../../lib/session-events'; +import { SessionEventPopover } from './SessionEventPopover'; + +test('SessionEventPopover renders formatted card-mine details with fetched note info', () => { + const marker: SessionChartMarker = { + key: 'card-6000', + kind: 'card', + anchorTsMs: 6_000, + eventTsMs: 6_000, + noteIds: [11, 22], + cardsDelta: 2, + }; + + const markup = renderToStaticMarkup( + {}} + onClose={() => {}} + onOpenNote={() => {}} + />, + ); + + assert.match(markup, /Card mined/); + assert.match(markup, /\+2 cards/); + assert.match(markup, /冒険者/); + assert.match(markup, /呪い/); + assert.match(markup, /駆け出しの冒険者だ/); + assert.match(markup, /curse/); + assert.match(markup, /Pin/); + 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( + {}} + 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', + kind: 'card', + anchorTsMs: 9_000, + eventTsMs: 9_000, + noteIds: [91], + cardsDelta: 1, + }; + + const markup = renderToStaticMarkup( + {}} + onClose={() => {}} + onOpenNote={() => {}} + />, + ); + + assert.match(markup, /Pinned/); + assert.match(markup, /Preview unavailable from AnkiConnect/); + assert.doesNotMatch(markup, /No readable note fields returned/); +}); diff --git a/stats/src/components/sessions/SessionEventPopover.tsx b/stats/src/components/sessions/SessionEventPopover.tsx new file mode 100644 index 0000000..fca6a0e --- /dev/null +++ b/stats/src/components/sessions/SessionEventPopover.tsx @@ -0,0 +1,153 @@ +import { formatEventSeconds, type SessionChartMarker, type SessionEventNoteInfo } from '../../lib/session-events'; + +interface SessionEventPopoverProps { + marker: SessionChartMarker; + noteInfos: Map; + loading: boolean; + pinned: boolean; + onTogglePinned: () => void; + onClose: () => void; + onOpenNote: (noteId: number) => void; +} + +function formatEventTime(tsMs: number): string { + return new Date(tsMs).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +export function SessionEventPopover({ + marker, + noteInfos, + loading, + pinned, + onTogglePinned, + 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 ( +
+
+
+
+ {marker.kind === 'pause' && 'Paused'} + {marker.kind === 'seek' && `Seek ${marker.direction}`} + {marker.kind === 'card' && 'Card mined'} +
+
{formatEventTime(marker.eventTsMs)}
+
+
+ {pinned ? ( + + Pinned + + ) : null} + + {pinned ? ( + + ) : null} +
+ {marker.kind === 'pause' && '||'} + {marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')} + {marker.kind === 'card' && '\u26CF'} +
+
+
+ + {marker.kind === 'pause' && ( +
+ Duration: {formatEventSeconds(marker.durationMs)} +
+ )} + + {marker.kind === 'seek' && ( +
+
+ From {formatEventSeconds(marker.fromMs) ?? '\u2014'}{' '} + to {formatEventSeconds(marker.toMs) ?? '\u2014'} +
+
+ Length {seekDurationLabel ?? '\u2014'} +
+
+ )} + + {marker.kind === 'card' && ( +
+
+ +{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'} +
+ {loading ? ( +
Loading Anki note info...
+ ) : null} +
+ {marker.noteIds.length > 0 ? ( + marker.noteIds.map((noteId) => { + const info = noteInfos.get(noteId); + const hasPreview = Boolean(info?.expression || info?.context || info?.meaning); + return ( +
+
+
+ Note {noteId} +
+ {!hasPreview ? ( +
Preview unavailable
+ ) : null} +
+ {info?.expression ? ( +
{info.expression}
+ ) : null} + {info?.context ? ( +
{info.context}
+ ) : null} + {info?.meaning ? ( +
{info.meaning}
+ ) : null} + {!hasPreview ? ( +
+ Preview unavailable from AnkiConnect. +
+ ) : null} + +
+ ); + }) + ) : ( +
No linked note ids recorded.
+ )} +
+
+ )} +
+ ); +}