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; preview?: { word?: unknown; sentence?: unknown; translation?: unknown; } | null; 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 previewExpression = typeof note.preview?.word === 'string' ? stripHtml(note.preview.word) : ''; const previewContext = typeof note.preview?.sentence === 'string' ? stripHtml(note.preview.sentence) : ''; const previewMeaning = typeof note.preview?.translation === 'string' ? stripHtml(note.preview.translation) : ''; if (previewExpression || previewContext || previewMeaning) { return { noteId: note.noteId, expression: previewExpression, context: previewContext || null, meaning: previewMeaning || 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 mergeSessionEventNoteInfos( requestedNoteIds: number[], notes: SessionEventNoteRecord[], ): Map { const next = new Map(); notes.forEach((note, index) => { const info = extractSessionEventNoteInfo(note); if (!info) return; next.set(info.noteId, info); const requestedNoteId = requestedNoteIds[index]; if (requestedNoteId && requestedNoteId > 0) { next.set(requestedNoteId, info); } }); return next; } export function collectPendingSessionEventNoteIds( noteIds: number[], noteInfos: ReadonlyMap, pendingNoteIds: ReadonlySet, ): number[] { const next: number[] = []; const seen = new Set(); for (const noteId of noteIds) { if (!Number.isInteger(noteId) || noteId <= 0 || seen.has(noteId)) { continue; } seen.add(noteId); if (noteInfos.has(noteId) || pendingNoteIds.has(noteId)) { continue; } next.push(noteId); } return next; } export function getSessionEventCardRequest(marker: SessionChartMarker | null): { noteIds: number[]; requestKey: string | null; } { if (!marker || marker.kind !== 'card' || marker.noteIds.length === 0) { return { noteIds: [], requestKey: null }; } const noteIds = Array.from( new Set(marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)), ); return { noteIds, requestKey: noteIds.length > 0 ? `${marker.key}:${noteIds.join(',')}` : null, }; } 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, }; }