import { BaseDirectory, exists, mkdir, readTextFile, writeTextFile, } from "@tauri-apps/plugin-fs"; export type WorkflowEventType = | "recording_started" | "recording_stopped" | "editor_step_edit" | "share_link_generated" | "share_localized_variant_published" | "session_exported"; export interface WorkflowEvent { id: string; type: WorkflowEventType; sessionId: string | null; timestampMs: number; metadata?: Record; } export interface WorkflowMetrics { windowDays: number; recordingsStarted: number; sessionsShared: number; shareCompletionRate: number; medianRecordToShareSeconds: number | null; medianEditsPerSharedSession: number | null; } const STORAGE_KEY = "showrunner.workflow.events.v1"; const METRICS_DIR = "showrunner/metrics"; const METRICS_FILE = `${METRICS_DIR}/workflow-events.v1.json`; const MAX_EVENTS = 5000; const MAX_AGE_MS = 45 * 24 * 60 * 60 * 1000; const DISK_FLUSH_DEBOUNCE_MS = 300; let eventCache: WorkflowEvent[] | null = null; let hydratePromise: Promise | null = null; let flushTimer: ReturnType | null = null; let diskAvailable: boolean | null = null; function isBrowserEnvironment(): boolean { return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } function nowMs(): number { return Date.now(); } function parseStoredEvents(raw: string | null): WorkflowEvent[] { if (!raw) return []; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((item): item is WorkflowEvent => { return ( item && typeof item.id === "string" && typeof item.type === "string" && (typeof item.sessionId === "string" || item.sessionId === null) && typeof item.timestampMs === "number" ); }); } catch { return []; } } function pruneEvents(events: WorkflowEvent[]): WorkflowEvent[] { const threshold = nowMs() - MAX_AGE_MS; return events .filter((event) => event.timestampMs >= threshold) .sort((a, b) => a.timestampMs - b.timestampMs) .slice(-MAX_EVENTS); } function readLocalEvents(): WorkflowEvent[] { if (!isBrowserEnvironment()) return []; return pruneEvents(parseStoredEvents(window.localStorage.getItem(STORAGE_KEY))); } function writeLocalEvents(events: WorkflowEvent[]): void { if (!isBrowserEnvironment()) return; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(events.slice(-MAX_EVENTS))); } catch { // Best effort only. We do not block core UX on telemetry persistence failures. } } function mergeUniqueEvents(primary: WorkflowEvent[], secondary: WorkflowEvent[]): WorkflowEvent[] { const merged = new Map(); for (const event of [...primary, ...secondary]) { merged.set(event.id, event); } return pruneEvents(Array.from(merged.values())); } function ensureCacheSeeded(): void { if (eventCache !== null) return; eventCache = readLocalEvents(); } async function ensureDiskAvailable(): Promise { if (diskAvailable !== null) return diskAvailable; try { await mkdir(METRICS_DIR, { baseDir: BaseDirectory.AppData, recursive: true, }); diskAvailable = true; } catch { diskAvailable = false; } return diskAvailable; } async function readDiskEvents(): Promise { if (!(await ensureDiskAvailable())) return []; try { const hasFile = await exists(METRICS_FILE, { baseDir: BaseDirectory.AppData, }); if (!hasFile) return []; const raw = await readTextFile(METRICS_FILE, { baseDir: BaseDirectory.AppData, }); return pruneEvents(parseStoredEvents(raw)); } catch { return []; } } async function writeDiskEvents(events: WorkflowEvent[]): Promise { if (!(await ensureDiskAvailable())) return; try { await writeTextFile(METRICS_FILE, JSON.stringify(events), { baseDir: BaseDirectory.AppData, }); } catch { // Best effort only. } } async function flushEventsToDisk(): Promise { ensureCacheSeeded(); await writeDiskEvents(eventCache ?? []); } function scheduleDiskFlush(): void { if (flushTimer) return; flushTimer = setTimeout(() => { flushTimer = null; void flushEventsToDisk(); }, DISK_FLUSH_DEBOUNCE_MS); } export async function initializeWorkflowMetricsStore(): Promise { ensureCacheSeeded(); if (hydratePromise) return hydratePromise; hydratePromise = (async () => { const diskEvents = await readDiskEvents(); eventCache = mergeUniqueEvents(eventCache ?? [], diskEvents); writeLocalEvents(eventCache); await flushEventsToDisk(); })().finally(() => { hydratePromise = null; }); return hydratePromise; } function median(values: number[]): number | null { if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); if (sorted.length % 2 === 0) { return Math.round((sorted[middle - 1] + sorted[middle]) / 2); } return sorted[middle]; } export function trackWorkflowEvent( type: WorkflowEventType, details?: { sessionId?: string | null; metadata?: Record; timestampMs?: number; } ): void { ensureCacheSeeded(); const events = [...(eventCache ?? [])]; const next: WorkflowEvent = { id: `${type}-${nowMs()}-${Math.random().toString(16).slice(2, 8)}`, type, sessionId: details?.sessionId ?? null, timestampMs: details?.timestampMs ?? nowMs(), metadata: details?.metadata, }; events.push(next); eventCache = pruneEvents(events); writeLocalEvents(eventCache); scheduleDiskFlush(); } export function getWorkflowMetrics(windowDays = 30): WorkflowMetrics { ensureCacheSeeded(); const allEvents = eventCache ?? []; const thresholdMs = nowMs() - windowDays * 24 * 60 * 60 * 1000; const events = allEvents.filter((event) => event.timestampMs >= thresholdMs); const recordingsBySession = new Map(); const sharesBySession = new Map(); const editsBySession = new Map(); for (const event of events) { if (!event.sessionId) continue; if (event.type === "recording_started") { const current = recordingsBySession.get(event.sessionId); if (current == null || event.timestampMs < current) { recordingsBySession.set(event.sessionId, event.timestampMs); } continue; } if (event.type === "share_link_generated") { const current = sharesBySession.get(event.sessionId); if (current == null || event.timestampMs < current) { sharesBySession.set(event.sessionId, event.timestampMs); } continue; } if (event.type === "editor_step_edit") { editsBySession.set(event.sessionId, (editsBySession.get(event.sessionId) ?? 0) + 1); } } const recordToShareSeconds: number[] = []; for (const [sessionId, recordingStart] of recordingsBySession.entries()) { const sharedAt = sharesBySession.get(sessionId); if (sharedAt == null) continue; if (sharedAt < recordingStart) continue; recordToShareSeconds.push(Math.round((sharedAt - recordingStart) / 1000)); } const editCountsForSharedSessions: number[] = []; for (const sessionId of sharesBySession.keys()) { editCountsForSharedSessions.push(editsBySession.get(sessionId) ?? 0); } const recordingsStarted = recordingsBySession.size; const sessionsShared = sharesBySession.size; const shareCompletionRate = recordingsStarted > 0 ? Math.round((sessionsShared / recordingsStarted) * 100) : 0; return { windowDays, recordingsStarted, sessionsShared, shareCompletionRate, medianRecordToShareSeconds: median(recordToShareSeconds), medianEditsPerSharedSession: median(editCountsForSharedSessions), }; } export function clearWorkflowEvents(): void { eventCache = []; if (!isBrowserEnvironment()) return; window.localStorage.removeItem(STORAGE_KEY); void writeDiskEvents([]); }