diff --git a/backlog/tasks/task-193 - Fix-session-chart-event-popup-position-drift.md b/backlog/tasks/task-193 - Fix-session-chart-event-popup-position-drift.md new file mode 100644 index 0000000..9a8f71f --- /dev/null +++ b/backlog/tasks/task-193 - Fix-session-chart-event-popup-position-drift.md @@ -0,0 +1,62 @@ +--- +id: TASK-193 +title: Fix session chart event popup position drift +status: Done +assignee: + - Codex +created_date: '2026-03-17 23:55' +updated_date: '2026-03-17 23:59' +labels: + - stats + - ui + - bug +milestone: m-1 +dependencies: [] +references: + - stats/src/components/sessions/SessionDetail.tsx + - stats/src/components/sessions/SessionEventOverlay.tsx + - stats/src/lib/session-events.ts +priority: medium +ordinal: 105600 +--- + +## Description + + + +Fix the session timeline event popup trigger positions so hover markers stay aligned with the underlying chart event lines across the full visible time range. + + + +## Acceptance Criteria + + + +- [x] #1 Event popup triggers stay horizontally aligned with chart event lines from session start through session end. +- [x] #2 Alignment logic uses the rendered chart plot area rather than guessed container percentages. +- [x] #3 Regression coverage locks the marker-position projection math. + + +## Implementation Plan + + + +1. Add a failing regression test for marker-position projection with chart offsets. +2. Capture the rendered plot box from Recharts and pass it into the overlay. +3. Position overlay markers in plot-area pixels, rerun targeted stats verification, then record the result. + + +## Outcome + + + +Completed. Session event hover markers now read the actual Recharts plot-area offset and width, then project marker X positions into plot-area pixels instead of full-container percentages. That keeps popup triggers aligned with the underlying reference lines across long session timelines. + +Verification: + +- `bun test stats/src/lib/session-events.test.ts stats/src/lib/session-detail.test.tsx stats/src/components/sessions/SessionEventPopover.test.tsx` +- `cd stats && bun run build` +- `bun x prettier --check 'stats/src/components/sessions/SessionDetail.tsx' 'stats/src/components/sessions/SessionEventOverlay.tsx' 'stats/src/lib/session-events.ts' 'stats/src/lib/session-events.test.ts' 'backlog/tasks/task-193 - Fix-session-chart-event-popup-position-drift.md'` +- `bun run typecheck:stats` still fails on pre-existing unrelated errors in `src/components/anime/AnilistSelector.tsx`, `src/components/library/LibraryTab.tsx`, `src/lib/reading-utils.test.ts`, `src/lib/reading-utils.ts`, `src/lib/vocabulary-tab.test.ts`, and `src/lib/yomitan-lookup.test.tsx` + + diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx index 16025f5..ddf8f45 100644 --- a/stats/src/components/sessions/SessionDetail.tsx +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -11,6 +11,7 @@ import { ReferenceArea, ReferenceLine, CartesianGrid, + Customized, } from 'recharts'; import { useSessionDetail } from '../../hooks/useSessions'; import { getStatsClient } from '../../hooks/useStatsApi'; @@ -18,8 +19,11 @@ import type { KnownWordsTimelinePoint } from '../../hooks/useSessions'; import { CHART_THEME } from '../../lib/chart-theme'; import { buildSessionChartEvents, + extractSessionEventNoteInfo, + resolveActiveSessionMarkerKey, type SessionChartMarker, type SessionEventNoteInfo, + type SessionChartPlotArea, } from '../../lib/session-events'; import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import { getSessionDisplayWordCount } from '../../lib/session-word-count'; @@ -69,19 +73,6 @@ function lookupKnownWords(map: Map, linesSeen: number): number { return best > 0 ? map.get(best)! : 0; } -function extractNoteExpression(note: { - noteId: number; - fields: Record; -}): SessionEventNoteInfo { - const expression = - note.fields?.Expression?.value ?? - note.fields?.expression?.value ?? - note.fields?.Word?.value ?? - note.fields?.word?.value ?? - ''; - return { noteId: note.noteId, expression }; -} - interface RatioChartPoint { tsMs: number; knownPct: number; @@ -102,11 +93,30 @@ type TimelineEntry = { tokensSeen: number; }; +function SessionChartOffsetProbe({ + offset, + onPlotAreaChange, +}: { + offset?: { left?: number; width?: number }; + onPlotAreaChange: (plotArea: SessionChartPlotArea) => void; +}) { + useEffect(() => { + if (!offset) return; + const { left, width } = offset; + if (typeof left !== 'number' || !Number.isFinite(left)) return; + if (typeof width !== 'number' || !Number.isFinite(width)) return; + onPlotAreaChange({ left, width }); + }, [offset?.left, offset?.width, onPlotAreaChange]); + + return null; +} + export function SessionDetail({ session }: SessionDetailProps) { const { timeline, events, knownWordsTimeline, loading, error } = useSessionDetail( session.sessionId, ); - const [activeMarkerKey, setActiveMarkerKey] = useState(null); + const [hoveredMarkerKey, setHoveredMarkerKey] = useState(null); + const [pinnedMarkerKey, setPinnedMarkerKey] = useState(null); const [noteInfos, setNoteInfos] = useState>(new Map()); const [loadingNoteIds, setLoadingNoteIds] = useState>(new Set()); const requestedNoteIdsRef = useRef>(new Set()); @@ -124,6 +134,7 @@ export function SessionDetail({ session }: SessionDetailProps) { 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( () => markers.find((marker) => marker.key === activeMarkerKey) ?? null, [markers, activeMarkerKey], @@ -161,7 +172,8 @@ export function SessionDetail({ session }: SessionDetailProps) { setNoteInfos((prev) => { const next = new Map(prev); for (const note of notes) { - const info = extractNoteExpression(note); + const info = extractSessionEventNoteInfo(note); + if (!info) continue; next.set(info.noteId, info); } return next; @@ -205,8 +217,10 @@ export function SessionDetail({ session }: SessionDetailProps) { yomitanLookupEvents={yomitanLookupEvents} pauseRegions={pauseRegions} markers={markers} - activeMarkerKey={activeMarkerKey} - onActiveMarkerChange={setActiveMarkerKey} + hoveredMarkerKey={hoveredMarkerKey} + onHoveredMarkerChange={setHoveredMarkerKey} + pinnedMarkerKey={pinnedMarkerKey} + onPinnedMarkerChange={setPinnedMarkerKey} noteInfos={noteInfos} loadingNoteIds={loadingNoteIds} onOpenNote={handleOpenNote} @@ -227,8 +241,10 @@ export function SessionDetail({ session }: SessionDetailProps) { yomitanLookupEvents={yomitanLookupEvents} pauseRegions={pauseRegions} markers={markers} - activeMarkerKey={activeMarkerKey} - onActiveMarkerChange={setActiveMarkerKey} + hoveredMarkerKey={hoveredMarkerKey} + onHoveredMarkerChange={setHoveredMarkerKey} + pinnedMarkerKey={pinnedMarkerKey} + onPinnedMarkerChange={setPinnedMarkerKey} noteInfos={noteInfos} loadingNoteIds={loadingNoteIds} onOpenNote={handleOpenNote} @@ -251,8 +267,10 @@ function RatioView({ yomitanLookupEvents, pauseRegions, markers, - activeMarkerKey, - onActiveMarkerChange, + hoveredMarkerKey, + onHoveredMarkerChange, + pinnedMarkerKey, + onPinnedMarkerChange, noteInfos, loadingNoteIds, onOpenNote, @@ -269,8 +287,10 @@ function RatioView({ yomitanLookupEvents: SessionEvent[]; pauseRegions: Array<{ startMs: number; endMs: number }>; markers: SessionChartMarker[]; - activeMarkerKey: string | null; - onActiveMarkerChange: (markerKey: string | null) => void; + hoveredMarkerKey: string | null; + onHoveredMarkerChange: (markerKey: string | null) => void; + pinnedMarkerKey: string | null; + onPinnedMarkerChange: (markerKey: string | null) => void; noteInfos: Map; loadingNoteIds: Set; onOpenNote: (noteId: number) => void; @@ -280,6 +300,7 @@ function RatioView({ lookupRate: ReturnType; session: SessionSummary; }) { + const [plotArea, setPlotArea] = useState(null); const chartData: RatioChartPoint[] = []; for (const t of sorted) { const totalWords = getSessionDisplayWordCount(t); @@ -313,84 +334,99 @@ function RatioView({
- - - - - - - - - - - - - - - `${v}%`} - axisLine={false} - tickLine={false} - width={32} - /> - - { - const d = props.payload; - if (!d) return [_value, name]; - if (name === 'Known') - return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known']; - if (name === 'Unknown') - return [ - `${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`, - 'Unknown', - ]; - return [_value, name]; - }} - itemSorter={() => -1} - /> - - {/* Pause shaded regions */} - {pauseRegions.map((r, i) => ( - { + setPlotArea((prevPlotArea) => + prevPlotArea && + prevPlotArea.left === nextPlotArea.left && + prevPlotArea.width === nextPlotArea.width + ? prevPlotArea + : nextPlotArea, + ); + }} + /> + } /> - ))} + + + + + + + + + + - {/* Card mine markers */} + + + + `${v}%`} + axisLine={false} + tickLine={false} + width={32} + /> + + { + const d = props.payload; + if (!d) return [_value, name]; + if (name === 'Known') + return [`${d.knownWords.toLocaleString()} (${d.knownPct.toFixed(1)}%)`, 'Known']; + if (name === 'Unknown') + return [ + `${d.unknownWords.toLocaleString()} (${d.unknownPct.toFixed(1)}%)`, + 'Unknown', + ]; + return [_value, name]; + }} + itemSorter={() => -1} + /> + + {/* Pause shaded regions */} + {pauseRegions.map((r, i) => ( + + ))} + + {/* Card mine markers */} {cardEvents.map((e, i) => ( ( ))} - - + + ; markers: SessionChartMarker[]; - activeMarkerKey: string | null; - onActiveMarkerChange: (markerKey: string | null) => void; + hoveredMarkerKey: string | null; + onHoveredMarkerChange: (markerKey: string | null) => void; + pinnedMarkerKey: string | null; + onPinnedMarkerChange: (markerKey: string | null) => void; noteInfos: Map; loadingNoteIds: Set; onOpenNote: (noteId: number) => void; @@ -544,6 +587,7 @@ function FallbackView({ lookupRate: ReturnType; session: SessionSummary; }) { + const [plotArea, setPlotArea] = useState(null); const chartData: FallbackChartPoint[] = []; for (const t of sorted) { const totalWords = getSessionDisplayWordCount(t); @@ -563,42 +607,57 @@ function FallbackView({
- - - [`${value.toLocaleString()}`, 'Total tokens']} - /> - - {pauseRegions.map((r, i) => ( - { + setPlotArea((prevPlotArea) => + prevPlotArea && + prevPlotArea.left === nextPlotArea.left && + prevPlotArea.width === nextPlotArea.width + ? prevPlotArea + : nextPlotArea, + ); + }} + /> + } /> - ))} + + + [`${value.toLocaleString()}`, 'Total tokens']} + /> + + {pauseRegions.map((r, i) => ( + + ))} {cardEvents.map((e, i) => ( ))} - + void; + pinnedMarkerKey: string | null; + onPinnedMarkerChange: (markerKey: string | null) => void; + noteInfos: Map; + loadingNoteIds: Set; + onOpenNote: (noteId: number) => void; +} + +function toPercent(tsMs: number, tsMin: number, tsMax: number): number { + if (tsMax <= tsMin) return 50; + const ratio = ((tsMs - tsMin) / (tsMax - tsMin)) * 100; + return Math.max(0, Math.min(100, ratio)); +} + +function markerLabel(marker: SessionChartMarker): string { + switch (marker.kind) { + case 'pause': + return '||'; + case 'seek': + return marker.direction === 'backward' ? '<<' : '>>'; + case 'card': + return '\u26CF'; + } +} + +function markerColors(marker: SessionChartMarker): { border: string; bg: string; text: 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' }; + } +} + +function popupAlignment(percent: number): string { + if (percent <= 15) return 'left-0 translate-x-0'; + if (percent >= 85) return 'right-0 translate-x-0'; + return 'left-1/2 -translate-x-1/2'; +} + +function handleWrapperBlur( + event: FocusEvent, + onHoveredMarkerChange: (markerKey: string | null) => void, + pinnedMarkerKey: string | null, + markerKey: string, +): void { + if (pinnedMarkerKey === markerKey) return; + const nextFocused = event.relatedTarget; + if (nextFocused instanceof Node && event.currentTarget.contains(nextFocused)) { + return; + } + onHoveredMarkerChange(null); +} + +function handleWrapperMouseLeave( + event: MouseEvent, + onHoveredMarkerChange: (markerKey: string | null) => void, + pinnedMarkerKey: string | null, + markerKey: string, +): void { + if (pinnedMarkerKey === markerKey) return; + const nextHovered = event.relatedTarget; + if (nextHovered instanceof Node && event.currentTarget.contains(nextHovered)) { + return; + } + onHoveredMarkerChange(null); +} + +export function SessionEventOverlay({ + markers, + tsMin, + tsMax, + plotArea, + hoveredMarkerKey, + onHoveredMarkerChange, + pinnedMarkerKey, + onPinnedMarkerChange, + noteInfos, + loadingNoteIds, + onOpenNote, +}: SessionEventOverlayProps) { + if (markers.length === 0) return null; + + const rootRef = useRef(null); + const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey); + + useEffect(() => { + if (!pinnedMarkerKey) return; + + function handleDocumentPointerDown(event: PointerEvent): void { + if (rootRef.current?.contains(event.target as Node)) { + return; + } + onPinnedMarkerChange(null); + onHoveredMarkerChange(null); + } + + function handleDocumentKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Escape') return; + onPinnedMarkerChange(null); + onHoveredMarkerChange(null); + } + + document.addEventListener('pointerdown', handleDocumentPointerDown); + document.addEventListener('keydown', handleDocumentKeyDown); + return () => { + document.removeEventListener('pointerdown', handleDocumentPointerDown); + document.removeEventListener('keydown', handleDocumentKeyDown); + }; + }, [pinnedMarkerKey, onHoveredMarkerChange, onPinnedMarkerChange]); + + return ( +
+ {markers.map((marker) => { + const percent = toPercent(marker.anchorTsMs, tsMin, tsMax); + const left = plotArea + ? `${projectSessionMarkerLeftPx({ + anchorTsMs: marker.anchorTsMs, + tsMin, + tsMax, + plotLeftPx: plotArea.left, + plotWidthPx: plotArea.width, + })}px` + : `${percent}%`; + const colors = markerColors(marker); + const isActive = marker.key === activeMarkerKey; + const isPinned = marker.key === pinnedMarkerKey; + const loading = + marker.kind === 'card' && marker.noteIds.some((noteId) => loadingNoteIds.has(noteId)); + + return ( +
onHoveredMarkerChange(marker.key)} + onMouseLeave={(event) => + handleWrapperMouseLeave(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key) + } + onFocusCapture={() => onHoveredMarkerChange(marker.key)} + onBlurCapture={(event) => + handleWrapperBlur(event, onHoveredMarkerChange, pinnedMarkerKey, marker.key) + } + > +
+ + {isActive ? ( +
{ + if (!isPinned) { + onPinnedMarkerChange(marker.key); + } + }} + > + + onPinnedMarkerChange( + togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key), + ) + } + onClose={() => { + onPinnedMarkerChange(null); + onHoveredMarkerChange(null); + }} + onOpenNote={onOpenNote} + /> +
+ ) : null} +
+
+ ); + })} +
+ ); +} diff --git a/stats/src/lib/session-events.test.ts b/stats/src/lib/session-events.test.ts new file mode 100644 index 0000000..b9176d9 --- /dev/null +++ b/stats/src/lib/session-events.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { EventType } from '../types/stats'; +import { + buildSessionChartEvents, + extractSessionEventNoteInfo, + projectSessionMarkerLeftPx, + resolveActiveSessionMarkerKey, + togglePinnedSessionMarkerKey, +} from './session-events'; + +test('buildSessionChartEvents produces typed hover markers with parsed payload metadata', () => { + const chartEvents = buildSessionChartEvents([ + { eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null }, + { + eventType: EventType.SEEK_FORWARD, + tsMs: 3_000, + payload: '{"fromMs":1000,"toMs":5500}', + }, + { eventType: EventType.PAUSE_END, tsMs: 5_000, payload: null }, + { + eventType: EventType.CARD_MINED, + tsMs: 6_000, + payload: '{"cardsMined":2,"noteIds":[11,22]}', + }, + { eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null }, + ]); + + assert.deepEqual( + chartEvents.markers.map((marker) => marker.kind), + ['seek', '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]!; + 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]!; + assert.equal(cardMarker.kind, 'card'); + assert.deepEqual(cardMarker.noteIds, [11, 22]); + assert.equal(cardMarker.cardsDelta, 2); + + assert.deepEqual( + chartEvents.yomitanLookupEvents.map((event) => event.tsMs), + [7_000], + ); +}); + +test('projectSessionMarkerLeftPx respects chart plot offsets instead of full-width percentages', () => { + assert.equal( + projectSessionMarkerLeftPx({ + anchorTsMs: 1_000, + tsMin: 1_000, + tsMax: 11_000, + plotLeftPx: 5, + plotWidthPx: 958, + }), + 5, + ); + + assert.equal( + projectSessionMarkerLeftPx({ + anchorTsMs: 6_000, + tsMin: 1_000, + tsMax: 11_000, + plotLeftPx: 5, + plotWidthPx: 958, + }), + 484, + ); + + assert.equal( + projectSessionMarkerLeftPx({ + anchorTsMs: 11_000, + tsMin: 1_000, + tsMax: 11_000, + plotLeftPx: 5, + plotWidthPx: 958, + }), + 963, + ); +}); + +test('extractSessionEventNoteInfo prefers expression-like fields and strips html', () => { + const info = extractSessionEventNoteInfo({ + noteId: 91, + fields: { + Sentence: { value: '
この呪いの剣は危険だ
' }, + Vocabulary: { value: '呪いの剣' }, + Meaning: { value: '
cursed sword
' }, + }, + }); + + assert.deepEqual(info, { + noteId: 91, + expression: '呪いの剣', + context: 'この呪いの剣は危険だ', + meaning: 'cursed sword', + }); +}); + +test('extractSessionEventNoteInfo ignores malformed notes without a numeric note id', () => { + assert.equal( + extractSessionEventNoteInfo({ + noteId: Number.NaN, + fields: { + Vocabulary: { value: '呪い' }, + }, + }), + null, + ); +}); + +test('session marker pin helpers prefer pinned markers and toggle on repeat clicks', () => { + assert.equal(resolveActiveSessionMarkerKey('card-1', 'seek-2'), 'seek-2'); + assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1'); + assert.equal(togglePinnedSessionMarkerKey(null, 'card-1'), 'card-1'); + assert.equal(togglePinnedSessionMarkerKey('card-1', 'card-1'), null); + assert.equal(togglePinnedSessionMarkerKey('card-1', 'seek-2'), 'seek-2'); +}); diff --git a/stats/src/lib/session-events.ts b/stats/src/lib/session-events.ts new file mode 100644 index 0000000..19239bc --- /dev/null +++ b/stats/src/lib/session-events.ts @@ -0,0 +1,304 @@ +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, +] as const; + +export interface PauseRegion { + startMs: number; + endMs: number; +} + +export interface SessionChartEvents { + cardEvents: SessionEvent[]; + seekEvents: SessionEvent[]; + yomitanLookupEvents: SessionEvent[]; + pauseRegions: PauseRegion[]; + markers: SessionChartMarker[]; +} + +export interface SessionEventNoteInfo { + noteId: number; + expression: string; + context: string | null; + meaning: string | null; +} + +export interface SessionChartPlotArea { + left: number; + width: number; +} + +interface SessionEventNoteField { + value: string; +} + +interface SessionEventNoteRecord { + noteId: unknown; + fields?: Record | null; +} + +export type SessionChartMarker = + | { + key: string; + kind: 'pause'; + anchorTsMs: number; + eventTsMs: number; + startMs: number; + 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'; + anchorTsMs: number; + eventTsMs: number; + noteIds: number[]; + cardsDelta: number; + }; + +function parsePayload(payload: string | null): Record | null { + if (!payload) return null; + try { + const parsed = JSON.parse(payload); + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } +} + +function readNumberField(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function readNoteIds(value: unknown): number[] { + if (!Array.isArray(value)) return []; + return value.filter( + (entry): entry is number => typeof entry === 'number' && Number.isInteger(entry), + ); +} + +function stripHtml(value: string): string { + return value + .replace(/\[sound:[^\]]+\]/gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function pickFieldValue( + fields: Record, + patterns: RegExp[], + excludeValues: Set = new Set(), +): string | null { + const entries = Object.entries(fields); + + for (const pattern of patterns) { + for (const [fieldName, field] of entries) { + if (!pattern.test(fieldName)) continue; + const cleaned = stripHtml(field?.value ?? ''); + if (cleaned && !excludeValues.has(cleaned)) return cleaned; + } + } + + return null; +} + +function pickExpressionField(fields: Record): string { + const entries = Object.entries(fields); + const preferredPatterns = [ + /^(expression|word|vocab|vocabulary|target|target word|front)$/i, + /(expression|word|vocab|vocabulary|target)/i, + ]; + + const preferredValue = pickFieldValue(fields, preferredPatterns); + if (preferredValue) return preferredValue; + + for (const [, field] of entries) { + const cleaned = stripHtml(field?.value ?? ''); + if (cleaned) return cleaned; + } + + return ''; +} + +export function extractSessionEventNoteInfo( + note: SessionEventNoteRecord, +): SessionEventNoteInfo | null { + if (typeof note.noteId !== 'number' || !Number.isInteger(note.noteId) || note.noteId <= 0) { + return null; + } + + const fields = note.fields ?? {}; + const expression = pickExpressionField(fields); + const usedValues = new Set(expression ? [expression] : []); + const context = + pickFieldValue( + fields, + [/^(sentence|context|example)$/i, /(sentence|context|example)/i], + usedValues, + ) ?? null; + if (context) { + usedValues.add(context); + } + const meaning = + pickFieldValue( + fields, + [ + /^(meaning|definition|gloss|translation|back)$/i, + /(meaning|definition|gloss|translation|back)/i, + ], + usedValues, + ) ?? null; + + return { + noteId: note.noteId, + expression, + context, + meaning, + }; +} + +export function resolveActiveSessionMarkerKey( + hoveredMarkerKey: string | null, + pinnedMarkerKey: string | null, +): string | null { + return pinnedMarkerKey ?? hoveredMarkerKey; +} + +export function togglePinnedSessionMarkerKey( + currentPinnedMarkerKey: string | null, + nextMarkerKey: string, +): string | null { + return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey; +} + +export function formatEventSeconds(ms: number | null): string | null { + if (ms == null || !Number.isFinite(ms)) return null; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function projectSessionMarkerLeftPx({ + anchorTsMs, + tsMin, + tsMax, + plotLeftPx, + plotWidthPx, +}: { + anchorTsMs: number; + tsMin: number; + tsMax: number; + plotLeftPx: number; + plotWidthPx: number; +}): number { + if (plotWidthPx <= 0) return plotLeftPx; + if (tsMax <= tsMin) return Math.round(plotLeftPx + plotWidthPx / 2); + const ratio = Math.max(0, Math.min(1, (anchorTsMs - tsMin) / (tsMax - tsMin))); + return Math.round(plotLeftPx + plotWidthPx * ratio); +} + +export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents { + const cardEvents: SessionEvent[] = []; + const seekEvents: SessionEvent[] = []; + const yomitanLookupEvents: SessionEvent[] = []; + const pauseRegions: PauseRegion[] = []; + const markers: SessionChartMarker[] = []; + let pendingPauseStartMs: number | null = null; + + for (const event of events) { + switch (event.eventType) { + case EventType.CARD_MINED: + cardEvents.push(event); + { + const payload = parsePayload(event.payload); + markers.push({ + key: `card-${event.tsMs}`, + kind: 'card', + anchorTsMs: event.tsMs, + eventTsMs: event.tsMs, + noteIds: readNoteIds(payload?.noteIds), + cardsDelta: readNumberField(payload?.cardsMined) ?? 1, + }); + } + 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; + case EventType.PAUSE_START: + pendingPauseStartMs = event.tsMs; + break; + case EventType.PAUSE_END: + if (pendingPauseStartMs !== null) { + pauseRegions.push({ startMs: pendingPauseStartMs, endMs: event.tsMs }); + markers.push({ + key: `pause-${pendingPauseStartMs}-${event.tsMs}`, + kind: 'pause', + anchorTsMs: pendingPauseStartMs + Math.round((event.tsMs - pendingPauseStartMs) / 2), + eventTsMs: pendingPauseStartMs, + startMs: pendingPauseStartMs, + endMs: event.tsMs, + durationMs: Math.max(0, event.tsMs - pendingPauseStartMs), + }); + pendingPauseStartMs = null; + } + break; + default: + break; + } + } + + if (pendingPauseStartMs !== null) { + pauseRegions.push({ startMs: pendingPauseStartMs, endMs: pendingPauseStartMs + 2_000 }); + markers.push({ + key: `pause-${pendingPauseStartMs}-${pendingPauseStartMs + 2_000}`, + kind: 'pause', + anchorTsMs: pendingPauseStartMs + 1_000, + eventTsMs: pendingPauseStartMs, + startMs: pendingPauseStartMs, + endMs: pendingPauseStartMs + 2_000, + durationMs: 2_000, + }); + } + + markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs); + + return { + cardEvents, + seekEvents, + yomitanLookupEvents, + pauseRegions, + markers, + }; +}