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,16 +1,9 @@
"use client";
import {
Activity,
BarChart3,
Calendar,
Download,
Filter,
TrendingDown,
TrendingUp,
} from "lucide-react";
import { StudyGuard } from "~/components/dashboard/study-guard";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { AlertCircle, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
@@ -19,290 +12,53 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context";
// Mock chart component - replace with actual charting library
function MockChart({ title, data }: { title: string; data: number[] }) {
const maxValue = Math.max(...data);
export default function AnalyticsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
return (
<div className="space-y-2">
<h4 className="text-sm font-medium">{title}</h4>
<div className="flex h-32 items-end space-x-1">
{data.map((value, index) => (
<div
key={index}
className="bg-primary min-h-[4px] flex-1 rounded-t"
style={{ height: `${(value / maxValue) * 100}%` }}
/>
))}
</div>
</div>
);
}
function AnalyticsOverview() {
const metrics = [
{
title: "Total Trials This Month",
value: "142",
change: "+12%",
trend: "up",
description: "vs last month",
icon: Activity,
},
{
title: "Avg Trial Duration",
value: "24.5m",
change: "-3%",
trend: "down",
description: "vs last month",
icon: Calendar,
},
{
title: "Completion Rate",
value: "94.2%",
change: "+2.1%",
trend: "up",
description: "vs last month",
icon: TrendingUp,
},
{
title: "Participant Retention",
value: "87.3%",
change: "+5.4%",
trend: "up",
description: "vs last month",
icon: BarChart3,
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => (
<Card key={metric.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{metric.title}
</CardTitle>
<metric.icon className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metric.value}</div>
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
<span
className={`flex items-center ${
metric.trend === "up" ? "text-green-600" : "text-red-600"
}`}
>
{metric.trend === "up" ? (
<TrendingUp className="mr-1 h-3 w-3" />
) : (
<TrendingDown className="mr-1 h-3 w-3" />
)}
{metric.change}
</span>
<span>{metric.description}</span>
</div>
</CardContent>
</Card>
))}
</div>
);
}
function ChartsSection() {
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Trial Volume</CardTitle>
<CardDescription>Monthly trial execution trends</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="Trials per Month" data={trialData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Participant Enrollment</CardTitle>
<CardDescription>New participants over time</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="New Participants" data={participantData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Completion Rates</CardTitle>
<CardDescription>Trial completion percentage</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="Completion %" data={completionData} />
</CardContent>
</Card>
</div>
);
}
function RecentInsights() {
const insights = [
{
title: "Peak Performance Hours",
description:
"Participants show 23% better performance during 10-11 AM trials",
type: "trend",
severity: "info",
},
{
title: "Attention Span Decline",
description:
"Average attention span has decreased by 8% over the last month",
type: "alert",
severity: "warning",
},
{
title: "High Completion Rate",
description: "Memory retention study achieved 98% completion rate",
type: "success",
severity: "success",
},
{
title: "Equipment Utilization",
description: "Robot interaction trials are at 85% capacity utilization",
type: "info",
severity: "info",
},
];
const getSeverityColor = (severity: string) => {
switch (severity) {
case "success":
return "bg-green-50 text-green-700 border-green-200";
case "warning":
return "bg-yellow-50 text-yellow-700 border-yellow-200";
case "info":
return "bg-blue-50 text-blue-700 border-blue-200";
default:
return "bg-gray-50 text-gray-700 border-gray-200";
useEffect(() => {
// If user has a selected study, redirect to study analytics
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/analytics`);
}
};
}, [selectedStudyId, router]);
return (
<Card>
<CardHeader>
<CardTitle>Recent Insights</CardTitle>
<CardDescription>
AI-generated insights from your research data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{insights.map((insight, index) => (
<div
key={index}
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
>
<h4 className="mb-1 font-medium">{insight.title}</h4>
<p className="text-sm">{insight.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function AnalyticsContent() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">
Insights and data analysis for your research
</p>
</div>
<div className="flex items-center space-x-2">
<Select defaultValue="30d">
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="90d">Last 90 days</SelectItem>
<SelectItem value="1y">Last year</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" />
Filter
</Button>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
</div>
{/* Overview Metrics */}
<AnalyticsOverview />
{/* Charts */}
<ChartsSection />
{/* Insights */}
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<RecentInsights />
</div>
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Generate custom reports</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-start">
<BarChart3 className="mr-2 h-4 w-4" />
Trial Performance Report
<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-blue-50">
<AlertCircle className="h-8 w-8 text-blue-500" />
</div>
<CardTitle className="text-2xl">Analytics Moved</CardTitle>
<CardDescription>
Analytics are now organized by study for better data insights.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To view analytics, please:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s analytics page</li>
<li> Get study-specific insights and data</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 variant="outline" className="w-full justify-start">
<Activity className="mr-2 h-4 w-4" />
Participant Engagement
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
<Button variant="outline" className="w-full justify-start">
<TrendingUp className="mr-2 h-4 w-4" />
Trend Analysis
</Button>
<Button variant="outline" className="w-full justify-start">
<Download className="mr-2 h-4 w-4" />
Custom Export
</Button>
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function AnalyticsPage() {
return (
<StudyGuard>
<AnalyticsContent />
</StudyGuard>
);
}

View File

@@ -1,353 +0,0 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BarChart3,
Building,
FlaskConical,
TestTube,
Users,
Calendar,
Clock,
AlertCircle,
CheckCircle2,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
// Dashboard Overview Cards
function OverviewCards() {
const { data: stats, isLoading } = api.dashboard.getStats.useQuery();
const cards = [
{
title: "Active Studies",
value: stats?.totalStudies ?? 0,
description: "Research studies you have access to",
icon: Building,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "Experiments",
value: stats?.totalExperiments ?? 0,
description: "Experiment protocols designed",
icon: FlaskConical,
color: "text-green-600",
bg: "bg-green-50",
},
{
title: "Participants",
value: stats?.totalParticipants ?? 0,
description: "Enrolled participants",
icon: Users,
color: "text-purple-600",
bg: "bg-purple-50",
},
{
title: "Trials",
value: stats?.totalTrials ?? 0,
description: "Total trials conducted",
icon: TestTube,
color: "text-orange-600",
bg: "bg-orange-50",
},
];
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="bg-muted h-4 w-20 animate-pulse rounded" />
<div className="bg-muted h-8 w-8 animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="bg-muted h-8 w-12 animate-pulse rounded" />
<div className="bg-muted mt-2 h-3 w-24 animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={`rounded-md p-2 ${card.bg}`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
<p className="text-muted-foreground text-xs">{card.description}</p>
</CardContent>
</Card>
))}
</div>
);
}
// Recent Activity Component
function RecentActivity() {
const { data: activities = [], isLoading } =
api.dashboard.getRecentActivity.useQuery({
limit: 8,
});
const getStatusIcon = (status: string) => {
switch (status) {
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
case "pending":
return <Clock className="h-4 w-4 text-yellow-600" />;
case "error":
return <AlertCircle className="h-4 w-4 text-red-600" />;
default:
return <AlertCircle className="h-4 w-4 text-blue-600" />;
}
};
return (
<Card className="col-span-4">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Latest updates from your research platform
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center space-x-4">
<div className="bg-muted h-4 w-4 animate-pulse rounded-full" />
<div className="flex-1 space-y-2">
<div className="bg-muted h-4 w-3/4 animate-pulse rounded" />
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
</div>
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
))}
</div>
) : activities.length === 0 ? (
<div className="py-8 text-center">
<AlertCircle className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No recent activity
</p>
</div>
) : (
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center space-x-4">
{getStatusIcon(activity.status)}
<div className="flex-1 space-y-1">
<p className="text-sm leading-none font-medium">
{activity.title}
</p>
<p className="text-muted-foreground text-sm">
{activity.description}
</p>
</div>
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(activity.time, { addSuffix: true })}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// Quick Actions Component
function QuickActions() {
const actions = [
{
title: "Start New Trial",
description: "Begin a new experimental trial",
href: "/dashboard/trials/new",
icon: TestTube,
color: "bg-blue-500 hover:bg-blue-600",
},
{
title: "Add Participant",
description: "Enroll a new participant",
href: "/dashboard/participants/new",
icon: Users,
color: "bg-green-500 hover:bg-green-600",
},
{
title: "Create Experiment",
description: "Design new experiment protocol",
href: "/dashboard/experiments/new",
icon: FlaskConical,
color: "bg-purple-500 hover:bg-purple-600",
},
{
title: "View Analytics",
description: "Analyze research data",
href: "/dashboard/analytics",
icon: BarChart3,
color: "bg-orange-500 hover:bg-orange-600",
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{actions.map((action) => (
<Card
key={action.title}
className="group cursor-pointer transition-all hover:shadow-md"
>
<CardContent className="p-6">
<Button asChild className={`w-full ${action.color} text-white`}>
<Link href={action.href}>
<action.icon className="mr-2 h-4 w-4" />
{action.title}
</Link>
</Button>
<p className="text-muted-foreground mt-2 text-sm">
{action.description}
</p>
</CardContent>
</Card>
))}
</div>
);
}
// Study Progress Component
function StudyProgress() {
const { data: studies = [], isLoading } =
api.dashboard.getStudyProgress.useQuery({
limit: 5,
});
return (
<Card className="col-span-3">
<CardHeader>
<CardTitle>Study Progress</CardTitle>
<CardDescription>
Current status of active research studies
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
</div>
<div className="bg-muted h-5 w-16 animate-pulse rounded" />
</div>
<div className="bg-muted h-2 w-full animate-pulse rounded" />
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
))}
</div>
) : studies.length === 0 ? (
<div className="py-8 text-center">
<Building className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No active studies found
</p>
<p className="text-muted-foreground text-xs">
Create a study to get started
</p>
</div>
) : (
<div className="space-y-6">
{studies.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{study.name}
</p>
<p className="text-muted-foreground text-sm">
{study.participants}/{study.totalParticipants} completed
trials
</p>
</div>
<Badge
variant={
study.status === "active" ? "default" : "secondary"
}
>
{study.status}
</Badge>
</div>
<Progress value={study.progress} className="h-2" />
<p className="text-muted-foreground text-xs">
{study.progress}% complete
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
export default function DashboardPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome to your HRI Studio research platform
</p>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
<Calendar className="mr-1 h-3 w-3" />
{new Date().toLocaleDateString()}
</Badge>
</div>
</div>
{/* Overview Cards */}
<OverviewCards />
{/* Main Content Grid */}
<div className="grid gap-4 lg:grid-cols-7">
<StudyProgress />
<div className="col-span-4 space-y-4">
<RecentActivity />
</div>
</div>
{/* Quick Actions */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Quick Actions</h2>
<QuickActions />
</div>
</div>
);
}

View File

@@ -1,15 +0,0 @@
import { ParticipantForm } from "~/components/participants/ParticipantForm";
interface EditParticipantPageProps {
params: Promise<{
id: string;
}>;
}
export default async function EditParticipantPage({
params,
}: EditParticipantPageProps) {
const { id } = await params;
return <ParticipantForm mode="edit" participantId={id} />;
}

View File

@@ -1,443 +0,0 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle,
Calendar,
CheckCircle,
Edit,
Mail,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EntityViewSidebar,
EmptyState,
InfoGrid,
QuickActions,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
interface ParticipantDetailPageProps {
params: Promise<{
id: string;
}>;
}
export default function ParticipantDetailPage({
params,
}: ParticipantDetailPageProps) {
const { data: session } = useSession();
const [participant, setParticipant] = useState<{
id: string;
name: string | null;
email: string | null;
participantCode: string;
study: { id: string; name: string } | null;
demographics: unknown;
notes: string | null;
consentGiven: boolean;
consentDate: Date | null;
createdAt: Date;
updatedAt: Date;
studyId: string;
trials: unknown[];
consents: unknown[];
} | null>(null);
const [trials, setTrials] = useState<
{
id: string;
status: string;
createdAt: Date;
duration: number | null;
experiment: { name: string } | null;
}[]
>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
async function resolveParams() {
const resolved = await params;
setResolvedParams(resolved);
}
void resolveParams();
}, [params]);
const { data: participantData } = api.participants.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: trialsData } = api.trials.list.useQuery(
{ participantId: resolvedParams?.id ?? "", limit: 10 },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (participantData) {
setParticipant(participantData);
}
if (trialsData) {
setTrials(trialsData);
}
if (participantData !== undefined) {
setLoading(false);
}
}, [participantData, trialsData]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Participants", href: "/participants" },
{
label: participant?.name ?? participant?.participantCode ?? "Participant",
},
]);
if (!session?.user) {
return notFound();
}
if (loading || !participant) {
return <div>Loading...</div>;
}
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
return (
<EntityView>
{/* Header */}
<EntityViewHeader
title={participant.name ?? participant.participantCode}
subtitle={
participant.name
? `Code: ${participant.participantCode}`
: "Participant"
}
icon="Users"
actions={
canEdit && (
<>
<Button variant="outline" asChild>
<Link href={`/participants/${resolvedParams?.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</>
)
}
/>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Participant Information */}
<EntityViewSection title="Participant Information" icon="FileText">
<InfoGrid
items={[
{
label: "Participant Code",
value: (
<code className="bg-muted rounded px-2 py-1 font-mono text-sm">
{participant.participantCode}
</code>
),
},
{
label: "Name",
value: participant?.name ?? "Not provided",
},
{
label: "Email",
value: participant?.email ? (
<div className="flex items-center gap-2">
<Mail className="h-4 w-4" />
<a
href={`mailto:${participant.email}`}
className="text-primary hover:underline"
>
{participant.email}
</a>
</div>
) : (
"Not provided"
),
},
{
label: "Study",
value: participant?.study ? (
<Link
href={`/studies/${participant.study.id}`}
className="text-primary hover:underline"
>
{participant.study.name}
</Link>
) : (
"No study assigned"
),
},
]}
/>
{/* Demographics */}
{participant?.demographics &&
typeof participant.demographics === "object" &&
participant.demographics !== null &&
Object.keys(participant.demographics as Record<string, unknown>)
.length > 0 ? (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-3 text-sm font-medium">
Demographics
</h4>
<InfoGrid
items={(() => {
const demo = participant.demographics as Record<
string,
unknown
>;
const items: Array<{ label: string; value: string }> = [];
if (demo.age) {
items.push({
label: "Age",
value:
typeof demo.age === "number"
? demo.age.toString()
: typeof demo.age === "string"
? demo.age
: "Unknown",
});
}
if (demo.gender) {
items.push({
label: "Gender",
value:
typeof demo.gender === "string"
? demo.gender
: "Unknown",
});
}
return items;
})()}
/>
</div>
) : null}
{/* Notes */}
{participant?.notes && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Notes
</h4>
<div className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
{participant.notes}
</div>
</div>
)}
</EntityViewSection>
{/* Trial History */}
<EntityViewSection
title="Trial History"
icon="Play"
description="Experimental sessions for this participant"
actions={
canEdit && (
<Button size="sm" asChild>
<Link
href={`/trials/new?participantId=${resolvedParams?.id}`}
>
Schedule Trial
</Link>
</Button>
)
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/trials/${trial.id}`}
className="font-medium hover:underline"
>
{trial.experiment?.name ?? "Trial"}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{trial.createdAt
? formatDistanceToNow(new Date(trial.createdAt), {
addSuffix: true,
})
: "Not scheduled"}
</span>
{trial.duration && (
<span>{Math.round(trial.duration / 60)} min</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No Trials Yet"
description="This participant hasn't been assigned to any trials."
action={
canEdit && (
<Button asChild>
<Link
href={`/trials/new?participantId=${resolvedParams?.id}`}
>
Schedule First Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
{/* Sidebar */}
<EntityViewSidebar>
{/* Consent Status */}
<EntityViewSection title="Consent Status" icon="Shield">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Informed Consent</span>
<Badge
variant={
participant?.consentGiven ? "default" : "destructive"
}
>
{participant?.consentGiven ? (
<>
<CheckCircle className="mr-1 h-3 w-3" />
Given
</>
) : (
<>
<XCircle className="mr-1 h-3 w-3" />
Not Given
</>
)}
</Badge>
</div>
{participant?.consentDate && (
<div className="text-muted-foreground text-sm">
Consented:{" "}
{formatDistanceToNow(new Date(participant.consentDate), {
addSuffix: true,
})}
</div>
)}
{!participant.consentGiven && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Consent required before trials can be conducted.
</AlertDescription>
</Alert>
)}
</div>
</EntityViewSection>
{/* Registration Details */}
<EntityViewSection title="Registration Details" icon="Calendar">
<InfoGrid
columns={1}
items={[
{
label: "Registered",
value: formatDistanceToNow(participant?.createdAt, {
addSuffix: true,
}),
},
...(participant.updatedAt &&
participant.updatedAt !== participant.createdAt
? [
{
label: "Last Updated",
value: formatDistanceToNow(participant.updatedAt, {
addSuffix: true,
}),
},
]
: []),
]}
/>
</EntityViewSection>
{/* Quick Actions */}
{canEdit && (
<EntityViewSection title="Quick Actions" icon="Edit">
<QuickActions
actions={[
{
label: "Schedule Trial",
icon: "Play",
href: `/trials/new?participantId=${resolvedParams?.id}`,
},
{
label: "Edit Information",
icon: "Edit",
href: `/participants/${resolvedParams?.id}/edit`,
},
{
label: "Export Data",
icon: "FileText",
href: `/participants/${resolvedParams?.id}/export`,
},
]}
/>
</EntityViewSection>
)}
</EntityViewSidebar>
</div>
</EntityView>
);
}

View File

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

View File

@@ -1,10 +1,65 @@
import { ParticipantsDataTable } from "~/components/participants/participants-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 { Users, 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 ParticipantsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study participants
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/participants`);
}
}, [selectedStudyId, router]);
export default function ParticipantsPage() {
return (
<StudyGuard>
<ParticipantsDataTable />
</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-green-50">
<Users className="h-8 w-8 text-green-500" />
</div>
<CardTitle className="text-2xl">Participants Moved</CardTitle>
<CardDescription>
Participant management is now organized by study for better
organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage participants:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s participants page</li>
<li> Add and manage participants 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>
);
}

View File

@@ -149,14 +149,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
const trials = trialsData ?? [];
const activities = activityData?.activities ?? [];
const completedTrials = trials.filter((trial: { status: string }) => trial.status === "completed").length;
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)}%` : "—",
completionRate:
totalTrials > 0
? `${Math.round((completedTrials / totalTrials) * 100)}%`
: "—",
};
return (
@@ -269,26 +274,27 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
experiment.status === "draft"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
? "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">
<p className="text-muted-foreground mt-1 text-sm">
{experiment.description}
</p>
)}
<div className="mt-2 flex items-center space-x-4 text-xs text-muted-foreground">
<div className="text-muted-foreground mt-2 flex items-center space-x-4 text-xs">
<span>
Created {formatDistanceToNow(experiment.createdAt, { addSuffix: true })}
Created{" "}
{formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
})}
</span>
{experiment.estimatedDuration && (
<span>
Est. {experiment.estimatedDuration} min
</span>
<span>Est. {experiment.estimatedDuration} min</span>
)}
</div>
</div>
@@ -299,9 +305,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}`}>
View
</Link>
<Link href={`/experiments/${experiment.id}`}>View</Link>
</Button>
</div>
</div>
@@ -327,19 +331,25 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
>
<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) ?? "?"}
{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"}
{activity.user?.name ??
activity.user?.email ??
"Unknown User"}
</p>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(activity.createdAt, { addSuffix: true })}
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(activity.createdAt, {
addSuffix: true,
})}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
<p className="text-muted-foreground mt-1 text-sm">
{activity.description}
</p>
</div>
@@ -347,7 +357,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
))}
{activityData && activityData.pagination.total > 5 && (
<div className="pt-2">
<Button asChild variant="outline" size="sm" className="w-full">
<Button
asChild
variant="outline"
size="sm"
className="w-full"
>
<Link href={`/studies/${study.id}/activity`}>
View All Activity ({activityData.pagination.total})
</Link>
@@ -434,17 +449,17 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
{
label: "Manage Participants",
icon: "Users",
href: `/participants?studyId=${study.id}`,
href: `/studies/${study.id}/participants`,
},
{
label: "Schedule Trials",
icon: "Calendar",
href: `/trials?studyId=${study.id}`,
href: `/studies/${study.id}/trials`,
},
{
label: "View Analytics",
icon: "BarChart3",
href: `/analytics?studyId=${study.id}`,
href: `/studies/${study.id}/analytics`,
},
]}
/>

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>
);
}