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

@@ -112,7 +112,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
: [{ label: "New Trial" }]),
]
: [
{ label: "Trials", href: "/trials" },
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
@@ -426,8 +426,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
mode={mode}
entityName="Trial"
entityNamePlural="Trials"
backUrl="/trials"
listUrl="/trials"
backUrl={`/studies/${contextStudyId}/trials`}
listUrl={`/studies/${contextStudyId}/trials`}
title={
mode === "create"
? "Schedule New Trial"

View File

@@ -1,572 +0,0 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { formatDistanceToNow } from "date-fns";
import {
BarChart3,
Copy,
Edit,
Eye,
FlaskConical,
MoreHorizontal,
Pause,
Play,
StopCircle,
TestTube,
Trash2,
User,
} from "lucide-react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
export type Trial = {
id: string;
name: string;
description: string | null;
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
studyId: string;
experimentId: string;
participantId: string;
wizardId: string | null;
study: {
id: string;
name: string;
};
experiment: {
id: string;
name: string;
};
participant: {
id: string;
name: string;
email: string;
participantCode?: string;
};
wizard: {
id: string;
name: string | null;
email: string;
} | null;
duration?: number; // in minutes
_count?: {
actions: number;
logs: number;
};
userRole?: "owner" | "researcher" | "wizard" | "observer";
canAccess?: boolean;
canEdit?: boolean;
canDelete?: boolean;
canExecute?: boolean;
};
const statusConfig = {
scheduled: {
label: "Scheduled",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
description: "Trial is scheduled for future execution",
},
in_progress: {
label: "In Progress",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
description: "Trial is currently running",
},
completed: {
label: "Completed",
className: "bg-green-100 text-green-800 hover:bg-green-200",
description: "Trial has been completed successfully",
},
aborted: {
label: "Aborted",
className: "bg-red-100 text-red-800 hover:bg-red-200",
description: "Trial was aborted before completion",
},
failed: {
label: "Failed",
className: "bg-red-100 text-red-800 hover:bg-red-200",
description: "Trial failed due to an error",
},
};
function TrialActionsCell({ trial }: { trial: Trial }) {
const startTrialMutation = api.trials.start.useMutation();
const completeTrialMutation = api.trials.complete.useMutation();
const abortTrialMutation = api.trials.abort.useMutation();
// const deleteTrialMutation = api.trials.delete.useMutation();
const handleDelete = async () => {
if (
window.confirm(`Are you sure you want to delete trial "${trial.name}"?`)
) {
try {
// await deleteTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial deletion not yet implemented");
// window.location.reload();
} catch {
toast.error("Failed to delete trial");
}
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(trial.id);
toast.success("Trial ID copied to clipboard");
};
const handleStartTrial = async () => {
try {
await startTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial started successfully");
window.location.href = `/trials/${trial.id}/wizard`;
} catch {
toast.error("Failed to start trial");
}
};
const handlePauseTrial = async () => {
try {
// For now, pausing means completing the trial
await completeTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial paused/completed");
window.location.reload();
} catch {
toast.error("Failed to pause trial");
}
};
const handleStopTrial = async () => {
if (window.confirm("Are you sure you want to stop this trial?")) {
try {
await abortTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial stopped");
window.location.reload();
} catch {
toast.error("Failed to stop trial");
}
}
};
const canStart = trial.status === "scheduled" && trial.canExecute;
const canPause = trial.status === "in_progress" && trial.canExecute;
const canStop = trial.status === "in_progress" && trial.canExecute;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
{trial.canAccess ? (
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
<Eye className="mr-2 h-4 w-4" />
View Details (Restricted)
</DropdownMenuItem>
)}
{trial.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Trial
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{canStart && (
<DropdownMenuItem onClick={handleStartTrial}>
<Play className="mr-2 h-4 w-4" />
Start Trial
</DropdownMenuItem>
)}
{canPause && (
<DropdownMenuItem onClick={handlePauseTrial}>
<Pause className="mr-2 h-4 w-4" />
Pause Trial
</DropdownMenuItem>
)}
{canStop && (
<DropdownMenuItem
onClick={handleStopTrial}
className="text-orange-600 focus:text-orange-600"
>
<StopCircle className="mr-2 h-4 w-4" />
Stop Trial
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}/wizard`}>
<TestTube className="mr-2 h-4 w-4" />
Wizard Interface
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}/analysis`}>
<BarChart3 className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Trial ID
</DropdownMenuItem>
{trial.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Trial
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const trialsColumns: ColumnDef<Trial>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Trial Name" />
),
cell: ({ row }) => {
const trial = row.original;
return (
<div className="max-w-[140px] min-w-0">
<div className="flex items-center gap-2">
{trial.canAccess ? (
<Link
href={`/trials/${trial.id}`}
className="block truncate font-medium hover:underline"
title={trial.name}
>
{trial.name}
</Link>
) : (
<div
className="text-muted-foreground block cursor-not-allowed truncate font-medium"
title={`${trial.name} (View access restricted)`}
>
{trial.name}
</div>
)}
{!trial.canAccess && (
<Badge
variant="outline"
className="ml-auto shrink-0 border-amber-200 bg-amber-50 text-amber-700"
title={`Access restricted - You are an ${trial.userRole ?? "observer"} on this study`}
>
{trial.userRole === "observer" ? "View Only" : "Restricted"}
</Badge>
)}
</div>
</div>
);
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = row.getValue("status");
const trial = row.original;
const config = statusConfig[status as keyof typeof statusConfig];
return (
<div className="flex flex-col gap-1">
<Badge
variant="secondary"
className={`${config.className} whitespace-nowrap`}
title={config.description}
>
{config.label}
</Badge>
{trial.userRole && (
<Badge
variant="outline"
className="text-xs"
title={`Your role in this study: ${trial.userRole}`}
>
{trial.userRole}
</Badge>
)}
</div>
);
},
filterFn: (row, id, value: string[]) => {
const status = row.getValue(id) as string; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
return value.includes(status);
},
},
{
accessorKey: "participant",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Participant" />
),
cell: ({ row }) => {
const participant = row.original.participant;
return (
<div className="max-w-[120px]">
<div className="flex items-center space-x-1">
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span
className="truncate text-sm font-medium"
title={
participant?.name ??
participant?.participantCode ??
"Unnamed Participant"
}
>
{participant?.name ??
participant?.participantCode ??
"Unnamed Participant"}
</span>
</div>
</div>
);
},
enableSorting: false,
},
{
accessorKey: "experiment",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Experiment" />
),
cell: ({ row }) => {
const experiment = row.original.experiment;
return (
<div className="flex max-w-[140px] items-center space-x-2">
<FlaskConical className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<Link
href={`/experiments/${experiment?.id ?? ""}`}
className="truncate text-sm hover:underline"
title={experiment?.name ?? "Unnamed Experiment"}
>
{experiment?.name ?? "Unnamed Experiment"}
</Link>
</div>
);
},
enableSorting: false,
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
accessorKey: "wizard",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Wizard" />
),
cell: ({ row }) => {
const wizard = row.original.wizard;
if (!wizard) {
return (
<span className="text-muted-foreground text-sm">Not assigned</span>
);
}
return (
<div className="max-w-[120px] space-y-1">
<div
className="truncate text-sm font-medium"
title={wizard.name ?? ""}
>
{wizard.name ?? ""}
</div>
<div
className="text-muted-foreground truncate text-xs"
title={wizard.email ?? ""}
>
{wizard.email ?? ""}
</div>
</div>
);
},
enableSorting: false,
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
accessorKey: "scheduledAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scheduled" />
),
cell: ({ row }) => {
const date = row.getValue("scheduledAt") as Date | null; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
if (!date) {
return (
<span className="text-muted-foreground text-sm">Not scheduled</span>
);
}
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
id: "duration",
header: "Duration",
cell: ({ row }) => {
const trial = row.original;
if (
trial.status === "completed" &&
trial.startedAt &&
trial.completedAt
) {
const duration = Math.round(
(trial.completedAt.getTime() - trial.startedAt.getTime()) /
(1000 * 60),
);
return <div className="text-sm whitespace-nowrap">{duration}m</div>;
}
if (trial.status === "in_progress" && trial.startedAt) {
const duration = Math.round(
(Date.now() - trial.startedAt.getTime()) / (1000 * 60),
);
return (
<div className="text-sm whitespace-nowrap text-blue-600">
{duration}m
</div>
);
}
if (trial.duration) {
return (
<div className="text-muted-foreground text-sm whitespace-nowrap">
~{trial.duration}m
</div>
);
}
return <span className="text-muted-foreground text-sm">-</span>;
},
enableSorting: false,
},
{
id: "stats",
header: "Data",
cell: ({ row }) => {
const trial = row.original;
const counts = trial._count;
return (
<div className="flex space-x-3 text-sm">
<div className="flex items-center space-x-1" title="Actions recorded">
<TestTube className="text-muted-foreground h-3 w-3" />
<span>{counts?.actions ?? 0}</span>
</div>
<div className="flex items-center space-x-1" title="Log entries">
<BarChart3 className="text-muted-foreground h-3 w-3" />
<span>{counts?.logs ?? 0}</span>
</div>
</div>
);
},
enableSorting: false,
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
enableHiding: true,
meta: {
defaultHidden: true,
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => <TrialActionsCell trial={row.original} />,
enableSorting: false,
enableHiding: false,
},
];

View File

@@ -1,271 +0,0 @@
"use client";
import React from "react";
import { Plus, TestTube, Eye } from "lucide-react";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { PageHeader, ActionButton } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { trialsColumns, type Trial } from "./trials-columns";
import { api } from "~/trpc/react";
export function TrialsDataTable() {
const [statusFilter, setStatusFilter] = React.useState("all");
const { selectedStudyId } = useStudyContext();
const {
data: trialsData,
isLoading,
error,
refetch,
} = api.trials.getUserTrials.useQuery(
{
page: 1,
limit: 50,
studyId: selectedStudyId ?? undefined,
status:
statusFilter === "all"
? undefined
: (statusFilter as
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed"),
},
{
refetchOnWindowFocus: false,
refetchInterval: 30000, // Refetch every 30 seconds for real-time updates
enabled: !!selectedStudyId, // Only fetch when a study is selected
},
);
// Auto-refresh trials when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void refetch();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refetch]);
// Get study data for breadcrumbs
const { data: studyData } = api.studies.get.useQuery(
{ id: selectedStudyId! },
{ enabled: !!selectedStudyId },
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId && studyData
? [
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
{ label: "Trials" },
]
: [{ label: "Trials" }]),
]);
// Transform trials data to match the Trial type expected by columns
const trials: Trial[] = React.useMemo(() => {
if (!trialsData?.trials) return [];
return trialsData.trials.map((trial) => ({
id: trial.id,
name: trial.notes
? `Trial: ${trial.notes}`
: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
description: trial.notes,
status: trial.status,
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : null,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
createdAt: trial.createdAt,
updatedAt: trial.updatedAt,
studyId: trial.experiment?.studyId ?? "",
experimentId: trial.experimentId,
participantId: trial.participantId ?? "",
wizardId: trial.wizardId,
study: {
id: trial.experiment?.studyId ?? "",
name: trial.experiment?.study?.name ?? "",
},
experiment: {
id: trial.experimentId,
name: trial.experiment?.name ?? "",
},
participant: {
id: trial.participantId ?? "",
name:
trial.participant?.name ?? trial.participant?.participantCode ?? "",
email: trial.participant?.email ?? "",
},
wizard: trial.wizard
? {
id: trial.wizard.id,
name: trial.wizard.name,
email: trial.wizard.email,
}
: null,
duration: trial.duration ? Math.round(trial.duration / 60) : undefined,
_count: {
actions: trial._count?.events ?? 0,
logs: trial._count?.mediaCaptures ?? 0,
},
userRole: trial.userRole,
canAccess: trial.canAccess ?? false,
canEdit:
trial.canAccess &&
(trial.status === "scheduled" || trial.status === "aborted"),
canDelete:
trial.canAccess &&
(trial.status === "scheduled" ||
trial.status === "aborted" ||
trial.status === "failed"),
canExecute:
trial.canAccess &&
(trial.status === "scheduled" || trial.status === "in_progress"),
}));
}, [trialsData]);
// Status filter options
const statusOptions = [
{ label: "All Statuses", value: "all" },
{ label: "Scheduled", value: "scheduled" },
{ label: "In Progress", value: "in_progress" },
{ label: "Completed", value: "completed" },
{ label: "Aborted", value: "aborted" },
{ label: "Failed", value: "failed" },
];
// Filter trials based on selected filters
const filteredTrials = React.useMemo(() => {
return trials.filter((trial) => {
const statusMatch =
statusFilter === "all" || trial.status === statusFilter;
return statusMatch;
});
}, [trials, statusFilter]);
const filters = (
<div className="flex items-center space-x-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Trials"
description="Schedule and manage trials for your HRI studies"
icon={TestTube}
actions={
<ActionButton
href={
selectedStudyId
? `/studies/${selectedStudyId}/trials/new`
: "/trials/new"
}
>
<Plus className="mr-2 h-4 w-4" />
Schedule Trial
</ActionButton>
}
/>
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<div className="text-red-800">
<h3 className="mb-2 text-lg font-semibold">
Failed to Load Trials
</h3>
<p className="mb-4">
{error.message || "An error occurred while loading your trials."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Trials"
description="Schedule and manage trials for your HRI studies"
icon={TestTube}
actions={
<ActionButton
href={
selectedStudyId
? `/studies/${selectedStudyId}/trials/new`
: "/trials/new"
}
>
<Plus className="mr-2 h-4 w-4" />
Schedule Trial
</ActionButton>
}
/>
<div className="space-y-4">
{filteredTrials.some((trial) => !trial.canAccess) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
<div className="rounded-full bg-amber-100 p-1">
<Eye className="h-4 w-4 text-amber-600" />
</div>
</div>
<div>
<h3 className="text-sm font-medium text-amber-800">
Limited Trial Access
</h3>
<p className="mt-1 text-sm text-amber-700">
Some trials are marked as &ldquo;View Only&rdquo; or
&ldquo;Restricted&rdquo; because you have observer-level
access to their studies. Only researchers, wizards, and study
owners can view detailed trial information.
</p>
</div>
</div>
</div>
)}
<DataTable
columns={trialsColumns}
data={filteredTrials}
searchKey="name"
searchPlaceholder="Search trials..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}

View File

@@ -13,7 +13,6 @@ import {
User,
Activity,
Zap,
Settings,
} from "lucide-react";
@@ -113,7 +112,7 @@ export function WizardInterface({
{ label: studyData.name, href: `/studies/${studyData.id}` },
{ label: "Trials", href: `/studies/${studyData.id}/trials` },
]
: [{ label: "Trials", href: "/trials" }]),
: []),
{
label: `Trial ${trial.participant.participantCode}`,
href: `/trials/${trial.id}`,