Files
hristudio/src/components/trials/TrialsGrid.tsx
Sean O'Connor dbfdd91dea feat: Redesign Landing, Auth, and Dashboard Pages
Also fixed schema type exports and seed script errors.
2026-02-01 22:28:19 -05:00

548 lines
17 KiB
TypeScript
Executable File

"use client";
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { api } from "~/trpc/react";
type TrialWithRelations = {
id: string;
experimentId: string;
participantId: string | null;
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
duration: number | null;
notes: string | null;
wizardId: string | null;
createdAt: Date;
experiment?: {
id: string;
name: string;
study?: {
id: string;
name: string;
};
};
participant?: {
id: string;
participantCode: string;
email: string | null;
name: string | null;
} | null;
wizard?: {
id: string;
name: string | null;
email: string;
} | null;
_count?: {
events: number;
mediaCaptures: number;
};
};
const statusConfig = {
scheduled: {
label: "Scheduled",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
icon: Clock,
action: "Start Trial",
actionIcon: Play,
},
in_progress: {
label: "In Progress",
className: "bg-green-100 text-green-800 hover:bg-green-200",
icon: Play,
action: "Monitor",
actionIcon: Eye,
},
completed: {
label: "Completed",
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
icon: Square,
action: "Review",
actionIcon: Eye,
},
aborted: {
label: "Aborted",
className: "bg-red-100 text-red-800 hover:bg-red-200",
icon: Square,
action: "View",
actionIcon: Eye,
},
failed: {
label: "Failed",
className: "bg-red-100 text-red-800 hover:bg-red-200",
icon: Square,
action: "View",
actionIcon: Eye,
},
};
interface TrialCardProps {
trial: TrialWithRelations;
userRole: string;
onTrialAction: (trialId: string, action: string) => void;
}
function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
const statusInfo = statusConfig[trial.status];
const StatusIcon = statusInfo.icon;
const ActionIcon = statusInfo.actionIcon;
const isScheduledSoon =
trial.status === "scheduled" && trial.scheduledAt
? new Date(trial.scheduledAt).getTime() - Date.now() < 60 * 60 * 1000
: false; // Within 1 hour
const canControl =
userRole === "wizard" ||
userRole === "researcher" ||
userRole === "administrator";
return (
<Card
className={`group transition-all duration-200 hover:border-slate-300 hover:shadow-md ${
trial.status === "in_progress" ? "shadow-md ring-2 ring-green-500" : ""
}`}
>
<CardHeader className="pb-3">
<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={`/studies/${trial.experiment?.study?.id}/trials/${trial.id}`}
className="hover:underline"
>
{trial.experiment?.name ?? "Unknown Experiment"}
</Link>
</CardTitle>
<CardDescription className="mt-1 text-sm text-slate-600">
Participant: {trial.participant?.participantCode ?? "Unknown"}
</CardDescription>
<div className="mt-2 flex items-center space-x-4 text-xs text-slate-500">
<Link
href={`/studies/${trial.experiment?.study?.id ?? "unknown"}`}
className="font-medium text-blue-600 hover:text-blue-800"
>
{trial.experiment?.study?.name ?? "Unknown Study"}
</Link>
{trial.wizard && (
<span>Wizard: {trial.wizard.name ?? trial.wizard.email}</span>
)}
</div>
</div>
<div className="flex flex-col items-end space-y-2">
<Badge className={statusInfo.className} variant="secondary">
<StatusIcon className="mr-1 h-3 w-3" />
{statusInfo.label}
</Badge>
{isScheduledSoon && (
<Badge
variant="outline"
className="border-orange-600 text-orange-600"
>
Starting Soon
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Schedule Information */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Scheduled:</span>
<span className="font-medium">
{trial.scheduledAt
? format(trial.scheduledAt, "MMM d, yyyy 'at' h:mm a")
: "Not scheduled"}
</span>
</div>
{trial.startedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Started:</span>
<span className="font-medium">
{formatDistanceToNow(trial.startedAt, { addSuffix: true })}
</span>
</div>
)}
{trial.completedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Completed:</span>
<span className="font-medium">
{formatDistanceToNow(trial.completedAt, { addSuffix: true })}
</span>
</div>
)}
{trial.duration && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Duration:</span>
<span className="font-medium">
{Math.round(trial.duration / 60)} minutes
</span>
</div>
)}
</div>
{/* Statistics */}
{trial._count && (
<>
<Separator />
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Events:</span>
<span className="font-medium">{trial._count.events}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Media:</span>
<span className="font-medium">
{trial._count.mediaCaptures}
</span>
</div>
</div>
</>
)}
{/* Notes Preview */}
{trial.notes && (
<>
<Separator />
<div className="text-sm">
<span className="text-slate-600">Notes: </span>
<span className="text-slate-900">
{trial.notes.substring(0, 100)}...
</span>
</div>
</>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
{trial.status === "scheduled" && canControl && (
<Button
size="sm"
className="flex-1"
onClick={() => onTrialAction(trial.id, "start")}
>
<ActionIcon className="mr-1 h-3 w-3" />
{statusInfo.action}
</Button>
)}
{trial.status === "in_progress" && (
<Button asChild size="sm" className="flex-1">
<Link
href={`/studies/${trial.experiment?.study?.id}/trials/${trial.id}/wizard`}
>
<Eye className="mr-1 h-3 w-3" />
Wizard Interface
</Link>
</Button>
)}
{trial.status === "completed" && (
<Button asChild size="sm" variant="outline" className="flex-1">
<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={`/studies/${trial.experiment?.study?.id}/trials/${trial.id}`}
>
<Settings className="mr-1 h-3 w-3" />
Details
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
export function TrialsGrid() {
const [statusFilter, setStatusFilter] = useState<string>("all");
const { selectedStudyId } = useStudyContext();
const { data: userSession } = api.auth.me.useQuery();
const {
data: trialsData,
isLoading,
error,
refetch,
} = api.trials.getUserTrials.useQuery(
{
page: 1,
limit: 50,
status:
statusFilter === "all"
? undefined
: (statusFilter as
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed"),
},
{
refetchOnWindowFocus: false,
refetchInterval: 30000, // Refetch every 30 seconds for real-time updates
},
);
const startTrialMutation = api.trials.start.useMutation({
onSuccess: () => {
void refetch();
},
});
const trials = trialsData?.trials ?? [];
const userRole = userSession?.roles?.[0] ?? "observer";
const handleTrialAction = async (trialId: string, action: string) => {
if (action === "start") {
try {
await startTrialMutation.mutateAsync({ id: trialId });
} catch (error) {
console.error("Failed to start trial:", error);
}
}
};
// 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">
{/* Status Filter Skeleton */}
<div className="flex items-center space-x-2">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-8 w-20 animate-pulse rounded bg-slate-200"
></div>
))}
</div>
{/* Grid Skeleton */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="space-y-2">
<div className="h-5 w-3/4 rounded bg-slate-200"></div>
<div className="h-4 w-1/2 rounded bg-slate-200"></div>
<div className="h-3 w-2/3 rounded bg-slate-200"></div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="h-4 w-full rounded bg-slate-200"></div>
<div className="h-4 w-full rounded bg-slate-200"></div>
</div>
<div className="flex gap-2">
<div className="h-8 flex-1 rounded bg-slate-200"></div>
<div className="h-8 w-16 rounded bg-slate-200"></div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-red-100">
<svg
className="h-8 w-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
Failed to Load Trials
</h3>
<p className="mb-4 text-slate-600">
{error.message || "An error occurred while loading your trials."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Trials</h1>
<p className="text-muted-foreground">
Schedule, execute, and monitor HRI experiment trials with real-time
wizard control
</p>
</div>
{/* Quick Actions Bar */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("all")}
>
All ({trials.length})
</Button>
<Button
variant={statusFilter === "scheduled" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("scheduled")}
>
Scheduled ({upcomingTrials.length})
</Button>
<Button
variant={statusFilter === "in_progress" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("in_progress")}
>
Active ({activeTrials.length})
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("completed")}
>
Completed ({completedTrials.length})
</Button>
</div>
{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) */}
{activeTrials.length > 0 &&
(statusFilter === "all" || statusFilter === "in_progress") && (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500"></div>
<h2 className="text-xl font-semibold text-slate-900">
Active Trials
</h2>
<Badge className="bg-green-100 text-green-800">
{activeTrials.length} running
</Badge>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{activeTrials.map((trial) => (
<TrialCard
key={trial.id}
trial={trial}
userRole={userRole}
onTrialAction={handleTrialAction}
/>
))}
</div>
</div>
)}
{/* Main Trials Grid */}
<div className="space-y-4">
{statusFilter !== "in_progress" && (
<h2 className="text-xl font-semibold text-slate-900">
{statusFilter === "all"
? "All Trials"
: statusFilter === "scheduled"
? "Scheduled Trials"
: statusFilter === "completed"
? "Completed Trials"
: "Cancelled Trials"}
</h2>
)}
{trials.length === 0 ? (
<Card className="py-12 text-center">
<CardContent>
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
<Play className="h-12 w-12 text-slate-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No Trials Yet
</h3>
<p className="mb-4 text-slate-600">
Schedule your first trial to start collecting data with real
participants. Trials let you execute your designed experiments
with wizard control.
</p>
{selectedStudyId && (
<Button asChild>
<Link href={`/studies/${selectedStudyId}/trials/new`}>
Schedule Your First Trial
</Link>
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{trials
.filter(
(trial) =>
statusFilter === "all" ||
trial.status === statusFilter ||
(statusFilter === "in_progress" &&
trial.status === "in_progress"),
)
.map((trial) => (
<TrialCard
key={trial.id}
trial={trial}
userRole={userRole}
onTrialAction={handleTrialAction}
/>
))}
</div>
)}
</div>
</div>
);
}