Consolidate global routes into study-scoped architecture

Removed global participants, trials, and analytics routes. All entity
management now flows through study-specific routes. Updated navigation,
breadcrumbs, and forms. Added helpful redirect pages for moved routes.
Eliminated duplicate table components and unified navigation patterns.
Fixed dashboard route structure and layout inheritance.
This commit is contained in:
2025-09-23 23:52:34 -04:00
parent 4acbec6288
commit c2bfeb8db2
29 changed files with 344 additions and 3896 deletions

View File

@@ -1,535 +0,0 @@
import { format } from "date-fns";
import {
Activity,
ArrowLeft,
BarChart3,
Bot,
Camera,
Clock,
Download,
FileText,
MessageSquare,
Share,
Target,
Timer,
TrendingUp,
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 {
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";
import { auth } from "~/server/auth";
import { api } from "~/trpc/server";
interface AnalysisPageProps {
params: Promise<{
trialId: string;
}>;
}
export default async function AnalysisPage({ params }: AnalysisPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/signin");
}
const { trialId } = await params;
let trial;
try {
trial = await api.trials.get({ id: trialId });
} catch {
notFound();
}
// Only allow analysis view for completed trials
if (trial.status !== "completed") {
redirect(`/trials/${trialId}?error=trial_not_completed`);
}
// Calculate trial metrics
const duration =
trial.startedAt && trial.completedAt
? Math.floor(
(new Date(trial.completedAt).getTime() -
new Date(trial.startedAt).getTime()) /
1000 /
60,
)
: 0;
// Mock experiment steps - in real implementation, fetch from experiment API
const experimentSteps: Array<{
id: string;
name: string;
description?: string;
order: number;
}> = [];
// Mock analysis data - in real implementation, this would come from API
const analysisData = {
totalEvents: 45,
wizardInterventions: 3,
robotActions: 12,
mediaCaptures: 8,
annotations: 15,
participantResponses: 22,
averageResponseTime: 2.3,
completionRate: 100,
successRate: 95,
errorCount: 2,
};
return (
<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
</Button>
<Button variant="outline">
<Share className="mr-2 h-4 w-4" />
Share Results
</Button>
<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-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-muted-foreground text-xs">Duration</p>
<p className="text-lg font-semibold">{duration} min</p>
</div>
</div>
</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-muted-foreground text-xs">
Completion Rate
</p>
<p className="text-lg font-semibold text-green-600">
{analysisData.completionRate}%
</p>
</div>
</div>
</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-muted-foreground text-xs">Total Events</p>
<p className="text-lg font-semibold">
{analysisData.totalEvents}
</p>
</div>
</div>
</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-muted-foreground text-xs">Success Rate</p>
<p className="text-lg font-semibold text-green-600">
{analysisData.successRate}%
</p>
</div>
</div>
</div>
</div>
</EntityViewSection>
{/* Main Analysis Content */}
<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 */}
<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">
<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>
<p className="text-sm">
{trial.startedAt
? format(trial.startedAt, "PPP 'at' p")
: "N/A"}
</p>
</div>
<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>
<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>
</EntityViewSection>
</div>
</EntityView>
);
}
// Generate metadata for the page
export async function generateMetadata({
params,
}: AnalysisPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });
return {
title: `Analysis - ${trial.experiment.name} | HRIStudio`,
description: `Analysis dashboard for trial with participant ${trial.participant.participantCode}`,
};
} catch {
return {
title: "Trial Analysis | HRIStudio",
description: "Analyze trial data and participant interactions",
};
}
}

View File

@@ -1,13 +0,0 @@
import { TrialForm } from "~/components/trials/TrialForm";
interface EditTrialPageProps {
params: Promise<{
trialId: string;
}>;
}
export default async function EditTrialPage({ params }: EditTrialPageProps) {
const { trialId } = await params;
return <TrialForm mode="edit" trialId={trialId} />;
}

View File

@@ -1,498 +0,0 @@
"use client";
import { formatDistanceToNow } from "date-fns";
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";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react";
interface TrialDetailPageProps {
params: Promise<{ trialId: string }>;
searchParams: Promise<{ error?: string }>;
}
const statusConfig = {
scheduled: {
label: "Scheduled",
variant: "outline" as const,
icon: "Clock" as const,
},
in_progress: {
label: "In Progress",
variant: "secondary" as const,
icon: "Play" as const,
},
completed: {
label: "Completed",
variant: "default" as const,
icon: "CheckCircle" as const,
},
failed: {
label: "Failed",
variant: "destructive" as const,
icon: "AlertCircle" as const,
},
cancelled: {
label: "Cancelled",
variant: "outline" as const,
icon: "AlertCircle" as const,
},
};
type Trial = {
id: string;
participantId: string | null;
experimentId: string;
wizardId?: string | null;
sessionNumber?: number;
status: string;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
notes: string | null;
createdAt: Date;
updatedAt: Date;
experiment: {
id: string;
name: string;
studyId: string;
} | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
};
type TrialEvent = {
id: string;
trialId: string;
eventType: string;
actionId: string | null;
timestamp: Date;
data: unknown;
createdBy: string | null;
};
export default function TrialDetailPage({
params,
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);
const [resolvedParams, setResolvedParams] = useState<{
trialId: string;
} | null>(null);
const [resolvedSearchParams, setResolvedSearchParams] = useState<{
error?: string;
} | null>(null);
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
useEffect(() => {
const resolveSearchParams = async () => {
const resolved = await searchParams;
setResolvedSearchParams(resolved);
};
void resolveSearchParams();
}, [searchParams]);
const trialQuery = api.trials.get.useQuery(
{ id: resolvedParams?.trialId ?? "" },
{ enabled: !!resolvedParams?.trialId },
);
const eventsQuery = api.trials.getEvents.useQuery(
{ trialId: resolvedParams?.trialId ?? "" },
{ enabled: !!resolvedParams?.trialId },
);
useEffect(() => {
if (trialQuery.data) {
setTrial(trialQuery.data as Trial);
}
}, [trialQuery.data]);
useEffect(() => {
if (eventsQuery.data) {
setEvents(eventsQuery.data as TrialEvent[]);
}
}, [eventsQuery.data]);
useEffect(() => {
if (trialQuery.isLoading || eventsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [trialQuery.isLoading, eventsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: "Study",
href: trial?.experiment?.studyId
? `/studies/${trial.experiment.studyId}`
: "/studies",
},
{
label: "Trials",
href: trial?.experiment?.studyId
? `/studies/${trial.experiment.studyId}/trials`
: "/trials",
},
{
label: `Trial #${resolvedParams?.trialId?.slice(-6) ?? "Unknown"}`,
},
]);
if (loading) return <div>Loading...</div>;
if (trialQuery.error || !trial) return <div>Trial not found</div>;
const statusInfo = statusConfig[trial.status as keyof typeof statusConfig];
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
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";
return (
<EntityView>
{resolvedSearchParams?.error && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{resolvedSearchParams.error}</AlertDescription>
</Alert>
)}
<EntityViewHeader
title={displayName}
subtitle={`${experimentName} - ${trial.participant?.participantCode ?? "Unknown Participant"}`}
icon="Play"
status={
statusInfo && {
label: statusInfo.label,
variant: statusInfo.variant,
icon: statusInfo.icon,
}
}
actions={
<>
{canControl && trial.status === "scheduled" && (
<>
<Button
onClick={handleStartTrial}
disabled={startTrialMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
{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">
<Link href={`/trials/${trial.id}/wizard`}>
<Eye className="mr-2 h-4 w-4" />
Monitor
</Link>
</Button>
)}
{trial.status === "completed" && (
<Button asChild variant="outline">
<Link href={`/trials/${trial.id}/analysis`}>
<Info className="mr-2 h-4 w-4" />
Analysis
</Link>
</Button>
)}
</>
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Trial Information */}
<EntityViewSection title="Trial Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Experiment",
value: trial.experiment ? (
<Link
href={`/experiments/${trial.experiment.id}`}
className="text-primary hover:underline"
>
{trial.experiment.name}
</Link>
) : (
"Unknown"
),
},
{
label: "Participant",
value: trial.participant ? (
<Link
href={`/participants/${trial.participant.id}`}
className="text-primary hover:underline"
>
{trial.participant.name ??
trial.participant.participantCode}
</Link>
) : (
"Unknown"
),
},
{
label: "Study",
value: trial.experiment?.studyId ? (
<Link
href={`/studies/${trial.experiment.studyId}`}
className="text-primary hover:underline"
>
Study
</Link>
) : (
"Unknown"
),
},
{
label: "Status",
value: statusInfo?.label ?? trial.status,
},
{
label: "Scheduled",
value: trial.createdAt
? formatDistanceToNow(trial.createdAt, { addSuffix: true })
: "Not scheduled",
},
{
label: "Duration",
value: trial.duration
? `${Math.round(trial.duration / 60)} minutes`
: trial.status === "in_progress"
? "Ongoing"
: "Not available",
},
]}
/>
</EntityViewSection>
{/* Trial Notes */}
{trial.notes && (
<EntityViewSection title="Notes" icon="FileText">
<div className="prose prose-sm max-w-none">
<p className="text-muted-foreground">{trial.notes}</p>
</div>
</EntityViewSection>
)}
{/* Event Timeline */}
<EntityViewSection
title="Event Timeline"
icon="Activity"
description={`${events.length} events recorded`}
>
{events.length > 0 ? (
<div className="space-y-4">
{events.slice(0, 10).map((event) => (
<div key={event.id} className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{event.eventType
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</span>
<span className="text-muted-foreground text-sm">
{formatDistanceToNow(event.timestamp, {
addSuffix: true,
})}
</span>
</div>
{event.data ? (
<div className="text-muted-foreground text-sm">
<pre className="text-xs">
{typeof event.data === "object" && event.data !== null
? JSON.stringify(event.data, null, 2)
: String(event.data as string | number | boolean)}
</pre>
</div>
) : null}
</div>
))}
{events.length > 10 && (
<div className="text-center">
<Button variant="outline" size="sm">
View All Events ({events.length})
</Button>
</div>
)}
</div>
) : (
<EmptyState
icon="Activity"
title="No events recorded"
description="Events will appear here as the trial progresses"
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Events",
value: events.length,
},
{
label: "Created",
value: formatDistanceToNow(trial.createdAt, {
addSuffix: true,
}),
},
{
label: "Started",
value: trial.startedAt
? formatDistanceToNow(trial.startedAt, { addSuffix: true })
: "Not started",
},
{
label: "Completed",
value: trial.completedAt
? formatDistanceToNow(trial.completedAt, {
addSuffix: true,
})
: "Not completed",
},
{
label: "Created By",
value: "System",
},
]}
/>
</EntityViewSection>
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
...(canControl && trial.status === "scheduled"
? [
{
label: "Start Trial",
icon: "Play" as const,
href: `/trials/${trial.id}/wizard`,
variant: "default" as const,
},
]
: []),
...(canControl && trial.status === "in_progress"
? [
{
label: "Monitor Trial",
icon: "Eye" as const,
href: `/trials/${trial.id}/wizard`,
},
]
: []),
...(trial.status === "completed"
? [
{
label: "View Analysis",
icon: "BarChart" as const,
href: `/trials/${trial.id}/analysis`,
},
{
label: "Export Data",
icon: "Download" as const,
href: `/trials/${trial.id}/export`,
},
]
: []),
{
label: "View Events",
icon: "Activity" as const,
href: `/trials/${trial.id}/events`,
},
{
label: "Export Report",
icon: "FileText" as const,
href: `/trials/${trial.id}/report`,
},
]}
/>
</EntityViewSection>
{/* Participant Info */}
{trial.participant && (
<EntityViewSection title="Participant" icon="User">
<InfoGrid
columns={1}
items={[
{
label: "Code",
value: trial.participant.participantCode,
},
{
label: "Name",
value: trial.participant.name ?? "Not provided",
},
]}
/>
</EntityViewSection>
)}
</div>
</div>
</EntityView>
);
}

View File

@@ -1,243 +0,0 @@
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

@@ -1,77 +0,0 @@
import { notFound, redirect } from "next/navigation";
import { WizardInterface } from "~/components/trials/wizard/WizardInterface";
import { auth } from "~/server/auth";
import { api } from "~/trpc/server";
interface WizardPageProps {
params: Promise<{
trialId: string;
}>;
}
export default async function WizardPage({ params }: WizardPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/signin");
}
// Check if user has wizard/researcher permissions
const userRole = session.user.roles?.[0]?.role;
if (
!userRole ||
!["wizard", "researcher", "administrator"].includes(userRole)
) {
redirect("/trials?error=insufficient_permissions");
}
const { trialId } = await params;
let trial;
try {
trial = await api.trials.get({ id: trialId });
} catch {
notFound();
}
// Only allow wizard interface for scheduled or in-progress trials
if (!["scheduled", "in_progress"].includes(trial.status)) {
redirect(`/trials/${trialId}?error=trial_not_active`);
}
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,
},
};
return <WizardInterface trial={normalizedTrial} userRole={userRole} />;
}
// Generate metadata for the page
export async function generateMetadata({
params,
}: WizardPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });
return {
title: `Wizard Control - ${trial.experiment.name} | HRIStudio`,
description: `Real-time wizard control interface for trial ${trial.participant.participantCode}`,
};
} catch {
return {
title: "Wizard Control | HRIStudio",
description: "Real-time wizard control interface for HRI trials",
};
}
}

View File

@@ -1,5 +0,0 @@
import { TrialForm } from "~/components/trials/TrialForm";
export default function NewTrialPage() {
return <TrialForm mode="create" />;
}

View File

@@ -1,10 +1,65 @@
import { TrialsDataTable } from "~/components/trials/trials-data-table";
import { StudyGuard } from "~/components/dashboard/study-guard";
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { TestTube, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function TrialsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study trials
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/trials`);
}
}, [selectedStudyId, router]);
export default function TrialsPage() {
return (
<StudyGuard>
<TrialsDataTable />
</StudyGuard>
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-orange-50">
<TestTube className="h-8 w-8 text-orange-500" />
</div>
<CardTitle className="text-2xl">Trials Moved</CardTitle>
<CardDescription>
Trial management is now organized by study for better workflow
organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage trials:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s trials page</li>
<li> Schedule and monitor trials for that specific study</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}