- {/* Header */}
-
-
-
Studies
-
- Manage your Human-Robot Interaction research studies
-
-
-
-
-
- Welcome, {session.user.name ?? session.user.email}
-
-
-
- Sign Out
-
-
- ← Back to Home
-
-
-
-
-
- {/* Studies Grid */}
-
- {/* Create New Study Card */}
-
-
-
- Create New Study
- Start a new HRI research study
-
-
-
- Create Study
-
-
-
-
- {/* Example Study Cards */}
-
-
- Robot Navigation Study
-
- Investigating user preferences for robot navigation patterns
-
-
-
-
- Created: Dec 2024
- Status: Active
-
-
-
- View Details
-
-
- Edit
-
-
-
-
-
-
-
- Social Robot Interaction
-
- Analyzing human responses to social robot behaviors
-
-
-
-
- Created: Nov 2024
- Status: Draft
-
-
-
- View Details
-
-
- Edit
-
-
-
-
-
-
- {/* Empty State for No Studies */}
-
-
-
- Authentication Test Successful!
-
-
- You're viewing a protected page. The authentication system is
- working correctly. This page will be replaced with actual study
- management functionality.
-
-
User ID: {session.user.id}
-
+
+ {/* Header */}
+
+
Studies
+
+ Manage your Human-Robot Interaction research studies
+
+
+ {/* Studies Grid */}
+
);
}
diff --git a/src/components/experiments/ExperimentsGrid.tsx b/src/components/experiments/ExperimentsGrid.tsx
new file mode 100644
index 0000000..602cdde
--- /dev/null
+++ b/src/components/experiments/ExperimentsGrid.tsx
@@ -0,0 +1,361 @@
+"use client";
+
+import { useState } from "react";
+import { Plus, FlaskConical, Settings, Calendar, Users } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import Link from "next/link";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Separator } from "~/components/ui/separator";
+import { api } from "~/trpc/react";
+
+type ExperimentWithRelations = {
+ id: string;
+ name: string;
+ description: string;
+ status: "draft" | "active" | "completed" | "archived";
+ estimatedDuration: number | null;
+ createdAt: Date;
+ updatedAt: Date;
+ studyId: string;
+ createdById: string;
+ study: {
+ id: string;
+ name: string;
+ };
+ createdBy: {
+ id: string;
+ name: string | null;
+ email: string;
+ };
+ _count?: {
+ steps: number;
+ trials: number;
+ };
+};
+
+const statusConfig = {
+ draft: {
+ label: "Draft",
+ className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
+ icon: "📝",
+ },
+ active: {
+ label: "Active",
+ className: "bg-green-100 text-green-800 hover:bg-green-200",
+ icon: "🟢",
+ },
+ completed: {
+ label: "Completed",
+ className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
+ icon: "✅",
+ },
+ archived: {
+ label: "Archived",
+ className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
+ icon: "📦",
+ },
+};
+
+interface ExperimentCardProps {
+ experiment: ExperimentWithRelations;
+}
+
+function ExperimentCard({ experiment }: ExperimentCardProps) {
+ const statusInfo = statusConfig[experiment.status];
+
+ return (
+
+
+
+
+
+
+ {experiment.name}
+
+
+
+ {experiment.description}
+
+
+ Study:
+
+ {experiment.study.name}
+
+
+
+
+ {statusInfo.icon}
+ {statusInfo.label}
+
+
+
+
+
+ {/* Experiment Metadata */}
+
+ {experiment.estimatedDuration && (
+
+
+ Estimated duration: {experiment.estimatedDuration} minutes
+
+ )}
+
+
+ Created by {experiment.createdBy.name ?? experiment.createdBy.email}
+
+
+
+ {/* Statistics */}
+ {experiment._count && (
+ <>
+
+
+
+ Steps:
+ {experiment._count.steps}
+
+
+ Trials:
+ {experiment._count.trials}
+
+
+ >
+ )}
+
+ {/* Metadata */}
+
+
+
+ Created:
+
+ {formatDistanceToNow(experiment.createdAt, { addSuffix: true })}
+
+
+ {experiment.updatedAt !== experiment.createdAt && (
+
+ Updated:
+
+ {formatDistanceToNow(experiment.updatedAt, { addSuffix: true })}
+
+
+ )}
+
+
+ {/* Actions */}
+
+
+ View Details
+
+
+
+
+ Design
+
+
+
+
+
+ );
+}
+
+export function ExperimentsGrid() {
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ const {
+ data: experimentsData,
+ isLoading,
+ error,
+ refetch,
+ } = api.experiments.getUserExperiments.useQuery(
+ { page: 1, limit: 50 },
+ {
+ refetchOnWindowFocus: false,
+ },
+ );
+
+ const experiments = experimentsData?.experiments ?? [];
+
+ const handleExperimentCreated = () => {
+ setRefreshKey((prev) => prev + 1);
+ void refetch();
+ };
+
+ if (isLoading) {
+ return (
+
+ {/* Create Experiment Card */}
+
+
+
+ Create New Experiment
+
+ Design a new experimental protocol
+
+
+
+
+ Create Experiment
+
+
+
+
+ {/* Loading Skeletons */}
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {/* Create Experiment Card */}
+
+
+
+ Create New Experiment
+
+ Design a new experimental protocol
+
+
+
+
+ Create Experiment
+
+
+
+
+ {/* Error State */}
+
+
+
+
+
+ Failed to Load Experiments
+
+
+ {error.message ||
+ "An error occurred while loading your experiments."}
+
+
refetch()} variant="outline">
+ Try Again
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Create New Experiment Card */}
+
+
+
+ Create New Experiment
+ Design a new experimental protocol
+
+
+
+ Create Experiment
+
+
+
+
+ {/* Experiments */}
+ {experiments.map((experiment) => (
+
+ ))}
+
+ {/* Empty State */}
+ {experiments.length === 0 && (
+
+
+
+
+
+
+
+ No Experiments Yet
+
+
+ Create your first experiment to start designing HRI protocols.
+ Experiments define the structure and flow of your research
+ trials.
+
+
+
+ Create Your First Experiment
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/experiments/designer/ExperimentDesigner.tsx b/src/components/experiments/designer/ExperimentDesigner.tsx
new file mode 100644
index 0000000..6999b18
--- /dev/null
+++ b/src/components/experiments/designer/ExperimentDesigner.tsx
@@ -0,0 +1,589 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+import {
+ DndContext,
+ DragOverlay,
+ useDraggable,
+ useDroppable,
+ DragStartEvent,
+ DragEndEvent,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import {
+ Play,
+ Bot,
+ GitBranch,
+ Shuffle,
+ Settings,
+ Plus,
+ Save,
+ Undo,
+ Redo,
+ Eye,
+ Trash2,
+} from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { Badge } from "~/components/ui/badge";
+import { Separator } from "~/components/ui/separator";
+import { ScrollArea } from "~/components/ui/scroll-area";
+
+// Step type definitions
+export type StepType = "wizard" | "robot" | "parallel" | "conditional";
+
+export interface ExperimentStep {
+ id: string;
+ type: StepType;
+ name: string;
+ description?: string;
+ order: number;
+ parameters: Record
;
+ duration?: number;
+ parentId?: string;
+ children?: string[];
+}
+
+export interface ExperimentDesign {
+ id: string;
+ name: string;
+ steps: ExperimentStep[];
+ version: number;
+ lastSaved: Date;
+}
+
+const stepTypeConfig = {
+ wizard: {
+ label: "Wizard Action",
+ description: "Manual control by wizard operator",
+ icon: Play,
+ color: "bg-blue-100 text-blue-700 border-blue-200",
+ defaultParams: {
+ instruction: "",
+ allowSkip: true,
+ timeout: null,
+ },
+ },
+ robot: {
+ label: "Robot Action",
+ description: "Automated robot behavior",
+ icon: Bot,
+ color: "bg-green-100 text-green-700 border-green-200",
+ defaultParams: {
+ action: "",
+ parameters: {},
+ waitForCompletion: true,
+ },
+ },
+ parallel: {
+ label: "Parallel Steps",
+ description: "Execute multiple steps simultaneously",
+ icon: Shuffle,
+ color: "bg-purple-100 text-purple-700 border-purple-200",
+ defaultParams: {
+ waitForAll: true,
+ maxDuration: null,
+ },
+ },
+ conditional: {
+ label: "Conditional Branch",
+ description: "Execute steps based on conditions",
+ icon: GitBranch,
+ color: "bg-orange-100 text-orange-700 border-orange-200",
+ defaultParams: {
+ condition: "",
+ trueSteps: [],
+ falseSteps: [],
+ },
+ },
+};
+
+interface StepLibraryItemProps {
+ type: StepType;
+ onDragStart?: () => void;
+}
+
+function StepLibraryItem({ type, onDragStart }: StepLibraryItemProps) {
+ const config = stepTypeConfig[type];
+ const Icon = config.icon;
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ isDragging,
+ } = useDraggable({
+ id: `library-${type}`,
+ data: { type: "library-item", stepType: type },
+ });
+
+ const style = {
+ transform: CSS.Translate.toString(transform),
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+
+ {config.label}
+
+
{config.description}
+
+ );
+}
+
+interface ExperimentStepCardProps {
+ step: ExperimentStep;
+ onEdit: (step: ExperimentStep) => void;
+ onDelete: (stepId: string) => void;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+function ExperimentStepCard({
+ step,
+ onEdit,
+ onDelete,
+ isSelected,
+ onClick
+}: ExperimentStepCardProps) {
+ const config = stepTypeConfig[step.type];
+ const Icon = config.icon;
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: step.id,
+ data: { type: "step", step },
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {step.name}
+
+ {config.label}
+
+
+
+
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+
+
+
+
+ {step.description && (
+ {step.description}
+ )}
+
+
+ Step {step.order}
+ {step.duration && • {step.duration}s }
+
+
+ {
+ e.stopPropagation();
+ onEdit(step);
+ }}
+ className="h-6 w-6 p-0"
+ >
+
+
+ {
+ e.stopPropagation();
+ onDelete(step.id);
+ }}
+ className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
+ >
+
+
+
+
+
+
+ );
+}
+
+interface ExperimentCanvasProps {
+ steps: ExperimentStep[];
+ selectedStepId?: string;
+ onStepSelect: (stepId: string) => void;
+ onStepEdit: (step: ExperimentStep) => void;
+ onStepDelete: (stepId: string) => void;
+ onStepsReorder: (steps: ExperimentStep[]) => void;
+}
+
+function ExperimentCanvas({
+ steps,
+ selectedStepId,
+ onStepSelect,
+ onStepEdit,
+ onStepDelete,
+ onStepsReorder,
+}: ExperimentCanvasProps) {
+ const { setNodeRef } = useDroppable({
+ id: "experiment-canvas",
+ });
+
+ const stepIds = steps.map((step) => step.id);
+
+ return (
+
+ {steps.length === 0 ? (
+
+
+
+
+ Start Designing Your Experiment
+
+
+ Drag step types from the library on the left to begin creating your experimental protocol.
+
+
+
+ ) : (
+
+
+ {steps
+ .sort((a, b) => a.order - b.order)
+ .map((step) => (
+ onStepSelect(step.id)}
+ />
+ ))}
+
+
+ )}
+
+ );
+}
+
+interface ExperimentDesignerProps {
+ experimentId: string;
+ initialDesign?: ExperimentDesign;
+ onSave?: (design: ExperimentDesign) => Promise;
+}
+
+export function ExperimentDesigner({
+ experimentId,
+ initialDesign,
+ onSave,
+}: ExperimentDesignerProps) {
+ const [design, setDesign] = useState(
+ initialDesign || {
+ id: experimentId,
+ name: "New Experiment",
+ steps: [],
+ version: 1,
+ lastSaved: new Date(),
+ }
+ );
+ const [selectedStepId, setSelectedStepId] = useState();
+ const [activeId, setActiveId] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ })
+ );
+
+ const createStep = useCallback((type: StepType): ExperimentStep => {
+ const config = stepTypeConfig[type];
+ const newOrder = Math.max(...design.steps.map(s => s.order), 0) + 1;
+
+ return {
+ id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ type,
+ name: `${config.label} ${newOrder}`,
+ order: newOrder,
+ parameters: { ...config.defaultParams },
+ };
+ }, [design.steps]);
+
+ const handleDragStart = (event: DragStartEvent) => {
+ setActiveId(event.active.id as string);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ setActiveId(null);
+
+ if (!over) return;
+
+ // Handle dropping library item onto canvas
+ if (active.data.current?.type === "library-item" && over.id === "experiment-canvas") {
+ const stepType = active.data.current.stepType as StepType;
+ const newStep = createStep(stepType);
+
+ setDesign(prev => ({
+ ...prev,
+ steps: [...prev.steps, newStep],
+ }));
+ setHasUnsavedChanges(true);
+ return;
+ }
+
+ // Handle reordering steps
+ if (active.data.current?.type === "step" && over.data.current?.type === "step") {
+ const activeStep = design.steps.find(s => s.id === active.id);
+ const overStep = design.steps.find(s => s.id === over.id);
+
+ if (!activeStep || !overStep) return;
+
+ const newSteps = [...design.steps];
+ const activeIndex = newSteps.findIndex(s => s.id === active.id);
+ const overIndex = newSteps.findIndex(s => s.id === over.id);
+
+ // Swap positions
+ [newSteps[activeIndex], newSteps[overIndex]] = [newSteps[overIndex], newSteps[activeIndex]];
+
+ // Update order numbers
+ newSteps.forEach((step, index) => {
+ step.order = index + 1;
+ });
+
+ setDesign(prev => ({
+ ...prev,
+ steps: newSteps,
+ }));
+ setHasUnsavedChanges(true);
+ }
+ };
+
+ const handleStepEdit = (step: ExperimentStep) => {
+ // TODO: Open step configuration modal
+ console.log("Edit step:", step);
+ };
+
+ const handleStepDelete = (stepId: string) => {
+ setDesign(prev => ({
+ ...prev,
+ steps: prev.steps.filter(s => s.id !== stepId),
+ }));
+ setHasUnsavedChanges(true);
+
+ if (selectedStepId === stepId) {
+ setSelectedStepId(undefined);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!onSave || !hasUnsavedChanges) return;
+
+ setIsSaving(true);
+ try {
+ const updatedDesign = {
+ ...design,
+ lastSaved: new Date(),
+ version: design.version + 1,
+ };
+
+ await onSave(updatedDesign);
+ setDesign(updatedDesign);
+ setHasUnsavedChanges(false);
+ } catch (error) {
+ console.error("Failed to save experiment:", error);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
+
{design.name}
+ {hasUnsavedChanges && (
+
+ Unsaved Changes
+
+ )}
+
+
+
+
+
+ Undo
+
+
+
+ Redo
+
+
+
+
+ Preview
+
+
+
+ {isSaving ? "Saving..." : "Save"}
+
+
+
+
+
+
+ {/* Step Library Sidebar */}
+
+
+
Step Library
+
+ Drag steps onto the canvas to build your experiment
+
+
+
+
+ {(Object.keys(stepTypeConfig) as StepType[]).map((type) => (
+
+ ))}
+
+
+
+
+
+
Experiment Stats
+
+
+ Total Steps:
+ {design.steps.length}
+
+
+ Estimated Duration:
+
+ {design.steps.reduce((acc, step) => acc + (step.duration || 0), 0)}s
+
+
+
+ Version:
+ v{design.version}
+
+
+
+
+
+ {/* Main Canvas */}
+
+ {
+ setDesign(prev => ({ ...prev, steps: newSteps }));
+ setHasUnsavedChanges(true);
+ }}
+ />
+
+
+
+ {activeId ? (
+
+ {activeId.startsWith("library-") ? (
+
+ ) : (
+
+
+ Moving step...
+
+
+ )}
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/src/components/experiments/designer/ExperimentDesignerClient.tsx b/src/components/experiments/designer/ExperimentDesignerClient.tsx
new file mode 100644
index 0000000..bc022be
--- /dev/null
+++ b/src/components/experiments/designer/ExperimentDesignerClient.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import { api } from "~/trpc/react";
+import { ExperimentDesigner, ExperimentDesign } from "./ExperimentDesigner";
+
+interface ExperimentDesignerClientProps {
+ experiment: {
+ id: string;
+ name: string;
+ description: string;
+ studyId: string;
+ study?: {
+ name: string;
+ };
+ };
+}
+
+export function ExperimentDesignerClient({ experiment }: ExperimentDesignerClientProps) {
+ const [saveError, setSaveError] = useState(null);
+
+ // Fetch the experiment's design data
+ const { data: experimentSteps, isLoading } = api.experiments.getSteps.useQuery({
+ experimentId: experiment.id,
+ });
+
+ const saveDesignMutation = api.experiments.saveDesign.useMutation({
+ onSuccess: () => {
+ setSaveError(null);
+ },
+ onError: (error) => {
+ setSaveError(error.message);
+ },
+ });
+
+ const handleSave = async (design: ExperimentDesign) => {
+ try {
+ await saveDesignMutation.mutateAsync({
+ experimentId: experiment.id,
+ steps: design.steps,
+ version: design.version,
+ });
+ } catch (error) {
+ console.error("Failed to save design:", error);
+ throw error;
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading experiment designer...
+
+
+ );
+ }
+
+ const initialDesign: ExperimentDesign = {
+ id: experiment.id,
+ name: experiment.name,
+ steps: experimentSteps || [],
+ version: 1,
+ lastSaved: new Date(),
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Back to Experiment
+
+
+
+
+ {experiment.name}
+
+
+ Visual Protocol Designer
+
+
+
+
+
+ Study:
+
+ {experiment.study?.name || "Unknown Study"}
+
+
+
+
+ {/* Error Display */}
+ {saveError && (
+
+
+
+
+ Failed to save experiment: {saveError}
+
+
+
+
+ )}
+
+ {/* Designer */}
+
+
+
+
+ );
+}
diff --git a/src/components/studies/CreateStudyDialog.tsx b/src/components/studies/CreateStudyDialog.tsx
new file mode 100644
index 0000000..c333d2a
--- /dev/null
+++ b/src/components/studies/CreateStudyDialog.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Card, CardContent } from "~/components/ui/card";
+import { Textarea } from "~/components/ui/textarea";
+import { api } from "~/trpc/react";
+
+const createStudySchema = z.object({
+ name: z.string().min(1, "Study name is required").max(100, "Name too long"),
+ description: z
+ .string()
+ .min(10, "Description must be at least 10 characters")
+ .max(1000, "Description too long"),
+ irbProtocolNumber: z.string().optional(),
+ institution: z
+ .string()
+ .min(1, "Institution is required")
+ .max(100, "Institution name too long"),
+ status: z.enum(["draft", "active", "completed", "archived"]),
+});
+
+type CreateStudyFormData = z.infer;
+
+interface CreateStudyDialogProps {
+ children: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+export function CreateStudyDialog({
+ children,
+ onSuccess,
+}: CreateStudyDialogProps) {
+ const [open, setOpen] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ setValue,
+ watch,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(createStudySchema),
+ defaultValues: {
+ status: "draft" as const,
+ },
+ });
+
+ const createStudyMutation = api.studies.create.useMutation({
+ onSuccess: () => {
+ setOpen(false);
+ reset();
+ onSuccess?.();
+ },
+ onError: (err) => {
+ console.error("Failed to create study:", err);
+ },
+ });
+
+ const onSubmit = async (data: CreateStudyFormData) => {
+ try {
+ await createStudyMutation.mutateAsync(data);
+ } catch (error) {
+ // Error handling is done in the mutation's onError callback
+ }
+ };
+
+ const watchedStatus = watch("status");
+
+ return (
+
+ {children}
+
+
+ Create New Study
+
+ Start a new Human-Robot Interaction research study. You'll be
+ assigned as the study owner.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/studies/StudiesGrid.tsx b/src/components/studies/StudiesGrid.tsx
new file mode 100644
index 0000000..8b8ced1
--- /dev/null
+++ b/src/components/studies/StudiesGrid.tsx
@@ -0,0 +1,310 @@
+"use client";
+
+import { useState } from "react";
+import { Plus } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { CreateStudyDialog } from "./CreateStudyDialog";
+import { StudyCard } from "./StudyCard";
+import { api } from "~/trpc/react";
+
+type StudyWithRelations = {
+ id: string;
+ name: string;
+ description: string;
+ status: "draft" | "active" | "completed" | "archived";
+ institution: string;
+ irbProtocolNumber: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ ownerId: string;
+ createdBy: {
+ id: string;
+ name: string | null;
+ email: string;
+ };
+ members: Array<{
+ role: "owner" | "researcher" | "wizard" | "observer";
+ user: {
+ id: string;
+ name: string | null;
+ email: string;
+ };
+ }>;
+ experiments?: Array<{ id: string }>;
+ participants?: Array<{ id: string }>;
+};
+
+type ProcessedStudy = {
+ id: string;
+ name: string;
+ description: string;
+ status: "draft" | "active" | "completed" | "archived";
+ institution: string;
+ irbProtocolNumber?: string;
+ createdAt: Date;
+ updatedAt: Date;
+ ownerId: string;
+ owner: {
+ name: string | null;
+ email: string;
+ };
+ userRole?: "owner" | "researcher" | "wizard" | "observer";
+ isOwner?: boolean;
+ _count?: {
+ experiments: number;
+ trials: number;
+ studyMembers: number;
+ participants: number;
+ };
+};
+
+export function StudiesGrid() {
+ const [refreshKey, setRefreshKey] = useState(0);
+ const { data: session } = api.auth.me.useQuery();
+
+ const {
+ data: studiesData,
+ isLoading,
+ error,
+ refetch,
+ } = api.studies.list.useQuery(
+ { memberOnly: true },
+ {
+ refetchOnWindowFocus: false,
+ },
+ );
+
+ const processStudies = (
+ rawStudies: StudyWithRelations[],
+ ): ProcessedStudy[] => {
+ const currentUserId = session?.id;
+
+ return rawStudies.map((study) => {
+ // Find current user's membership
+ const userMembership = study.members?.find(
+ (member) => member.user.id === currentUserId,
+ );
+
+ return {
+ id: study.id,
+ name: study.name,
+ description: study.description,
+ status: study.status,
+ institution: study.institution,
+ irbProtocolNumber: study.irbProtocolNumber ?? undefined,
+ createdAt: study.createdAt,
+ updatedAt: study.updatedAt,
+ ownerId: study.ownerId,
+ owner: {
+ name: study.createdBy.name,
+ email: study.createdBy.email,
+ },
+ userRole: userMembership?.role,
+ isOwner: study.ownerId === currentUserId,
+ _count: {
+ experiments: study.experiments?.length ?? 0,
+ trials: 0, // Will be populated when trials relation is added
+ studyMembers: study.members?.length ?? 0,
+ participants: study.participants?.length ?? 0,
+ },
+ };
+ });
+ };
+
+ const studies = studiesData?.studies
+ ? processStudies(studiesData.studies)
+ : [];
+
+ const handleStudyCreated = () => {
+ setRefreshKey((prev) => prev + 1);
+ void refetch();
+ };
+
+ if (isLoading) {
+ return (
+
+ {/* Create Study Card Skeleton */}
+
+
+
+ Create New Study
+ Start a new HRI research study
+
+
+
+ Create Study
+
+
+
+
+ {/* Loading Skeletons */}
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {/* Create Study Card */}
+
+
+
+ Create New Study
+ Start a new HRI research study
+
+
+
+ Create Study
+
+
+
+
+ {/* Error State */}
+
+
+
+
+
+ Failed to Load Studies
+
+
+ {error.message ||
+ "An error occurred while loading your studies."}
+
+
refetch()} variant="outline">
+ Try Again
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Create New Study Card */}
+
+
+
+ Create New Study
+ Start a new HRI research study
+
+
+
+ Create Study
+
+
+
+
+ {/* Studies */}
+ {studies.map((study) => (
+
+ ))}
+
+ {/* Empty State */}
+ {studies.length === 0 && (
+
+
+
+
+
+ No Studies Yet
+
+
+ Get started by creating your first Human-Robot Interaction
+ research study. Studies help you organize experiments, manage
+ participants, and collaborate with your team.
+
+
+ Create Your First Study
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/studies/StudyCard.tsx b/src/components/studies/StudyCard.tsx
new file mode 100644
index 0000000..129843e
--- /dev/null
+++ b/src/components/studies/StudyCard.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { formatDistanceToNow } from "date-fns";
+import Link from "next/link";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Separator } from "~/components/ui/separator";
+
+interface Study {
+ id: string;
+ name: string;
+ description: string;
+ status: "draft" | "active" | "completed" | "archived";
+ institution: string;
+ irbProtocolNumber?: string;
+ createdAt: Date;
+ updatedAt: Date;
+ ownerId: string;
+ _count?: {
+ experiments: number;
+ trials: number;
+ studyMembers: number;
+ participants: number;
+ };
+ owner: {
+ name: string | null;
+ email: string;
+ };
+}
+
+interface StudyCardProps {
+ study: Study;
+ userRole?: "owner" | "researcher" | "wizard" | "observer";
+ isOwner?: boolean;
+}
+
+const statusConfig = {
+ draft: {
+ label: "Draft",
+ className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
+ icon: "📝",
+ },
+ active: {
+ label: "Active",
+ className: "bg-green-100 text-green-800 hover:bg-green-200",
+ icon: "🟢",
+ },
+ completed: {
+ label: "Completed",
+ className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
+ icon: "✅",
+ },
+ archived: {
+ label: "Archived",
+ className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
+ icon: "📦",
+ },
+};
+
+export function StudyCard({ study, userRole, isOwner }: StudyCardProps) {
+ const statusInfo = statusConfig[study.status];
+ const canEdit =
+ isOwner ?? (userRole === "owner" || userRole === "researcher");
+
+ return (
+
+
+
+
+
+
+ {study.name}
+
+
+
+ {study.description}
+
+
+
+ {statusInfo.icon}
+ {statusInfo.label}
+
+
+
+
+
+ {/* Institution and IRB */}
+
+
+
+
+
+ {study.institution}
+
+ {study.irbProtocolNumber && (
+
+
+
+
+ IRB: {study.irbProtocolNumber}
+
+ )}
+
+
+ {/* Statistics */}
+ {study._count && (
+ <>
+
+
+
+
+ Experiments:
+
+ {study._count.experiments}
+
+
+
+ Trials:
+ {study._count.trials}
+
+
+
+
+ Team:
+
+ {study._count.studyMembers}
+
+
+
+ Participants:
+
+ {study._count.participants}
+
+
+
+
+ >
+ )}
+
+ {/* Metadata */}
+
+
+
+ Created:
+
+ {formatDistanceToNow(study.createdAt, { addSuffix: true })}
+
+
+
+ Owner:
+
+ {study.owner.name ?? study.owner.email}
+
+
+ {study.updatedAt !== study.createdAt && (
+
+ Updated:
+
+ {formatDistanceToNow(study.updatedAt, { addSuffix: true })}
+
+
+ )}
+
+
+ {/* Actions */}
+
+
+ View Details
+
+ {canEdit && (
+
+ Edit
+
+ )}
+
+
+ {/* Role indicator */}
+ {userRole && (
+
+
+ Your role: {userRole}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..424eb4e
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "~/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..1c7ebff
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "~/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/server/api/routers/experiments.ts b/src/server/api/routers/experiments.ts
index 690def4..8613e5e 100644
--- a/src/server/api/routers/experiments.ts
+++ b/src/server/api/routers/experiments.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
-import { and, eq, desc, asc, inArray } from "drizzle-orm";
+import { and, eq, desc, asc, inArray, count } from "drizzle-orm";
+import { randomUUID } from "crypto";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import type { db } from "~/server/db";
@@ -109,6 +110,111 @@ export const experimentsRouter = createTRPCRouter({
}));
}),
+ getUserExperiments: protectedProcedure
+ .input(
+ z.object({
+ page: z.number().min(1).default(1),
+ limit: z.number().min(1).max(100).default(20),
+ status: z.enum(experimentStatusEnum.enumValues).optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const { page, limit, status } = input;
+ const offset = (page - 1) * limit;
+ const userId = ctx.session.user.id;
+
+ // Get all studies user is a member of
+ const userStudies = await ctx.db.query.studyMembers.findMany({
+ where: eq(studyMembers.userId, userId),
+ columns: {
+ studyId: true,
+ },
+ });
+
+ const studyIds = userStudies.map((membership) => membership.studyId);
+
+ if (studyIds.length === 0) {
+ return {
+ experiments: [],
+ pagination: {
+ page,
+ limit,
+ total: 0,
+ pages: 0,
+ },
+ };
+ }
+
+ // Build where conditions
+ const conditions = [inArray(experiments.studyId, studyIds)];
+
+ if (status) {
+ conditions.push(eq(experiments.status, status));
+ }
+
+ const whereClause = and(...conditions);
+
+ // Get experiments with relations
+ const userExperiments = await ctx.db.query.experiments.findMany({
+ where: whereClause,
+ with: {
+ study: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ createdBy: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ steps: {
+ columns: {
+ id: true,
+ },
+ },
+ trials: {
+ columns: {
+ id: true,
+ },
+ },
+ },
+ limit,
+ offset,
+ orderBy: [desc(experiments.updatedAt)],
+ });
+
+ // Get total count
+ const totalCountResult = await ctx.db
+ .select({ count: count() })
+ .from(experiments)
+ .where(whereClause);
+
+ const totalCount = totalCountResult[0]?.count ?? 0;
+
+ // Transform data to include counts
+ const transformedExperiments = userExperiments.map((experiment) => ({
+ ...experiment,
+ _count: {
+ steps: experiment.steps.length,
+ trials: experiment.trials.length,
+ },
+ }));
+
+ return {
+ experiments: transformedExperiments,
+ pagination: {
+ page,
+ limit,
+ total: totalCount,
+ pages: Math.ceil(totalCount / limit),
+ },
+ };
+ }),
+
get: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
@@ -988,7 +1094,10 @@ export const experimentsRouter = createTRPCRouter({
});
}
- if (action.type === "wait" && !(action.parameters as { duration?: number })?.duration) {
+ if (
+ action.type === "wait" &&
+ !(action.parameters as { duration?: number })?.duration
+ ) {
errors.push({
type: "missing_duration",
message: `Wait action "${action.name}" missing duration parameter`,
@@ -1015,4 +1124,177 @@ export const experimentsRouter = createTRPCRouter({
warnings,
};
}),
+
+ getSteps: protectedProcedure
+ .input(z.object({ experimentId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id;
+
+ // First verify user has access to this experiment
+ const experiment = await ctx.db.query.experiments.findFirst({
+ where: eq(experiments.id, input.experimentId),
+ with: {
+ study: {
+ with: {
+ members: {
+ where: eq(studyMembers.userId, userId),
+ },
+ },
+ },
+ },
+ });
+
+ if (!experiment || experiment.study.members.length === 0) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Access denied to this experiment",
+ });
+ }
+
+ // Get steps with their actions
+ const experimentSteps = await ctx.db.query.steps.findMany({
+ where: eq(steps.experimentId, input.experimentId),
+ with: {
+ actions: {
+ orderBy: [asc(actions.orderIndex)],
+ },
+ },
+ orderBy: [asc(steps.orderIndex)],
+ });
+
+ // Transform to designer format
+ return experimentSteps.map((step) => ({
+ id: step.id,
+ type: step.type as "wizard" | "robot" | "parallel" | "conditional",
+ name: step.name,
+ description: step.description,
+ order: step.orderIndex,
+ duration: step.durationEstimate,
+ parameters: step.conditions as Record,
+ parentId: undefined, // Not supported in current schema
+ children: [], // TODO: implement hierarchical steps if needed
+ }));
+ }),
+
+ saveDesign: protectedProcedure
+ .input(
+ z.object({
+ experimentId: z.string().uuid(),
+ steps: z.array(
+ z.object({
+ id: z.string(),
+ type: z.enum(["wizard", "robot", "parallel", "conditional"]),
+ name: z.string(),
+ description: z.string().optional(),
+ order: z.number(),
+ duration: z.number().optional(),
+ parameters: z.record(z.any()),
+ parentId: z.string().optional(),
+ children: z.array(z.string()).optional(),
+ }),
+ ),
+ version: z.number(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id;
+
+ // Verify user has write access to this experiment
+ const experiment = await ctx.db.query.experiments.findFirst({
+ where: eq(experiments.id, input.experimentId),
+ with: {
+ study: {
+ with: {
+ members: {
+ where: and(
+ eq(studyMembers.userId, userId),
+ inArray(studyMembers.role, ["owner", "researcher"] as const),
+ ),
+ },
+ },
+ },
+ },
+ });
+
+ if (!experiment || experiment.study.members.length === 0) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Access denied to modify this experiment",
+ });
+ }
+
+ // Get existing steps
+ const existingSteps = await ctx.db.query.steps.findMany({
+ where: eq(steps.experimentId, input.experimentId),
+ });
+
+ const existingStepIds = new Set(existingSteps.map((s) => s.id));
+ const newStepIds = new Set(input.steps.map((s) => s.id));
+
+ // Steps to delete (exist in DB but not in input)
+ const stepsToDelete = existingSteps.filter((s) => !newStepIds.has(s.id));
+
+ // Steps to insert (in input but don't exist in DB or have temp IDs)
+ const stepsToInsert = input.steps.filter(
+ (s) => !existingStepIds.has(s.id) || s.id.startsWith("step-"),
+ );
+
+ // Steps to update (exist in both)
+ const stepsToUpdate = input.steps.filter(
+ (s) => existingStepIds.has(s.id) && !s.id.startsWith("step-"),
+ );
+
+ // Execute in transaction
+ await ctx.db.transaction(async (tx) => {
+ // Delete removed steps
+ if (stepsToDelete.length > 0) {
+ await tx.delete(steps).where(
+ inArray(
+ steps.id,
+ stepsToDelete.map((s) => s.id),
+ ),
+ );
+ }
+
+ // Insert new steps
+ for (const step of stepsToInsert) {
+ const stepId = step.id.startsWith("step-") ? randomUUID() : step.id;
+
+ await tx.insert(steps).values({
+ id: stepId,
+ experimentId: input.experimentId,
+ name: step.name,
+ description: step.description,
+ type: step.type,
+ orderIndex: step.order,
+ durationEstimate: step.duration,
+ conditions: step.parameters,
+ });
+ }
+
+ // Update existing steps
+ for (const step of stepsToUpdate) {
+ await tx
+ .update(steps)
+ .set({
+ name: step.name,
+ description: step.description,
+ type: step.type,
+ orderIndex: step.order,
+ durationEstimate: step.duration,
+ conditions: step.parameters,
+ updatedAt: new Date(),
+ })
+ .where(eq(steps.id, step.id));
+ }
+
+ // Update experiment's updated timestamp
+ await tx
+ .update(experiments)
+ .set({ updatedAt: new Date() })
+ .where(eq(experiments.id, input.experimentId));
+ });
+
+ return { success: true };
+ }),
});
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index df93f2f..d79ef60 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -788,7 +788,12 @@ export const auditLogs = createTable(
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
sessions: many(sessions),
- systemRoles: many(userSystemRoles),
+ systemRoles: many(userSystemRoles, {
+ relationName: "user",
+ }),
+ grantedRoles: many(userSystemRoles, {
+ relationName: "grantedByUser",
+ }),
createdStudies: many(studies),
studyMemberships: many(studyMembers),
createdExperiments: many(experiments),
@@ -814,10 +819,12 @@ export const userSystemRolesRelations = relations(
user: one(users, {
fields: [userSystemRoles.userId],
references: [users.id],
+ relationName: "user",
}),
grantedByUser: one(users, {
fields: [userSystemRoles.grantedBy],
references: [users.id],
+ relationName: "grantedByUser",
}),
}),
);