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

@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import * as React from "react";
import { format, formatDistanceToNow } from "date-fns";
@@ -331,33 +331,7 @@ export const columns: ColumnDef<Trial>[] = [
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
if (!date)
return <span className="text-muted-foreground text-sm">Unknown</span>;
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{
id: "actions",
enableHiding: false,
@@ -393,19 +367,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(trial.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
{trial.status === "scheduled" && (
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
@@ -431,11 +392,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{(trial.status === "scheduled" || trial.status === "failed") && (
<DropdownMenuItem className="text-red-600">
<Ban className="mr-2 h-4 w-4" />

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>
);
}

View File

@@ -2,17 +2,21 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import { PlaybackProvider } from "../playback/PlaybackContext";
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
import { EventTimeline } from "../playback/EventTimeline";
import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { EventsDataTable } from "../analysis/events-data-table";
interface TrialAnalysisViewProps {
trial: {
@@ -27,9 +31,10 @@ interface TrialAnalysisViewProps {
mediaCount?: number;
media?: { url: string; contentType: string }[];
};
backHref: string;
}
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
// Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id,
@@ -39,139 +44,153 @@ export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
const videoUrl = videoMedia?.url;
// Metrics
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
const errorCount = events.filter(e => e.eventType.includes("error")).length;
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden">
<div className="flex h-full flex-col gap-4 p-4 text-sm">
{/* Header Context */}
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14">
<div className="flex items-center justify-between pb-2 border-b">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild className="-ml-2">
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex flex-col">
<h1 className="text-base font-semibold leading-none">
<h1 className="text-lg font-semibold leading-none tracking-tight">
{trial.experiment.name}
</h1>
<p className="text-xs text-muted-foreground mt-1">
{trial.participant.participantCode} Session {trial.id.slice(0, 4)}...
</p>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
<div className="flex items-center gap-2 text-muted-foreground mt-1">
<span className="font-mono">{trial.participant.participantCode}</span>
<span></span>
<span>Session {trial.id.slice(0, 4)}</span>
</div>
{trial.duration && (
<Badge variant="secondary" className="text-[10px] font-mono">
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-muted-foreground bg-muted/30 px-3 py-1 rounded-full border">
<Clock className="h-3.5 w-3.5" />
<span className="text-xs font-mono">
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
</span>
</div>
</div>
</div>
{/* Main Resizable Workspace */}
<div className="flex-1 min-h-0">
<ResizablePanelGroup direction="horizontal">
{/* Metrics Header */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card className="bg-gradient-to-br from-blue-50 to-transparent dark:from-blue-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
<Clock className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? (
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span>
) : (
"--:--"
)}
</div>
<p className="text-xs text-muted-foreground">Total session time</p>
</CardContent>
</Card>
{/* LEFT: Video & Timeline */}
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
<ResizablePanelGroup direction="vertical">
{/* Top: Video Player */}
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative">
{videoUrl ? (
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
) : (
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
<p className="text-sm">No recording available.</p>
</div>
)}
</ResizablePanel>
<Card className="bg-gradient-to-br from-purple-50 to-transparent dark:from-purple-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
<Bot className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{robotActionCount}</div>
<p className="text-xs text-muted-foreground">Executed autonomous behaviors</p>
</CardContent>
</Card>
<ResizableHandle withHandle />
<Card className="bg-gradient-to-br from-orange-50 to-transparent dark:from-orange-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
<AlertTriangle className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{interventionCount}</div>
<p className="text-xs text-muted-foreground">Manual wizard overrides</p>
</CardContent>
</Card>
{/* Bottom: Timeline Track */}
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
<Info className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
<Card className="bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
<Activity className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn(
"inline-block h-2 w-2 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
</div>
</CardContent>
</Card>
</div>
{/* Main Workspace: Vertical Layout */}
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
<ResizablePanelGroup direction="vertical">
{/* TOP: Video & Timeline */}
<ResizablePanel defaultSize={50} minSize={30} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40">
<div className="relative flex-1 min-h-0 flex items-center justify-center">
{videoUrl ? (
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0 p-2 overflow-hidden">
<EventTimeline />
) : (
<div className="flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
<div className="bg-muted rounded-full p-4 mb-4">
<VideoOff className="h-8 w-8 opacity-50" />
</div>
<h3 className="font-semibold text-lg">No playback media available</h3>
<p className="text-sm max-w-sm mt-2">
There is no video recording associated with this trial session.
</p>
</div>
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
{/* Timeline Control */}
<div className="shrink-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4">
<EventTimeline />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle className="bg-border/50" />
{/* RIGHT: Logs & Metrics */}
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
{/* Metrics Strip */}
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{events.filter(e => e.eventType.includes("intervention")).length}
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{trial.status === 'completed' ? 'PASS' : 'INC'}
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
</div>
</CardContent>
</Card>
</div>
{/* Log Title */}
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
<span className="text-xs font-semibold flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-primary" />
Event Log
</span>
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
</div>
{/* Scrollable Event List */}
<div className="flex-1 min-h-0 relative bg-background/50">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{events.map((event, i) => (
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
{event.eventType.replace(/_/g, " ")}
</span>
</div>
{!!event.data && (
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
</div>
)}
</div>
</div>
))}
{events.length === 0 && (
<div className="p-8 text-center text-xs text-muted-foreground italic">
No events found in log.
</div>
)}
</div>
</ScrollArea>
{/* BOTTOM: Events Table */}
<ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">Event Log</h3>
</div>
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
</div>
<ScrollArea className="flex-1">
<div className="p-4">
<EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</ResizablePanel>
</ResizablePanelGroup>
</div>
@@ -187,3 +206,4 @@ function formatTime(ms: number) {
const s = Math.floor(totalSeconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}

View File

@@ -406,6 +406,32 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
const pauseTrialMutation = api.trials.pause.useMutation({
onSuccess: () => {
toast.success("Trial paused");
// Optionally update local state if needed, though status might not change on backend strictly to "paused"
// depending on enum. But we logged the event.
},
onError: (error) => {
toast.error("Failed to pause trial", { description: error.message });
},
});
const archiveTrialMutation = api.trials.archive.useMutation({
onSuccess: () => {
console.log("Trial archived successfully");
},
onError: (error) => {
console.error("Failed to archive trial", error);
},
});
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => {
// toast.success("Event logged"); // Too noisy
},
});
// Action handlers
const handleStartTrial = async () => {
console.log(
@@ -443,8 +469,11 @@ export const WizardInterface = React.memo(function WizardInterface({
};
const handlePauseTrial = async () => {
// TODO: Implement pause functionality
console.log("Pause trial");
try {
await pauseTrialMutation.mutateAsync({ id: trial.id });
} catch (error) {
console.error("Failed to pause trial:", error);
}
};
const handleNextStep = (targetIndex?: number) => {
@@ -498,6 +527,19 @@ export const WizardInterface = React.memo(function WizardInterface({
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) {
// Log step change
logEventMutation.mutate({
trialId: trial.id,
type: "step_changed",
data: {
fromIndex: currentStepIndex,
toIndex: nextIndex,
fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name,
}
});
setCurrentStepIndex(nextIndex);
} else {
handleCompleteTrial();
@@ -507,6 +549,8 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleCompleteTrial = async () => {
try {
await completeTrialMutation.mutateAsync({ id: trial.id });
// Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id });
} catch (error) {
console.error("Failed to complete trial:", error);
}
@@ -543,10 +587,7 @@ export const WizardInterface = React.memo(function WizardInterface({
});
};
// Mutation for events (Acknowledge)
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => toast.success("Event logged"),
});
// Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({