mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Refactor trial routes to be study-scoped and update navigation
This commit is contained in:
@@ -199,7 +199,9 @@ export default function ExperimentDetailPage({
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
@@ -318,7 +320,7 @@ export default function ExperimentDetailPage({
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/trials/${trial.id}`}
|
||||
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
Trial #{trial.id.slice(-6)}
|
||||
@@ -370,7 +372,9 @@ export default function ExperimentDetailPage({
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { TestTube, Plus } from "lucide-react";
|
||||
import { TrialsTable } from "~/components/trials/TrialsTable";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
@@ -39,10 +40,10 @@ export default function StudyTrialsPage() {
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<a href={`/studies/${studyId}/trials/new`}>
|
||||
<Link href={`/studies/${studyId}/trials/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { TestTube, ArrowRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
export default function TrialsRedirect() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
useEffect(() => {
|
||||
// If user has a selected study, redirect to study trials
|
||||
if (selectedStudyId) {
|
||||
router.replace(`/studies/${selectedStudyId}/trials`);
|
||||
}
|
||||
}, [selectedStudyId, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-orange-50">
|
||||
<TestTube className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Trials Moved</CardTitle>
|
||||
<CardDescription>
|
||||
Trial management is now organized by study for better workflow
|
||||
organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground space-y-2 text-center text-sm">
|
||||
<p>To manage trials:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• Select a study from your studies list</li>
|
||||
<li>• Navigate to that study's trials page</li>
|
||||
<li>• Schedule and monitor trials for that specific study</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/studies">
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
Browse Studies
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,7 +277,9 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/new?experimentId=${experiment.id}`}>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
Create trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
@@ -117,7 +117,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ import { format, formatDistanceToNow } from "date-fns";
|
||||
import { Clock, Eye, Play, Plus, Settings, Square } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
@@ -122,7 +123,10 @@ function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
|
||||
<Link href={`/trials/${trial.id}`} className="hover:underline">
|
||||
<Link
|
||||
href={`/studies/${trial.experiment?.study?.id}/trials/${trial.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{trial.experiment?.name ?? "Unknown Experiment"}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
@@ -241,7 +245,9 @@ function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/trials/${trial.id}/wizard`}>
|
||||
<Link
|
||||
href={`/studies/${trial.experiment?.study?.id}/trials/${trial.id}/wizard`}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
@@ -249,14 +255,18 @@ function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||
<Link href={`/trials/${trial.id}/analysis`}>
|
||||
<Link
|
||||
href={`/studies/${trial.experiment?.study?.id}/trials/${trial.id}`}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View Analysis
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/trials/${trial.id}`}>
|
||||
<Link
|
||||
href={`/studies/${trial.experiment?.study?.id}/trials/${trial.id}`}
|
||||
>
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Details
|
||||
</Link>
|
||||
@@ -268,8 +278,8 @@ function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
|
||||
}
|
||||
|
||||
export function TrialsGrid() {
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const { data: userSession } = api.auth.me.useQuery();
|
||||
|
||||
@@ -317,14 +327,11 @@ export function TrialsGrid() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Group trials by status for better organization
|
||||
const upcomingTrials = trials.filter((t) => t.status === "scheduled");
|
||||
const activeTrials = trials.filter((t) => t.status === "in_progress");
|
||||
const completedTrials = trials.filter((t) => t.status === "completed");
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -441,12 +448,14 @@ export function TrialsGrid() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/trials/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
{selectedStudyId && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${selectedStudyId}/trials/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Trials Section (Priority) */}
|
||||
@@ -503,9 +512,13 @@ export function TrialsGrid() {
|
||||
participants. Trials let you execute your designed experiments
|
||||
with wizard control.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/trials/new">Schedule Your First Trial</Link>
|
||||
</Button>
|
||||
{selectedStudyId && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${selectedStudyId}/trials/new`}>
|
||||
Schedule Your First Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -115,7 +115,10 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
const sessionNumber = row.getValue("sessionNumber");
|
||||
return (
|
||||
<div className="font-mono text-sm">
|
||||
<Link href={`/trials/${row.original.id}`} className="hover:underline">
|
||||
<Link
|
||||
href={`/studies/${row.original.studyId}/trials/${row.original.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
#{Number(sessionNumber)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -138,20 +141,16 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
cell: ({ row }) => {
|
||||
const experimentName = row.getValue("experimentName");
|
||||
const experimentId = row.original.experimentId;
|
||||
const studyName = row.original.studyName;
|
||||
return (
|
||||
<div className="max-w-[250px]">
|
||||
<div className="font-medium">
|
||||
<div className="truncate font-medium">
|
||||
<Link
|
||||
href={`/experiments/${experimentId}`}
|
||||
className="truncate hover:underline"
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(experimentName)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-sm">
|
||||
{studyName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -393,26 +392,40 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}`}>View details</Link>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
View details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{trial.status === "scheduled" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/start`}>Start trial</Link>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
||||
>
|
||||
Start trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/control`}>Control trial</Link>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
||||
>
|
||||
Control trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/analysis`}>View analysis</Link>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
View analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/trials/${trial.id}/edit`}>Edit trial</Link>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
Edit trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Play,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
X,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
|
||||
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer";
|
||||
import { ActionControls } from "./ActionControls";
|
||||
import { RobotStatus } from "./RobotStatus";
|
||||
import { ParticipantInfo } from "./ParticipantInfo";
|
||||
import { EventsLogSidebar } from "./EventsLogSidebar";
|
||||
import { TrialControlPanel } from "./panels/TrialControlPanel";
|
||||
import { ExecutionPanel } from "./panels/ExecutionPanel";
|
||||
import { MonitoringPanel } from "./panels/MonitoringPanel";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTrialWebSocket } from "~/hooks/useWebSocket";
|
||||
@@ -80,7 +62,6 @@ export function WizardInterface({
|
||||
trial: initialTrial,
|
||||
userRole: _userRole,
|
||||
}: WizardInterfaceProps) {
|
||||
const router = useRouter();
|
||||
const [trial, setTrial] = useState(initialTrial);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [trialStartTime, setTrialStartTime] = useState<Date | null>(
|
||||
@@ -97,29 +78,6 @@ export function WizardInterface({
|
||||
},
|
||||
);
|
||||
|
||||
// Get study data for breadcrumbs
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
{ id: trial.experiment.studyId },
|
||||
{ enabled: !!trial.experiment.studyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${studyData.id}` },
|
||||
{ label: "Trials", href: `/studies/${studyData.id}/trials` },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: `Trial ${trial.participant.participantCode}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Wizard Control" },
|
||||
]);
|
||||
|
||||
// Map database step types to component step types
|
||||
const mapStepType = (dbType: string) => {
|
||||
switch (dbType) {
|
||||
@@ -147,81 +105,57 @@ export function WizardInterface({
|
||||
} = useTrialWebSocket(trial.id);
|
||||
|
||||
// Fallback polling for trial updates when WebSocket is not available
|
||||
api.trials.get.useQuery(
|
||||
const { data: pollingData } = api.trials.get.useQuery(
|
||||
{ id: trial.id },
|
||||
{
|
||||
refetchInterval: wsConnected ? 10000 : 2000,
|
||||
refetchOnWindowFocus: true,
|
||||
enabled: !wsConnected,
|
||||
enabled: !wsConnected && !wsConnecting,
|
||||
refetchInterval: wsConnected ? false : 5000,
|
||||
},
|
||||
);
|
||||
|
||||
// Mutations for trial control
|
||||
const startTrialMutation = api.trials.start.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
status: data.status,
|
||||
startedAt: data.startedAt,
|
||||
}));
|
||||
setTrialStartTime(new Date());
|
||||
},
|
||||
});
|
||||
// Update trial data from polling
|
||||
React.useEffect(() => {
|
||||
if (pollingData && !wsConnected) {
|
||||
setTrial({
|
||||
...pollingData,
|
||||
metadata: pollingData.metadata as Record<string, unknown> | null,
|
||||
participant: {
|
||||
...pollingData.participant,
|
||||
demographics: pollingData.participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [pollingData, wsConnected]);
|
||||
|
||||
const completeTrialMutation = api.trials.complete.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
status: data.status,
|
||||
completedAt: data.completedAt,
|
||||
}));
|
||||
}
|
||||
router.push(`/trials/${trial.id}/analysis`);
|
||||
},
|
||||
});
|
||||
|
||||
const abortTrialMutation = api.trials.abort.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
status: data.status,
|
||||
completedAt: data.completedAt,
|
||||
}));
|
||||
}
|
||||
router.push(`/trials/${trial.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Process steps from API response
|
||||
const steps: StepData[] = React.useMemo(() => {
|
||||
if (!experimentSteps) return [];
|
||||
return experimentSteps.map((step) => ({
|
||||
// Transform experiment steps to component format
|
||||
const steps: StepData[] =
|
||||
experimentSteps?.map((step, index) => ({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
name: step.name ?? `Step ${index + 1}`,
|
||||
description: step.description,
|
||||
type: mapStepType(step.type),
|
||||
parameters:
|
||||
typeof step.parameters === "object" && step.parameters !== null
|
||||
? step.parameters
|
||||
: {},
|
||||
order: step.order,
|
||||
}));
|
||||
}, [experimentSteps]);
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
})) ?? [];
|
||||
|
||||
const currentStep = steps[currentStepIndex] ?? null;
|
||||
const progress =
|
||||
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
|
||||
const totalSteps = steps.length;
|
||||
const progressPercentage =
|
||||
totalSteps > 0 ? (currentStepIndex / totalSteps) * 100 : 0;
|
||||
|
||||
// Update elapsed time
|
||||
// Timer effect for elapsed time
|
||||
useEffect(() => {
|
||||
if (!trialStartTime || trial.status !== "in_progress") return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(
|
||||
Math.floor((Date.now() - trialStartTime.getTime()) / 1000),
|
||||
const now = new Date();
|
||||
const elapsed = Math.floor(
|
||||
(now.getTime() - trialStartTime.getTime()) / 1000,
|
||||
);
|
||||
setElapsedTime(elapsed);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
@@ -229,12 +163,67 @@ export function WizardInterface({
|
||||
|
||||
// Format elapsed time
|
||||
const formatElapsedTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Trial control handlers
|
||||
// Status badge configuration
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return { variant: "outline" as const, color: "blue", icon: Clock };
|
||||
case "in_progress":
|
||||
return { variant: "default" as const, color: "green", icon: Play };
|
||||
case "completed":
|
||||
return {
|
||||
variant: "secondary" as const,
|
||||
color: "gray",
|
||||
icon: CheckCircle,
|
||||
};
|
||||
case "aborted":
|
||||
return { variant: "destructive" as const, color: "orange", icon: X };
|
||||
case "failed":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
color: "red",
|
||||
icon: AlertCircle,
|
||||
};
|
||||
default:
|
||||
return { variant: "outline" as const, color: "gray", icon: Clock };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(trial.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
// Mutations for trial actions
|
||||
const startTrialMutation = api.trials.start.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setTrial({ ...trial, status: data.status, startedAt: data.startedAt });
|
||||
setTrialStartTime(new Date());
|
||||
},
|
||||
});
|
||||
|
||||
const completeTrialMutation = api.trials.complete.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setTrial({
|
||||
...trial,
|
||||
status: data.status,
|
||||
completedAt: data.completedAt,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const abortTrialMutation = api.trials.abort.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setTrial({ ...trial, status: data.status });
|
||||
},
|
||||
});
|
||||
|
||||
// Action handlers
|
||||
const handleStartTrial = async () => {
|
||||
try {
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
@@ -243,6 +232,22 @@ export function WizardInterface({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseTrial = async () => {
|
||||
// TODO: Implement pause functionality
|
||||
console.log("Pause trial");
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
transitionStep?.({
|
||||
to_step: currentStepIndex + 1,
|
||||
from_step: currentStepIndex,
|
||||
step_name: steps[currentStepIndex + 1]?.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteTrial = async () => {
|
||||
try {
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
@@ -252,396 +257,155 @@ export function WizardInterface({
|
||||
};
|
||||
|
||||
const handleAbortTrial = async () => {
|
||||
if (window.confirm("Are you sure you want to abort this trial?")) {
|
||||
try {
|
||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to abort trial:", error);
|
||||
}
|
||||
try {
|
||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to abort trial:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
if (transitionStep) {
|
||||
void transitionStep({
|
||||
to_step: currentStepIndex + 1,
|
||||
from_step: currentStepIndex,
|
||||
step_name: steps[currentStepIndex + 1]?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousStep = () => {
|
||||
if (currentStepIndex > 0) {
|
||||
setCurrentStepIndex(currentStepIndex - 1);
|
||||
if (transitionStep) {
|
||||
void transitionStep({
|
||||
to_step: currentStepIndex - 1,
|
||||
from_step: currentStepIndex,
|
||||
step_name: steps[currentStepIndex - 1]?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteWizardAction = (
|
||||
const handleExecuteAction = async (
|
||||
actionId: string,
|
||||
actionData: Record<string, unknown>,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
if (executeTrialAction) {
|
||||
void executeTrialAction(actionId, actionData);
|
||||
try {
|
||||
executeTrialAction?.(actionId, parameters ?? {});
|
||||
} catch (error) {
|
||||
console.error("Failed to execute action:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Left panel - Trial controls and step navigation
|
||||
const leftPanel = (
|
||||
<div className="h-full space-y-4 p-4">
|
||||
{/* Trial Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<span>Trial Status</span>
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Status Bar */}
|
||||
<div className="bg-background border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "in_progress"
|
||||
? "default"
|
||||
: trial.status === "completed"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Elapsed</span>
|
||||
<span>{formatElapsedTime(elapsedTime)}</span>
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatElapsedTime(elapsedTime)}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Step</span>
|
||||
<span>
|
||||
{currentStepIndex + 1} of {steps.length}
|
||||
)}
|
||||
|
||||
{steps.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Step {currentStepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={handleStartTrial}
|
||||
disabled={startTrialMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trial Controls */}
|
||||
{trial.status === "in_progress" && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Trial Controls</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button
|
||||
onClick={handleNextStep}
|
||||
disabled={currentStepIndex >= steps.length - 1}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<SkipForward className="mr-2 h-4 w-4" />
|
||||
Next Step
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCompleteTrial}
|
||||
disabled={completeTrialMutation.isPending}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Complete Trial
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAbortTrial}
|
||||
disabled={abortTrialMutation.isPending}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Abort Trial
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step List */}
|
||||
{steps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Experiment Steps</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`flex items-center gap-2 rounded-lg p-2 text-sm ${
|
||||
index === currentStepIndex
|
||||
? "bg-primary/10 border-primary/20 border"
|
||||
: index < currentStepIndex
|
||||
? "border border-green-200 bg-green-50"
|
||||
: "bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-background flex h-5 w-5 items-center justify-center rounded-full text-xs font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{step.name}</div>
|
||||
{step.description && (
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-16">
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
// Center panel - Main execution area
|
||||
const centerPanel = (
|
||||
<div className="h-full space-y-6 p-6">
|
||||
{/* Connection Status Alert */}
|
||||
{wsError && wsError.length > 0 && !wsConnecting && (
|
||||
<Alert
|
||||
variant={wsError.includes("polling mode") ? "default" : "destructive"}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{trial.experiment.name} • {trial.participant.participantCode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Connection Status */}
|
||||
{wsError && (
|
||||
<Alert className="mx-4 mt-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{wsError.includes("polling mode")
|
||||
? "Real-time connection unavailable - using polling for updates"
|
||||
: wsError}
|
||||
WebSocket connection failed. Using fallback polling. Some features
|
||||
may be limited.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{trial.status === "scheduled" ? (
|
||||
// Trial not started
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">Trial Scheduled</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
This trial is scheduled and ready to begin. Click "Start
|
||||
Trial" in the left panel to begin execution.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : trial.status === "in_progress" ? (
|
||||
// Trial in progress - Current step and controls
|
||||
<div className="space-y-6">
|
||||
{/* Current Step */}
|
||||
{currentStep && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Current Step: {currentStep.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{currentStepIndex > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousStep}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleNextStep}
|
||||
disabled={currentStepIndex >= steps.length - 1}
|
||||
>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Wizard Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Wizard Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionControls
|
||||
trialId={trial.id}
|
||||
currentStep={
|
||||
currentStep
|
||||
? {
|
||||
id: currentStep.id,
|
||||
name: currentStep.name,
|
||||
type: currentStep.type,
|
||||
description: currentStep.description ?? undefined,
|
||||
parameters: currentStep.parameters,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onActionComplete={handleCompleteWizardAction}
|
||||
isConnected={wsConnected}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
// Trial completed/aborted
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckCircle className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Trial {trial.status === "completed" ? "Completed" : "Ended"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
This trial has{" "}
|
||||
{trial.status === "completed"
|
||||
? "completed successfully"
|
||||
: "ended"}
|
||||
. You can view the results and analysis data.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href={`/trials/${trial.id}/analysis`}>View Analysis</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Right panel - Monitoring and context
|
||||
const rightPanel = (
|
||||
<div className="h-full space-y-4 p-4">
|
||||
{/* Robot Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Bot className="h-4 w-4" />
|
||||
Robot Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RobotStatus trialId={trial.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Participant Info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<User className="h-4 w-4" />
|
||||
Participant
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ParticipantInfo
|
||||
participant={trial.participant}
|
||||
trialStatus={trial.status}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Live Events */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Activity className="h-4 w-4" />
|
||||
Live Events
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EventsLogSidebar
|
||||
events={trialEvents}
|
||||
maxEvents={15}
|
||||
showTimestamps={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
Connection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Status</span>
|
||||
<Badge variant={wsConnected ? "default" : "secondary"}>
|
||||
{wsConnected ? "Connected" : "Polling"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>Trial ID: {trial.id.slice(-8)}</div>
|
||||
<div>Experiment: {trial.experiment.name}</div>
|
||||
<div>Participant: {trial.participant.participantCode}</div>
|
||||
{trialStartTime && (
|
||||
<div>Started: {trialStartTime.toLocaleTimeString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
title="Wizard Control"
|
||||
description={`${trial.experiment.name} • ${trial.participant.participantCode}`}
|
||||
icon={Activity}
|
||||
/>
|
||||
|
||||
{/* Main Panel Layout */}
|
||||
<PanelsContainer
|
||||
left={leftPanel}
|
||||
center={centerPanel}
|
||||
right={rightPanel}
|
||||
showDividers={true}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
{/* Main Content - Three Panel Layout */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<PanelsContainer
|
||||
left={
|
||||
<TrialControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
onStartTrial={handleStartTrial}
|
||||
onPauseTrial={handlePauseTrial}
|
||||
onNextStep={handleNextStep}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
isConnected={wsConnected}
|
||||
/>
|
||||
}
|
||||
center={
|
||||
<ExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
trialEvents={trialEvents.map((event) => ({
|
||||
type: event.type ?? "unknown",
|
||||
timestamp:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"timestamp" in event.data &&
|
||||
typeof event.data.timestamp === "number"
|
||||
? new Date(event.data.timestamp)
|
||||
: new Date(),
|
||||
data: "data" in event ? event.data : undefined,
|
||||
message:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"message" in event.data &&
|
||||
typeof event.data.message === "string"
|
||||
? event.data.message
|
||||
: undefined,
|
||||
}))}
|
||||
onStepSelect={(index) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<MonitoringPanel
|
||||
trial={trial}
|
||||
trialEvents={trialEvents.map((event) => ({
|
||||
type: event.type ?? "unknown",
|
||||
timestamp:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"timestamp" in event.data &&
|
||||
typeof event.data.timestamp === "number"
|
||||
? new Date(event.data.timestamp)
|
||||
: new Date(),
|
||||
data: "data" in event ? event.data : undefined,
|
||||
message:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"message" in event.data &&
|
||||
typeof event.data.message === "string"
|
||||
? event.data.message
|
||||
: undefined,
|
||||
}))}
|
||||
isConnected={wsConnected}
|
||||
wsError={wsError ?? undefined}
|
||||
/>
|
||||
}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardInterface;
|
||||
|
||||
@@ -61,7 +61,7 @@ export const trialExecutionItems: NavigationItem[] = [
|
||||
},
|
||||
{
|
||||
label: "Schedule Trial",
|
||||
href: "/trials/new",
|
||||
href: "/studies/{studyId}/trials/new",
|
||||
icon: Calendar,
|
||||
roles: ["administrator", "researcher"],
|
||||
requiresStudy: true,
|
||||
|
||||
Reference in New Issue
Block a user