mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
feat(stats): show seek length in session event tooltip
This commit is contained in:
98
stats/src/components/sessions/SessionEventPopover.test.tsx
Normal file
98
stats/src/components/sessions/SessionEventPopover.test.tsx
Normal file
@@ -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(
|
||||||
|
<SessionEventPopover
|
||||||
|
marker={marker}
|
||||||
|
noteInfos={
|
||||||
|
new Map([
|
||||||
|
[11, { noteId: 11, expression: '冒険者', context: '駆け出しの冒険者だ', meaning: null }],
|
||||||
|
[22, { noteId: 22, expression: '呪い', context: null, meaning: 'curse' }],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
loading={false}
|
||||||
|
pinned={false}
|
||||||
|
onTogglePinned={() => {}}
|
||||||
|
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(
|
||||||
|
<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',
|
||||||
|
kind: 'card',
|
||||||
|
anchorTsMs: 9_000,
|
||||||
|
eventTsMs: 9_000,
|
||||||
|
noteIds: [91],
|
||||||
|
cardsDelta: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<SessionEventPopover
|
||||||
|
marker={marker}
|
||||||
|
noteInfos={new Map()}
|
||||||
|
loading={false}
|
||||||
|
pinned={true}
|
||||||
|
onTogglePinned={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
onOpenNote={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(markup, /Pinned/);
|
||||||
|
assert.match(markup, /Preview unavailable from AnkiConnect/);
|
||||||
|
assert.doesNotMatch(markup, /No readable note fields returned/);
|
||||||
|
});
|
||||||
153
stats/src/components/sessions/SessionEventPopover.tsx
Normal file
153
stats/src/components/sessions/SessionEventPopover.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { formatEventSeconds, type SessionChartMarker, type SessionEventNoteInfo } from '../../lib/session-events';
|
||||||
|
|
||||||
|
interface SessionEventPopoverProps {
|
||||||
|
marker: SessionChartMarker;
|
||||||
|
noteInfos: Map<number, SessionEventNoteInfo>;
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{pinned ? (
|
||||||
|
<span className="rounded-full border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-blue">
|
||||||
|
Pinned
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePinned}
|
||||||
|
className="rounded-md border border-ctp-surface2 px-1.5 py-0.5 text-[10px] text-ctp-overlay1 transition-colors hover:bg-ctp-surface1 hover:text-ctp-text"
|
||||||
|
>
|
||||||
|
{pinned ? 'Unpin' : 'Pin'}
|
||||||
|
</button>
|
||||||
|
{pinned ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close event popup"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-ctp-surface2 px-1.5 py-0.5 text-[10px] text-ctp-overlay1 transition-colors hover:bg-ctp-surface1 hover:text-ctp-text"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="text-sm">
|
||||||
|
{marker.kind === 'pause' && '||'}
|
||||||
|
{marker.kind === 'seek' && (marker.direction === 'backward' ? '<<' : '>>')}
|
||||||
|
{marker.kind === 'card' && '\u26CF'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{marker.kind === 'pause' && (
|
||||||
|
<div className="text-xs text-ctp-subtext0">
|
||||||
|
Duration: <span className="text-ctp-peach">{formatEventSeconds(marker.durationMs)}</span>
|
||||||
|
</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">
|
||||||
|
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{marker.noteIds.length > 0 ? (
|
||||||
|
marker.noteIds.map((noteId) => {
|
||||||
|
const info = noteInfos.get(noteId);
|
||||||
|
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={noteId}
|
||||||
|
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
|
||||||
|
Note {noteId}
|
||||||
|
</div>
|
||||||
|
{!hasPreview ? (
|
||||||
|
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{info?.expression ? (
|
||||||
|
<div className="mb-1 text-sm font-medium text-ctp-text">{info.expression}</div>
|
||||||
|
) : null}
|
||||||
|
{info?.context ? (
|
||||||
|
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
|
||||||
|
) : null}
|
||||||
|
{info?.meaning ? (
|
||||||
|
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
|
||||||
|
) : null}
|
||||||
|
{!hasPreview ? (
|
||||||
|
<div className="mb-2 text-xs text-ctp-overlay1">
|
||||||
|
Preview unavailable from AnkiConnect.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenNote(noteId)}
|
||||||
|
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
|
||||||
|
>
|
||||||
|
Open in Anki
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user