Files
SubMiner/stats/src/lib/session-events.ts

385 lines
10 KiB
TypeScript

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<string, SessionEventNoteField> | 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<string, unknown> | null {
if (!payload) return null;
try {
const parsed = JSON.parse(payload);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : 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(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function pickFieldValue(
fields: Record<string, SessionEventNoteField>,
patterns: RegExp[],
excludeValues: Set<string> = 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, SessionEventNoteField>): 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<string>(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<number, SessionEventNoteInfo> {
const next = new Map<number, SessionEventNoteInfo>();
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<number, SessionEventNoteInfo>,
pendingNoteIds: ReadonlySet<number>,
): number[] {
const next: number[] = [];
const seen = new Set<number>();
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,
};
}