feat: Implement trial event logging, archiving, experiment soft deletion, and new analytics/event data tables.

This commit is contained in:
2026-02-10 16:14:31 -05:00
parent 0f535f6887
commit a8c868ad3f
17 changed files with 1356 additions and 567 deletions

View File

@@ -0,0 +1,107 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
// Define the shape of our data (matching schema)
export interface TrialEvent {
id: string;
trialId: string;
eventType: string;
timestamp: Date | string;
data: any;
createdBy: string | null;
}
// Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}
export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{
id: "timestamp",
header: "Time",
accessorKey: "timestamp",
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col">
<span className="font-mono font-medium">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{date.toLocaleTimeString()}
</span>
</div>
);
},
},
{
accessorKey: "eventType",
header: "Event Type",
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention) Icon = User; // Wizard/Intervention often User
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (type.includes("note")) Icon = MessageSquare;
else if (type.includes("completed")) Icon = CheckCircle;
return (
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
isIntervention && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: "data",
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
if (!data || Object.keys(data).length === 0) return <span className="text-muted-foreground text-xs">-</span>;
// Simplistic view for now: JSON stringify but truncated?
// Or meaningful extraction based on event type.
return (
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border block max-w-[400px] truncate">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
);
},
},
];

View File

@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { DataTable } from "~/components/ui/data-table";
import { type TrialEvent, eventsColumns } from "./events-columns";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Input } from "~/components/ui/input";
interface EventsDataTableProps {
data: TrialEvent[];
startTime?: Date;
}
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>("");
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
// Enhanced filtering logic
const filteredData = React.useMemo(() => {
return data.filter(event => {
// Type filter
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
return false;
}
// Global text search (checks type and data)
if (globalFilter) {
const searchLower = globalFilter.toLowerCase();
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
// Safe JSON stringify check
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
const dataMatch = dataString.includes(searchLower);
return typeMatch || dataMatch;
}
return true;
});
}, [data, eventTypeFilter, globalFilter]);
// Custom Filters UI
const filters = (
<div className="flex items-center gap-2">
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
</SelectContent>
</Select>
</div>
);
return (
<div className="space-y-4">
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering.
Since we implemented custom external filtering for "type" dropdown and "global" search,
we pass the filtered data directly.
However, the shared DataTable component has a `searchKey` prop that drives an internal Input.
If we want to use OUR custom search input (to search JSON data), we should probably NOT use
DataTable's internal search or pass a custom filter.
The shared DataTable's `searchKey` only filters a specific column string value.
Since "data" is an object, we can't easily use the built-in single-column search.
So we'll implement our own search input and pass `filters={filters}` which creates
additional dropdowns, but we might want to REPLACE the standard search input.
Looking at `DataTable` implementation:
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`,
no input is rendered, and we can put ours in `filters`.
*/}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
{filters}
</div>
</div>
<DataTable
columns={columns}
data={filteredData}
// No searchKey, we handle it externally
isLoading={false}
/>
</div>
);
}