/** * AnalyticsPanel — experience-wide rollups. * * The runtime logs every turn to ix_session_events; the backend's * /analytics endpoint aggregates those into: * * session_count – distinct viewer sessions * completion_rate – fraction that hit an 'ending' node * total_turns – sum of ix_session_turns rows * block_rate – fraction of turns the policy blocked * popular_actions – top-N action_ids by use count * * We render four stat cards on top, a popularity bar list below, * plus a refresh button with a live "last fetched" timestamp so * authors know how stale the number they're looking at is. */ import React, { useCallback, useMemo, useState } from "react"; import { BarChart3, RefreshCw } from "lucide-react"; import type { InteractiveApi } from "./api"; import type { AnalyticsSummary } from "./types"; import { EmptyState, ErrorBanner, Panel, SecondaryButton, SkeletonRow, useAsyncResource, } from "./ui"; export interface AnalyticsPanelProps { api: InteractiveApi; projectId: string; } export function AnalyticsPanel({ api, projectId }: AnalyticsPanelProps) { const [fetchedAt, setFetchedAt] = useState(null); const resource = useAsyncResource( async (signal) => { const data = await api.experienceAnalytics(projectId, signal); setFetchedAt(new Date()); return data; }, [api, projectId], ); const fetchedLabel = useMemo(() => { if (!fetchedAt) return ""; return `updated ${formatHm(fetchedAt)}`; }, [fetchedAt]); const reload = useCallback(() => resource.reload(), [resource]); return (
{fetchedLabel && {fetchedLabel}} } > Refresh
} > {resource.error ? ( ) : resource.loading && !resource.data ? ( ) : resource.data ? ( ) : null} {resource.loading && !resource.data ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : resource.data && resource.data.popular_actions.length > 0 ? ( ) : !resource.error ? ( } title="No action usage yet" description="Once viewers start taking actions in sessions, the most-used ones will rank here." /> ) : null}
); } // ──────────────────────────────────────────────────────────────── // Stat cards // ──────────────────────────────────────────────────────────────── function StatGrid({ data }: { data: AnalyticsSummary }) { const completionPct = Math.round(data.completion_rate * 100); const blockPct = Math.round(data.block_rate * 100); return (
0 ? "of started sessions" : "(no sessions yet)"} emphasis={completionPct >= 50 ? "good" : completionPct > 0 ? "neutral" : "dim"} /> 20 ? "warn" : "dim"} />
); } function StatCard({ label, value, sub, emphasis, }: { label: string; value: string; sub?: string; emphasis?: "good" | "warn" | "neutral" | "dim"; }) { const valueClass = { good: "text-emerald-300", warn: "text-amber-300", neutral: "text-[#f1f1f1]", dim: "text-[#f1f1f1]", }[emphasis || "neutral"]; return (
{label}
{value}
{sub &&
{sub}
}
); } function StatGridSkeleton() { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } // ──────────────────────────────────────────────────────────────── // Popular actions list (relative-width bars) // ──────────────────────────────────────────────────────────────── function PopularActionsList({ actions, }: { actions: AnalyticsSummary["popular_actions"]; }) { const max = actions.reduce((m, a) => Math.max(m, a.uses), 0) || 1; return (
    {actions.map((a) => { const pct = Math.max(4, Math.round((a.uses / max) * 100)); return (
  • {a.action_id} {a.uses}
  • ); })}
); } function formatHm(d: Date): string { const h = d.getHours().toString().padStart(2, "0"); const m = d.getMinutes().toString().padStart(2, "0"); return `${h}:${m}`; }