Pre-conf work 2025

This commit is contained in:
2025-09-02 08:25:41 -04:00
parent 550021a18e
commit 4acbec6288
75 changed files with 8047 additions and 5228 deletions

View File

@@ -0,0 +1,61 @@
"use client";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import type { ExperimentStep } from "~/lib/experiment-designer/types";
interface DesignerPageClientProps {
experiment: {
id: string;
name: string;
description: string | null;
study: {
id: string;
name: string;
};
};
initialDesign?: {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
};
}
export function DesignerPageClient({
experiment,
initialDesign,
}: DesignerPageClientProps) {
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment.study.name,
href: `/studies/${experiment.study.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment.study.id}/experiments`,
},
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{
label: "Designer",
},
]);
return (
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />
);
}

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import type {
ExperimentStep,
ExperimentAction,
@@ -8,6 +7,7 @@ import type {
ExecutionDescriptor,
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server";
import { DesignerPageClient } from "./DesignerPageClient";
interface ExperimentDesignerPageProps {
params: Promise<{
@@ -239,8 +239,8 @@ export default async function ExperimentDesignerPage({
}
return (
<DesignerRoot
experimentId={experiment.id}
<DesignerPageClient
experiment={experiment}
initialDesign={initialDesign}
/>
);

View File

@@ -52,7 +52,7 @@ export default async function DashboardLayout({
<BreadcrumbDisplay />
</div>
</header>
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-x-hidden overflow-y-auto p-4 pt-0">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden p-4 pt-0">
{children}
</div>
</SidebarInset>

View File

@@ -95,6 +95,26 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
{ enabled: !!resolvedParams?.id },
);
const { data: experimentsData } = api.experiments.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: participantsData } = api.participants.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: trialsData } = api.trials.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: activityData } = api.studies.getActivity.useQuery(
{ studyId: resolvedParams?.id ?? "", limit: 5 },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (studyData) {
setStudy(studyData);
@@ -124,12 +144,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
const statusInfo = statusConfig[study.status as keyof typeof statusConfig];
// TODO: Get actual stats from API
const mockStats = {
experiments: 0,
totalTrials: 0,
participants: 0,
completionRate: "—",
const experiments = experimentsData ?? [];
const participants = participantsData?.participants ?? [];
const trials = trialsData ?? [];
const activities = activityData?.activities ?? [];
const completedTrials = trials.filter((trial: { status: string }) => trial.status === "completed").length;
const totalTrials = trials.length;
const stats = {
experiments: experiments.length,
totalTrials: totalTrials,
participants: participants.length,
completionRate: totalTrials > 0 ? `${Math.round((completedTrials / totalTrials) * 100)}%` : "—",
};
return (
@@ -207,27 +234,128 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Button>
}
>
<EmptyState
icon="FlaskConical"
title="No Experiments Yet"
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
Create First Experiment
</Link>
</Button>
}
/>
{experiments.length === 0 ? (
<EmptyState
icon="FlaskConical"
title="No Experiments Yet"
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
Create First Experiment
</Link>
</Button>
}
/>
) : (
<div className="space-y-4">
{experiments.map((experiment) => (
<div
key={experiment.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex-1">
<div className="flex items-center space-x-3">
<h4 className="font-medium">
<Link
href={`/experiments/${experiment.id}`}
className="hover:underline"
>
{experiment.name}
</Link>
</h4>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
experiment.status === "draft"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
>
{experiment.status}
</span>
</div>
{experiment.description && (
<p className="mt-1 text-sm text-muted-foreground">
{experiment.description}
</p>
)}
<div className="mt-2 flex items-center space-x-4 text-xs text-muted-foreground">
<span>
Created {formatDistanceToNow(experiment.createdAt, { addSuffix: true })}
</span>
{experiment.estimatedDuration && (
<span>
Est. {experiment.estimatedDuration} min
</span>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
Design
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}`}>
View
</Link>
</Button>
</div>
</div>
))}
</div>
)}
</EntityViewSection>
{/* Recent Activity */}
<EntityViewSection title="Recent Activity" icon="BarChart3">
<EmptyState
icon="Calendar"
title="No Recent Activity"
description="Activity will appear here once you start working on this study"
/>
{activities.length === 0 ? (
<EmptyState
icon="Calendar"
title="No Recent Activity"
description="Activity will appear here once you start working on this study"
/>
) : (
<div className="space-y-3">
{activities.map((activity) => (
<div
key={activity.id}
className="flex items-start space-x-3 rounded-lg border p-3"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
<span className="text-sm font-medium text-blue-600">
{activity.user?.name?.charAt(0) ?? activity.user?.email?.charAt(0) ?? "?"}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">
{activity.user?.name ?? activity.user?.email ?? "Unknown User"}
</p>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(activity.createdAt, { addSuffix: true })}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{activity.description}
</p>
</div>
</div>
))}
{activityData && activityData.pagination.total > 5 && (
<div className="pt-2">
<Button asChild variant="outline" size="sm" className="w-full">
<Link href={`/studies/${study.id}/activity`}>
View All Activity ({activityData.pagination.total})
</Link>
</Button>
</div>
)}
</div>
)}
</EntityViewSection>
</div>
@@ -280,19 +408,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
stats={[
{
label: "Experiments",
value: mockStats.experiments,
value: stats.experiments,
},
{
label: "Total Trials",
value: mockStats.totalTrials,
value: stats.totalTrials,
},
{
label: "Participants",
value: mockStats.participants,
value: stats.participants,
},
{
label: "Completion Rate",
value: mockStats.completionRate,
value: stats.completionRate,
color: "success",
},
]}

View File

@@ -5,7 +5,6 @@ import {
BarChart3,
Bot,
Camera,
CheckCircle,
Clock,
Download,
FileText,
@@ -21,6 +20,11 @@ import { notFound, redirect } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
@@ -44,7 +48,7 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
let trial;
try {
trial = await api.trials.get({ id: trialId });
} catch (_error) {
} catch {
notFound();
}
@@ -65,7 +69,12 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
: 0;
// Mock experiment steps - in real implementation, fetch from experiment API
const experimentSteps: any[] = [];
const experimentSteps: Array<{
id: string;
name: string;
description?: string;
order: number;
}> = [];
// Mock analysis data - in real implementation, this would come from API
const analysisData = {
@@ -82,33 +91,18 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
};
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="border-b border-slate-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
<Separator orientation="vertical" className="h-6" />
<div>
<h1 className="text-2xl font-bold text-slate-900">
Trial Analysis
</h1>
<p className="mt-1 text-sm text-slate-600">
{trial.experiment.name} Participant:{" "}
{trial.participant.participantCode}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge className="bg-green-100 text-green-800" variant="secondary">
<CheckCircle className="mr-1 h-3 w-3" />
Completed
</Badge>
<EntityView>
<EntityViewHeader
title="Trial Analysis"
subtitle={`${trial.experiment.name} • Participant: ${trial.participant.participantCode}`}
icon="BarChart3"
status={{
label: "Completed",
variant: "default",
icon: "CheckCircle",
}}
actions={
<>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export Data
@@ -117,417 +111,414 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
<Share className="mr-2 h-4 w-4" />
Share Results
</Button>
</div>
</div>
</div>
<Button asChild variant="ghost">
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
</>
}
/>
<div className="space-y-6 p-6">
{/* Trial Summary Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="space-y-8">
{/* Trial Summary Stats */}
<EntityViewSection title="Trial Summary" icon="Target">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<Timer className="h-4 w-4 text-blue-600" />
<div>
<p className="text-sm font-medium text-slate-600">Duration</p>
<p className="text-muted-foreground text-xs">Duration</p>
<p className="text-lg font-semibold">{duration} min</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
</div>
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<Target className="h-4 w-4 text-green-600" />
<div>
<p className="text-sm font-medium text-slate-600">
<p className="text-muted-foreground text-xs">
Completion Rate
</p>
<p className="text-lg font-semibold">
<p className="text-lg font-semibold text-green-600">
{analysisData.completionRate}%
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
</div>
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<Activity className="h-4 w-4 text-purple-600" />
<div>
<p className="text-sm font-medium text-slate-600">
Total Events
</p>
<p className="text-muted-foreground text-xs">Total Events</p>
<p className="text-lg font-semibold">
{analysisData.totalEvents}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
</div>
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<TrendingUp className="h-4 w-4 text-orange-600" />
<div>
<p className="text-sm font-medium text-slate-600">
Success Rate
</p>
<p className="text-lg font-semibold">
<p className="text-muted-foreground text-xs">Success Rate</p>
<p className="text-lg font-semibold text-green-600">
{analysisData.successRate}%
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</EntityViewSection>
{/* Main Analysis Content */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="interactions">Interactions</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<EntityViewSection title="Detailed Analysis" icon="Activity">
<Tabs defaultValue="overview" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="interactions">Interactions</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Performance Metrics */}
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Performance Metrics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="h-5 w-5" />
<span>Performance Metrics</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Task Completion</span>
<span>{analysisData.completionRate}%</span>
</div>
<Progress
value={analysisData.completionRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Success Rate</span>
<span>{analysisData.successRate}%</span>
</div>
<Progress
value={analysisData.successRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Response Time (avg)</span>
<span>{analysisData.averageResponseTime}s</span>
</div>
<Progress value={75} className="h-2" />
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-semibold text-green-600">
{experimentSteps.length}
</div>
<div className="text-xs text-slate-600">
Steps Completed
</div>
</div>
<div>
<div className="text-lg font-semibold text-red-600">
{analysisData.errorCount}
</div>
<div className="text-xs text-slate-600">Errors</div>
</div>
</div>
</CardContent>
</Card>
{/* Event Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="h-5 w-5" />
<span>Event Breakdown</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-green-600" />
<span className="text-sm">Robot Actions</span>
</div>
<Badge variant="outline">
{analysisData.robotActions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-blue-600" />
<span className="text-sm">Wizard Interventions</span>
</div>
<Badge variant="outline">
{analysisData.wizardInterventions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<MessageSquare className="h-4 w-4 text-purple-600" />
<span className="text-sm">Participant Responses</span>
</div>
<Badge variant="outline">
{analysisData.participantResponses}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Camera className="h-4 w-4 text-indigo-600" />
<span className="text-sm">Media Captures</span>
</div>
<Badge variant="outline">
{analysisData.mediaCaptures}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-orange-600" />
<span className="text-sm">Annotations</span>
</div>
<Badge variant="outline">
{analysisData.annotations}
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Trial Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="h-5 w-5" />
<span>Performance Metrics</span>
<FileText className="h-5 w-5" />
<span>Trial Information</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Task Completion</span>
<span>{analysisData.completionRate}%</span>
</div>
<Progress
value={analysisData.completionRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Success Rate</span>
<span>{analysisData.successRate}%</span>
</div>
<Progress
value={analysisData.successRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Response Time (avg)</span>
<span>{analysisData.averageResponseTime}s</span>
</div>
<Progress value={75} className="h-2" />
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-semibold text-green-600">
{experimentSteps.length}
</div>
<div className="text-xs text-slate-600">
Steps Completed
</div>
<label className="text-sm font-medium text-slate-600">
Started
</label>
<p className="text-sm">
{trial.startedAt
? format(trial.startedAt, "PPP 'at' p")
: "N/A"}
</p>
</div>
<div>
<div className="text-lg font-semibold text-red-600">
{analysisData.errorCount}
</div>
<div className="text-xs text-slate-600">Errors</div>
<label className="text-sm font-medium text-slate-600">
Completed
</label>
<p className="text-sm">
{trial.completedAt
? format(trial.completedAt, "PPP 'at' p")
: "N/A"}
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Participant
</label>
<p className="text-sm">
{trial.participant.participantCode}
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Wizard
</label>
<p className="text-sm">N/A</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Event Breakdown */}
<TabsContent value="timeline" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="h-5 w-5" />
<span>Event Breakdown</span>
<Clock className="h-5 w-5" />
<span>Event Timeline</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-green-600" />
<span className="text-sm">Robot Actions</span>
</div>
<Badge variant="outline">
{analysisData.robotActions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-blue-600" />
<span className="text-sm">Wizard Interventions</span>
</div>
<Badge variant="outline">
{analysisData.wizardInterventions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<MessageSquare className="h-4 w-4 text-purple-600" />
<span className="text-sm">Participant Responses</span>
</div>
<Badge variant="outline">
{analysisData.participantResponses}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Camera className="h-4 w-4 text-indigo-600" />
<span className="text-sm">Media Captures</span>
</div>
<Badge variant="outline">
{analysisData.mediaCaptures}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-orange-600" />
<span className="text-sm">Annotations</span>
</div>
<Badge variant="outline">
{analysisData.annotations}
</Badge>
</div>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Clock className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Timeline Analysis
</h3>
<p className="text-sm">
Detailed timeline visualization and event analysis will be
available here. This would show the sequence of all trial
events with timestamps.
</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Trial Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<FileText className="h-5 w-5" />
<span>Trial Information</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="text-sm font-medium text-slate-600">
Started
</label>
<TabsContent value="interactions" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" />
<span>Interaction Analysis</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<MessageSquare className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Interaction Patterns
</h3>
<p className="text-sm">
{trial.startedAt
? format(trial.startedAt, "PPP 'at' p")
: "N/A"}
Analysis of participant-robot interactions, communication
patterns, and behavioral observations will be displayed
here.
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Completed
</label>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="media" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Camera className="h-5 w-5" />
<span>Media Recordings</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Camera className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">Media Gallery</h3>
<p className="text-sm">
{trial.completedAt
? format(trial.completedAt, "PPP 'at' p")
: "N/A"}
Video recordings, audio captures, and sensor data
visualizations from the trial will be available for review
here.
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Participant
</label>
<p className="text-sm">
{trial.participant.participantCode}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="export" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Download className="h-5 w-5" />
<span>Export Data</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-600">
Export trial data in various formats for further analysis or
reporting.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<FileText className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Trial Report (PDF)</div>
<div className="mt-1 text-xs text-slate-500">
Complete analysis report with visualizations
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<BarChart3 className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Raw Data (CSV)</div>
<div className="mt-1 text-xs text-slate-500">
Event data, timestamps, and measurements
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<Camera className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Media Archive (ZIP)</div>
<div className="mt-1 text-xs text-slate-500">
All video, audio, and sensor recordings
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<MessageSquare className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Annotations (JSON)</div>
<div className="mt-1 text-xs text-slate-500">
Researcher notes and coded observations
</div>
</div>
</div>
</Button>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Wizard
</label>
<p className="text-sm">N/A</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="timeline" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Clock className="h-5 w-5" />
<span>Event Timeline</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Clock className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Timeline Analysis
</h3>
<p className="text-sm">
Detailed timeline visualization and event analysis will be
available here. This would show the sequence of all trial
events with timestamps.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="interactions" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" />
<span>Interaction Analysis</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<MessageSquare className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Interaction Patterns
</h3>
<p className="text-sm">
Analysis of participant-robot interactions, communication
patterns, and behavioral observations will be displayed
here.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="media" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Camera className="h-5 w-5" />
<span>Media Recordings</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Camera className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">Media Gallery</h3>
<p className="text-sm">
Video recordings, audio captures, and sensor data
visualizations from the trial will be available for review
here.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="export" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Download className="h-5 w-5" />
<span>Export Data</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-600">
Export trial data in various formats for further analysis or
reporting.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<FileText className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Trial Report (PDF)</div>
<div className="mt-1 text-xs text-slate-500">
Complete analysis report with visualizations
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<BarChart3 className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Raw Data (CSV)</div>
<div className="mt-1 text-xs text-slate-500">
Event data, timestamps, and measurements
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<Camera className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Media Archive (ZIP)</div>
<div className="mt-1 text-xs text-slate-500">
All video, audio, and sensor recordings
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<MessageSquare className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Annotations (JSON)</div>
<div className="mt-1 text-xs text-slate-500">
Researcher notes and coded observations
</div>
</div>
</div>
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</EntityViewSection>
</div>
</div>
</EntityView>
);
}
// Generate metadata for the page
export async function generateMetadata({ params }: AnalysisPageProps) {
export async function generateMetadata({
params,
}: AnalysisPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });

View File

@@ -1,16 +1,9 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle,
Calendar,
CheckCircle,
Eye,
Info,
Play,
Zap,
} from "lucide-react";
import { AlertCircle, Eye, Info, Play, Zap } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
@@ -101,6 +94,8 @@ export default function TrialDetailPage({
searchParams,
}: TrialDetailPageProps) {
const { data: session } = useSession();
const router = useRouter();
const startTrialMutation = api.trials.start.useMutation();
const [trial, setTrial] = useState<Trial | null>(null);
const [events, setEvents] = useState<TrialEvent[]>([]);
const [loading, setLoading] = useState(true);
@@ -192,6 +187,12 @@ export default function TrialDetailPage({
const canControl =
userRoles.includes("wizard") || userRoles.includes("researcher");
const handleStartTrial = async () => {
if (!trial) return;
await startTrialMutation.mutateAsync({ id: trial.id });
router.push(`/trials/${trial.id}/wizard`);
};
const displayName = `Trial #${trial.id.slice(-6)}`;
const experimentName = trial.experiment?.name ?? "Unknown Experiment";
@@ -219,12 +220,21 @@ export default function TrialDetailPage({
actions={
<>
{canControl && trial.status === "scheduled" && (
<Button asChild>
<Link href={`/trials/${trial.id}/wizard`}>
<>
<Button
onClick={handleStartTrial}
disabled={startTrialMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
{startTrialMutation.isPending ? "Starting..." : "Start"}
</Button>
<Button asChild variant="outline">
<Link href={`/trials/${trial.id}/start`}>
<Zap className="mr-2 h-4 w-4" />
Preflight
</Link>
</Button>
</>
)}
{canControl && trial.status === "in_progress" && (
<Button asChild variant="secondary">
@@ -238,7 +248,7 @@ export default function TrialDetailPage({
<Button asChild variant="outline">
<Link href={`/trials/${trial.id}/analysis`}>
<Info className="mr-2 h-4 w-4" />
View Analysis
Analysis
</Link>
</Button>
)}

View File

@@ -0,0 +1,243 @@
import { formatDistanceToNow } from "date-fns";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Clock,
FlaskConical,
Play,
TestTube,
User,
} from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { auth } from "~/server/auth";
import { api } from "~/trpc/server";
interface StartPageProps {
params: Promise<{
trialId: string;
}>;
}
export default async function StartTrialPage({ params }: StartPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/signin");
}
const role = session.user.roles?.[0]?.role ?? "observer";
if (!["wizard", "researcher", "administrator"].includes(role)) {
redirect("/trials?error=insufficient_permissions");
}
const { trialId } = await params;
let trial: Awaited<ReturnType<typeof api.trials.get>>;
try {
trial = await api.trials.get({ id: trialId });
} catch {
notFound();
}
// Guard: Only allow start from scheduled; if in progress, go to wizard; if completed, go to analysis
if (trial.status === "in_progress") {
redirect(`/trials/${trialId}/wizard`);
}
if (trial.status === "completed") {
redirect(`/trials/${trialId}/analysis`);
}
if (!["scheduled"].includes(trial.status)) {
redirect(`/trials/${trialId}?error=trial_not_startable`);
}
// Server Action: start trial and redirect to wizard
async function startTrial() {
"use server";
// Confirm auth on action too
const s = await auth();
if (!s) redirect("/auth/signin");
const r = s.user.roles?.[0]?.role ?? "observer";
if (!["wizard", "researcher", "administrator"].includes(r)) {
redirect(`/trials/${trialId}?error=insufficient_permissions`);
}
await api.trials.start({ id: trialId });
redirect(`/trials/${trialId}/wizard`);
}
const scheduled =
trial.scheduledAt instanceof Date
? trial.scheduledAt
: trial.scheduledAt
? new Date(trial.scheduledAt)
: null;
const hasWizardAssigned = Boolean(trial.wizardId);
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="border-b border-slate-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button asChild variant="ghost" size="sm">
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
<Separator orientation="vertical" className="h-6" />
<div>
<h1 className="text-2xl font-bold text-slate-900">Start Trial</h1>
<p className="mt-1 text-sm text-slate-600">
{trial.experiment.name} Participant:{" "}
{trial.participant.participantCode}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-50 text-blue-700">
Scheduled
</Badge>
</div>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-5xl space-y-6 p-6">
{/* Summary */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">
Experiment
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
<FlaskConical className="h-4 w-4 text-slate-600" />
<div className="text-sm font-semibold text-slate-900">
{trial.experiment.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">
Participant
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
<User className="h-4 w-4 text-slate-600" />
<div className="text-sm font-semibold text-slate-900">
{trial.participant.participantCode}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">
Scheduled
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-600" />
<div className="text-sm font-semibold text-slate-900">
{scheduled
? `${formatDistanceToNow(scheduled, { addSuffix: true })}`
: "Not set"}
</div>
</CardContent>
</Card>
</div>
{/* Preflight Checks */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TestTube className="h-4 w-4 text-slate-700" />
Preflight Checklist
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-sm">
<div className="font-medium text-slate-900">Permissions</div>
<div className="text-slate-600">
You have sufficient permissions to start this trial.
</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
{hasWizardAssigned ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 text-amber-600" />
)}
<div className="text-sm">
<div className="font-medium text-slate-900">Wizard</div>
<div className="text-slate-600">
{hasWizardAssigned
? "A wizard has been assigned to this trial."
: "No wizard assigned. You can still start, but consider assigning a wizard for clarity."}
</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-sm">
<div className="font-medium text-slate-900">Status</div>
<div className="text-slate-600">
Trial is currently scheduled and ready to start.
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<Button asChild variant="ghost">
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Link>
</Button>
<form action={startTrial}>
<Button type="submit" className="shadow-sm">
<Play className="mr-2 h-4 w-4" />
Start Trial
</Button>
</form>
</div>
</div>
</div>
);
}
export async function generateMetadata({
params,
}: StartPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });
return {
title: `Start Trial - ${trial.experiment.name} | HRIStudio`,
description: `Preflight and start trial for participant ${trial.participant.participantCode}`,
};
} catch {
return {
title: "Start Trial | HRIStudio",
description: "Preflight checklist to start an HRI trial",
};
}
}

View File

@@ -29,7 +29,7 @@ export default async function WizardPage({ params }: WizardPageProps) {
let trial;
try {
trial = await api.trials.get({ id: trialId });
} catch (_error) {
} catch {
notFound();
}
@@ -38,51 +38,29 @@ export default async function WizardPage({ params }: WizardPageProps) {
redirect(`/trials/${trialId}?error=trial_not_active`);
}
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="border-b border-slate-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">
Wizard Control Interface
</h1>
<p className="mt-1 text-sm text-slate-600">
{trial.experiment.name} Participant:{" "}
{trial.participant.participantCode}
</p>
</div>
<div className="flex items-center space-x-2">
<div
className={`flex items-center space-x-2 rounded-full px-3 py-1 text-sm font-medium ${
trial.status === "in_progress"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
>
<div
className={`h-2 w-2 rounded-full ${
trial.status === "in_progress"
? "animate-pulse bg-green-500"
: "bg-blue-500"
}`}
></div>
{trial.status === "in_progress"
? "Trial Active"
: "Ready to Start"}
</div>
</div>
</div>
</div>
const normalizedTrial = {
...trial,
metadata:
typeof trial.metadata === "object" && trial.metadata !== null
? (trial.metadata as Record<string, unknown>)
: null,
participant: {
...trial.participant,
demographics:
typeof trial.participant.demographics === "object" &&
trial.participant.demographics !== null
? (trial.participant.demographics as Record<string, unknown>)
: null,
},
};
{/* Main Wizard Interface */}
<WizardInterface trial={trial} userRole={userRole} />
</div>
);
return <WizardInterface trial={normalizedTrial} userRole={userRole} />;
}
// Generate metadata for the page
export async function generateMetadata({ params }: WizardPageProps) {
export async function generateMetadata({
params,
}: WizardPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });

View File

@@ -1,43 +1,391 @@
import { type NextRequest } from "next/server";
export const runtime = "edge";
// Store active WebSocket connections (for external WebSocket server)
// These would be used by a separate WebSocket implementation
// const connections = new Map<string, Set<WebSocket>>();
// const userConnections = new Map<
// string,
// { userId: string; trialId: string; role: string }
// >();
declare global {
var WebSocketPair: new () => { 0: WebSocket; 1: WebSocket };
export const runtime = "nodejs";
interface WebSocket {
accept(): void;
}
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const trialId = url.searchParams.get("trialId");
const token = url.searchParams.get("token");
interface ResponseInit {
webSocket?: WebSocket;
}
}
type Json = Record<string, unknown>;
interface ClientInfo {
userId: string | null;
role: "wizard" | "researcher" | "administrator" | "observer" | "unknown";
connectedAt: number;
}
interface TrialState {
trial: {
id: string;
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
startedAt: string | null;
completedAt: string | null;
};
currentStepIndex: number;
updatedAt: number;
}
declare global {
// Per-trial subscriber sets
// Using globalThis for ephemeral in-memory broadcast in the current Edge isolate
// (not shared globally across regions/instances)
var __trialRooms: Map<string, Set<WebSocket>> | undefined;
var __trialState: Map<string, TrialState> | undefined;
}
const rooms = (globalThis.__trialRooms ??= new Map<string, Set<WebSocket>>());
const states = (globalThis.__trialState ??= new Map<string, TrialState>());
function safeJSON<T>(v: T): string {
try {
return JSON.stringify(v);
} catch {
return '{"type":"error","data":{"message":"serialization_error"}}';
}
}
function send(ws: WebSocket, message: { type: string; data?: Json }) {
try {
ws.send(safeJSON(message));
} catch {
// swallow send errors
}
}
function broadcast(trialId: string, message: { type: string; data?: Json }) {
const room = rooms.get(trialId);
if (!room) return;
const payload = safeJSON(message);
for (const client of room) {
try {
client.send(payload);
} catch {
// ignore individual client send failure
}
}
}
function ensureTrialState(trialId: string): TrialState {
let state = states.get(trialId);
if (!state) {
state = {
trial: {
id: trialId,
status: "scheduled",
startedAt: null,
completedAt: null,
},
currentStepIndex: 0,
updatedAt: Date.now(),
};
states.set(trialId, state);
}
return state;
}
function updateTrialStatus(
trialId: string,
patch: Partial<TrialState["trial"]> &
Partial<Pick<TrialState, "currentStepIndex">>,
) {
const state = ensureTrialState(trialId);
if (typeof patch.currentStepIndex === "number") {
state.currentStepIndex = patch.currentStepIndex;
}
state.trial = {
...state.trial,
...(patch.status !== undefined ? { status: patch.status } : {}),
...(patch.startedAt !== undefined
? { startedAt: patch.startedAt ?? null }
: {}),
...(patch.completedAt !== undefined
? { completedAt: patch.completedAt ?? null }
: {}),
};
state.updatedAt = Date.now();
states.set(trialId, state);
return state;
}
// Very lightweight token parse (base64-encoded JSON per client hook)
// In production, replace with properly signed JWT verification.
function parseToken(token: string | null): ClientInfo {
if (!token) {
return { userId: null, role: "unknown", connectedAt: Date.now() };
}
try {
const decodedUnknown = JSON.parse(atob(token)) as unknown;
const userId =
typeof decodedUnknown === "object" &&
decodedUnknown !== null &&
"userId" in decodedUnknown &&
typeof (decodedUnknown as Record<string, unknown>).userId === "string"
? ((decodedUnknown as Record<string, unknown>).userId as string)
: null;
const connectedAt = Date.now();
const role: ClientInfo["role"] = "wizard"; // default role for live trial control context
return { userId, role, connectedAt };
} catch {
return { userId: null, role: "unknown", connectedAt: Date.now() };
}
}
export async function GET(req: Request): Promise<Response> {
const { searchParams } = new URL(req.url);
const trialId = searchParams.get("trialId");
const token = searchParams.get("token");
if (!trialId) {
return new Response("Missing trialId parameter", { status: 400 });
}
if (!token) {
return new Response("Missing authentication token", { status: 401 });
// If this isn't a WebSocket upgrade, return a small JSON descriptor
const upgrade = req.headers.get("upgrade") ?? "";
if (upgrade.toLowerCase() !== "websocket") {
return new Response(
safeJSON({
message: "WebSocket endpoint",
trialId,
info: "Open a WebSocket connection to this URL to receive live trial updates.",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
// For WebSocket upgrade, we need to handle this differently in Next.js
// This is a simplified version - in production you'd use a separate WebSocket server
// Create WebSocket pair (typed) and destructure endpoints
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
return new Response(
JSON.stringify({
message: "WebSocket endpoint available",
// Register server-side handlers
server.accept();
const clientInfo = parseToken(token);
// Join room
const room = rooms.get(trialId) ?? new Set<WebSocket>();
room.add(server);
rooms.set(trialId, room);
// Immediately acknowledge connection and provide current trial status snapshot
const state = ensureTrialState(trialId);
send(server, {
type: "connection_established",
data: {
trialId,
endpoint: `/api/websocket?trialId=${trialId}&token=${token}`,
instructions: "Use WebSocket client to connect to this endpoint",
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
userId: clientInfo.userId,
role: clientInfo.role,
connectedAt: clientInfo.connectedAt,
},
);
});
send(server, {
type: "trial_status",
data: {
trial: state.trial,
current_step_index: state.currentStepIndex,
timestamp: Date.now(),
},
});
server.addEventListener("message", (ev: MessageEvent<string>) => {
let parsed: unknown;
try {
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "{}");
} catch {
send(server, {
type: "error",
data: { message: "invalid_json" },
});
return;
}
const maybeObj =
typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
const type = typeof maybeObj.type === "string" ? maybeObj.type : "";
const data: Json =
maybeObj.data &&
typeof maybeObj.data === "object" &&
maybeObj.data !== null
? (maybeObj.data as Record<string, unknown>)
: {};
const now = Date.now();
const getString = (key: string, fallback = ""): string => {
const v = (data as Record<string, unknown>)[key];
return typeof v === "string" ? v : fallback;
};
const getNumber = (key: string): number | undefined => {
const v = (data as Record<string, unknown>)[key];
return typeof v === "number" ? v : undefined;
};
switch (type) {
case "heartbeat": {
send(server, { type: "heartbeat_response", data: { timestamp: now } });
break;
}
case "request_trial_status": {
const s = ensureTrialState(trialId);
send(server, {
type: "trial_status",
data: {
trial: s.trial,
current_step_index: s.currentStepIndex,
timestamp: now,
},
});
break;
}
case "trial_action": {
// Supports: start_trial, complete_trial, abort_trial, and generic actions
const actionType = getString("actionType", "unknown");
let updated: TrialState | null = null;
if (actionType === "start_trial") {
const stepIdx = getNumber("step_index") ?? 0;
updated = updateTrialStatus(trialId, {
status: "in_progress",
startedAt: new Date().toISOString(),
currentStepIndex: stepIdx,
});
} else if (actionType === "complete_trial") {
updated = updateTrialStatus(trialId, {
status: "completed",
completedAt: new Date().toISOString(),
});
} else if (actionType === "abort_trial") {
updated = updateTrialStatus(trialId, {
status: "aborted",
completedAt: new Date().toISOString(),
});
}
// Broadcast the action execution event
broadcast(trialId, {
type: "trial_action_executed",
data: {
action_type: actionType,
timestamp: now,
userId: clientInfo.userId,
...data,
},
});
// If trial state changed, broadcast status
if (updated) {
broadcast(trialId, {
type: "trial_status",
data: {
trial: updated.trial,
current_step_index: updated.currentStepIndex,
timestamp: now,
},
});
}
break;
}
case "wizard_intervention": {
// Log/broadcast a wizard intervention (note, correction, manual control)
broadcast(trialId, {
type: "intervention_logged",
data: {
timestamp: now,
userId: clientInfo.userId,
...data,
},
});
break;
}
case "step_transition": {
// Update step index and broadcast
const from = getNumber("from_step");
const to = getNumber("to_step");
if (typeof to !== "number" || !Number.isFinite(to)) {
send(server, {
type: "error",
data: { message: "invalid_step_transition" },
});
return;
}
const updated = updateTrialStatus(trialId, {
currentStepIndex: to,
});
broadcast(trialId, {
type: "step_changed",
data: {
timestamp: now,
userId: clientInfo.userId,
from_step:
typeof from === "number" ? from : updated.currentStepIndex,
to_step: updated.currentStepIndex,
...data,
},
});
break;
}
default: {
// Relay unknown/custom messages to participants in the same trial room
broadcast(trialId, {
type: type !== "" ? type : "message",
data: {
timestamp: now,
userId: clientInfo.userId,
...data,
},
});
break;
}
}
});
server.addEventListener("close", () => {
const room = rooms.get(trialId);
if (room) {
room.delete(server);
if (room.size === 0) {
rooms.delete(trialId);
}
}
});
server.addEventListener("error", () => {
try {
server.close();
} catch {
// ignore
}
const room = rooms.get(trialId);
if (room) {
room.delete(server);
if (room.size === 0) {
rooms.delete(trialId);
}
}
});
// Hand over the client end of the socket to the response
return new Response(null, {
status: 101,
webSocket: client,
});
}