mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -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