Consolidate all study-dependent routes and UI

- Remove global experiments and plugins routes; redirect to study-scoped
  pages
- Update sidebar navigation to separate platform-level and study-level
  items
- Add study filter to dashboard and stats queries
- Refactor participants, trials, analytics pages to use new header and
  breadcrumbs
- Update documentation for new route architecture and migration guide
- Remove duplicate experiment creation route
- Upgrade Next.js to 15.5.4 in package.json and bun.lock
This commit is contained in:
2025-09-24 13:41:29 -04:00
parent e0679f726e
commit cd7c657d5f
18 changed files with 961 additions and 775 deletions

View File

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

View File

@@ -1,10 +1,65 @@
import { ExperimentsDataTable } from "~/components/experiments/experiments-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 { FlaskConical, 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 ExperimentsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study experiments
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/experiments`);
}
}, [selectedStudyId, router]);
export default function ExperimentsPage() {
return (
<StudyGuard>
<ExperimentsDataTable />
</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-blue-50">
<FlaskConical className="h-8 w-8 text-blue-500" />
</div>
<CardTitle className="text-2xl">Experiments Moved</CardTitle>
<CardDescription>
Experiment 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 experiments:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s experiments page</li>
<li> Create and manage experiment protocols 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

@@ -1,5 +1,67 @@
import { PluginStoreBrowse } from "~/components/plugins/plugin-store-browse";
"use client";
export default function PluginStoreBrowsePage() {
return <PluginStoreBrowse />;
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowRight, Store } 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 PluginBrowseRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study plugin browse
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/plugins/browse`);
}
}, [selectedStudyId, router]);
return (
<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-purple-50">
<Store className="h-8 w-8 text-purple-500" />
</div>
<CardTitle className="text-2xl">Plugin Store Moved</CardTitle>
<CardDescription>
Plugin browsing is now organized by study for better robot
capability management.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To browse and install plugins:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s plugin store</li>
<li>
Browse and install robot capabilities 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

@@ -1,5 +1,68 @@
import { PluginsDataTable } from "~/components/plugins/plugins-data-table";
"use client";
export default function PluginsPage() {
return <PluginsDataTable />;
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Puzzle, 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 PluginsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study plugins
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/plugins`);
}
}, [selectedStudyId, router]);
return (
<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-purple-50">
<Puzzle className="h-8 w-8 text-purple-500" />
</div>
<CardTitle className="text-2xl">Plugins Moved</CardTitle>
<CardDescription>
Plugin management is now organized by study for better robot
capability management.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage plugins:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s plugins page</li>
<li>
Install and configure robot capabilities 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

@@ -1,22 +1,229 @@
import Link from "next/link";
"use client";
import { redirect } from "next/navigation";
import { PasswordChangeForm } from "~/components/profile/password-change-form";
import { ProfileEditForm } from "~/components/profile/profile-edit-form";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { formatRole, getRoleDescription } from "~/lib/auth-client";
import { auth } from "~/server/auth";
import { User, Shield, Download, Trash2, ExternalLink } from "lucide-react";
import { useSession } from "next-auth/react";
export default async function ProfilePage() {
const session = await auth();
interface ProfileUser {
id: string;
name: string | null;
email: string;
image: string | null;
roles?: Array<{
role: "administrator" | "researcher" | "wizard" | "observer";
grantedAt: string | Date;
}>;
}
function ProfileContent({ user }: { user: ProfileUser }) {
return (
<div className="space-y-6">
<PageHeader
title="Profile"
description="Manage your account settings and preferences"
icon={User}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Profile Information */}
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
Your personal account information
</CardDescription>
</CardHeader>
<CardContent>
<ProfileEditForm
user={{
id: user.id,
name: user.name,
email: user.email,
image: user.image,
}}
/>
</CardContent>
</Card>
{/* Password Change */}
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>Change your account password</CardDescription>
</CardHeader>
<CardContent>
<PasswordChangeForm />
</CardContent>
</Card>
{/* Account Actions */}
<Card>
<CardHeader>
<CardTitle>Account Actions</CardTitle>
<CardDescription>Manage your account settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">Export Data</h4>
<p className="text-muted-foreground text-sm">
Download all your research data and account information
</p>
</div>
<Button variant="outline" disabled>
<Download className="mr-2 h-4 w-4" />
Export Data
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<h4 className="text-destructive text-sm font-medium">
Delete Account
</h4>
<p className="text-muted-foreground text-sm">
Permanently delete your account and all associated data
</p>
</div>
<Button variant="destructive" disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* User Summary */}
<Card>
<CardHeader>
<CardTitle>Account Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<span className="text-primary text-lg font-semibold">
{(user.name ?? user.email ?? "U").charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium">{user.name ?? "Unnamed User"}</p>
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
</div>
<Separator />
<div>
<p className="mb-2 text-sm font-medium">User ID</p>
<p className="text-muted-foreground bg-muted rounded p-2 font-mono text-xs break-all">
{user.id}
</p>
</div>
</CardContent>
</Card>
{/* System Roles */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
System Roles
</CardTitle>
<CardDescription>Your current system permissions</CardDescription>
</CardHeader>
<CardContent>
{user.roles && user.roles.length > 0 ? (
<div className="space-y-3">
{user.roles.map((roleInfo, index: number) => (
<div
key={index}
className="flex items-start justify-between"
>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge variant="secondary">
{formatRole(roleInfo.role)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{getRoleDescription(roleInfo.role)}
</p>
<p className="text-muted-foreground/80 mt-1 text-xs">
Granted{" "}
{new Date(roleInfo.grantedAt).toLocaleDateString()}
</p>
</div>
</div>
))}
<Separator />
<div className="text-center">
<p className="text-muted-foreground text-xs">
Need additional permissions?{" "}
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs"
>
Contact an administrator
<ExternalLink className="ml-1 h-3 w-3" />
</Button>
</p>
</div>
</div>
) : (
<div className="py-6 text-center">
<div className="bg-muted mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg">
<Shield className="text-muted-foreground h-6 w-6" />
</div>
<p className="mb-1 text-sm font-medium">No Roles Assigned</p>
<p className="text-muted-foreground text-xs">
You don&apos;t have any system roles yet. Contact an
administrator to get access to HRIStudio features.
</p>
<Button size="sm" variant="outline">
Request Access
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default function ProfilePage() {
const { data: session } = useSession();
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Profile" },
]);
if (!session?.user) {
redirect("/auth/signin");
@@ -24,295 +231,5 @@ export default async function ProfilePage() {
const user = session.user;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900">Profile</h1>
<p className="text-slate-600">
Manage your account settings and preferences
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-600">
Welcome, {user.name ?? user.email}
</span>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/auth/signout">Sign Out</Link>
</Button>
<Button asChild variant="outline">
<Link href="/"> Back to Home</Link>
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Profile Information */}
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
Your personal account information
</CardDescription>
</CardHeader>
<CardContent>
<ProfileEditForm
user={{
id: user.id,
name: user.name,
email: user.email,
image: user.image,
}}
/>
</CardContent>
</Card>
{/* Password Change */}
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>Change your account password</CardDescription>
</CardHeader>
<CardContent>
<PasswordChangeForm />
</CardContent>
</Card>
{/* Account Actions */}
<Card>
<CardHeader>
<CardTitle>Account Actions</CardTitle>
<CardDescription>Manage your account settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">Export Data</h4>
<p className="text-sm text-slate-600">
Download all your research data and account information
</p>
</div>
<Button variant="outline" disabled>
Export Data
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-red-700">
Delete Account
</h4>
<p className="text-sm text-slate-600">
Permanently delete your account and all associated data
</p>
</div>
<Button variant="destructive" disabled>
Delete Account
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* User Summary */}
<Card>
<CardHeader>
<CardTitle>Account Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
<span className="text-lg font-semibold text-blue-600">
{(user.name ?? user.email ?? "U").charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium">{user.name ?? "Unnamed User"}</p>
<p className="text-sm text-slate-600">{user.email}</p>
</div>
</div>
<Separator />
<div>
<p className="mb-2 text-sm font-medium">User ID</p>
<p className="rounded bg-slate-100 p-2 font-mono text-xs break-all text-slate-600">
{user.id}
</p>
</div>
</CardContent>
</Card>
{/* System Roles */}
<Card>
<CardHeader>
<CardTitle>System Roles</CardTitle>
<CardDescription>
Your current system permissions
</CardDescription>
</CardHeader>
<CardContent>
{user.roles && user.roles.length > 0 ? (
<div className="space-y-3">
{user.roles.map((roleInfo, index) => (
<div
key={index}
className="flex items-start justify-between"
>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge variant="secondary">
{formatRole(roleInfo.role)}
</Badge>
</div>
<p className="text-xs text-slate-600">
{getRoleDescription(roleInfo.role)}
</p>
<p className="mt-1 text-xs text-slate-500">
Granted {roleInfo.grantedAt.toLocaleDateString()}
</p>
</div>
</div>
))}
<Separator />
<div className="text-center">
<p className="text-xs text-slate-500">
Need additional permissions?{" "}
<Link
href="/contact"
className="text-blue-600 hover:text-blue-500"
>
Contact an administrator
</Link>
</p>
</div>
</div>
) : (
<div className="py-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-yellow-100">
<svg
className="h-6 w-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<p className="mb-1 text-sm font-medium text-slate-900">
No Roles Assigned
</p>
<p className="mb-3 text-xs text-slate-600">
You don&apos;t have any system roles yet. Contact an
administrator to get access to HRIStudio features.
</p>
<Button asChild size="sm" variant="outline">
<Link href="/contact">Request Access</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
asChild
variant="outline"
className="w-full justify-start"
>
<Link href="/studies">
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
My Studies
</Link>
</Button>
<Button
asChild
variant="outline"
className="w-full justify-start"
disabled
>
<Link href="/experiments">
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
Experiments
</Link>
</Button>
<Button
asChild
variant="outline"
className="w-full justify-start"
disabled
>
<Link href="/wizard">
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
Wizard Interface
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
return <ProfileContent user={user} />;
}

View File

@@ -27,7 +27,8 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { ManagementPageLayout } from "~/components/ui/page-layout";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
@@ -303,6 +304,14 @@ export default function StudyAnalyticsPage() {
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Analytics" },
]);
// Set the active study if it doesn't match the current route
useEffect(() => {
if (studyId && selectedStudyId !== studyId) {
@@ -311,19 +320,16 @@ export default function StudyAnalyticsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]);
return (
<ManagementPageLayout
title="Analytics"
description="Insights and data analysis for this study"
breadcrumb={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Analytics" },
]}
>
<div className="space-y-6">
<PageHeader
title="Analytics"
description="Insights and data analysis for this study"
icon={BarChart3}
/>
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsContent studyId={studyId} />
</Suspense>
</ManagementPageLayout>
</div>
);
}

View File

@@ -2,8 +2,11 @@
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import { Users, Plus } from "lucide-react";
import { ParticipantsTable } from "~/components/participants/ParticipantsTable";
import { ManagementPageLayout } from "~/components/ui/page-layout";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
@@ -13,6 +16,14 @@ export default function StudyParticipantsPage() {
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Participants" },
]);
// Sync selected study (unified study-context)
useEffect(() => {
if (studyId && selectedStudyId !== studyId) {
@@ -21,23 +32,24 @@ export default function StudyParticipantsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]);
return (
<ManagementPageLayout
title="Participants"
description="Manage participant registration, consent, and trial assignments for this study"
breadcrumb={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Participants" },
]}
createButton={{
label: "Add Participant",
href: `/studies/${studyId}/participants/new`,
}}
>
<div className="space-y-6">
<PageHeader
title="Participants"
description="Manage participant registration, consent, and trial assignments for this study"
icon={Users}
actions={
<Button asChild>
<a href={`/studies/${studyId}/participants/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Participant
</a>
</Button>
}
/>
<Suspense fallback={<div>Loading participants...</div>}>
<ParticipantsTable studyId={studyId} />
</Suspense>
</ManagementPageLayout>
</div>
);
}

View File

@@ -2,8 +2,11 @@
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import { TestTube, Plus } from "lucide-react";
import { TrialsTable } from "~/components/trials/TrialsTable";
import { ManagementPageLayout } from "~/components/ui/page-layout";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
@@ -13,6 +16,14 @@ export default function StudyTrialsPage() {
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials" },
]);
// Set the active study if it doesn't match the current route
useEffect(() => {
if (studyId && selectedStudyId !== studyId) {
@@ -21,23 +32,24 @@ export default function StudyTrialsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]);
return (
<ManagementPageLayout
title="Trials"
description="Schedule, execute, and monitor HRI experiment trials with real-time wizard control for this study"
breadcrumb={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials" },
]}
createButton={{
label: "Schedule Trial",
href: `/studies/${studyId}/trials/new`,
}}
>
<div className="space-y-6">
<PageHeader
title="Trials"
description="Manage trial execution, scheduling, and data collection for this study"
icon={TestTube}
actions={
<Button asChild>
<a href={`/studies/${studyId}/trials/new`}>
<Plus className="mr-2 h-4 w-4" />
Schedule Trial
</a>
</Button>
}
/>
<Suspense fallback={<div>Loading trials...</div>}>
<TrialsTable studyId={studyId} />
</Suspense>
</ManagementPageLayout>
</div>
);
}

View File

@@ -24,11 +24,20 @@ import {
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { api } from "~/trpc/react";
// Dashboard Overview Cards
function OverviewCards() {
const { data: stats, isLoading } = api.dashboard.getStats.useQuery();
function OverviewCards({ studyFilter }: { studyFilter: string | null }) {
const { data: stats, isLoading } = api.dashboard.getStats.useQuery({
studyId: studyFilter ?? undefined,
});
const cards = [
{
@@ -105,10 +114,11 @@ function OverviewCards() {
}
// Recent Activity Component
function RecentActivity() {
function RecentActivity({ studyFilter }: { studyFilter: string | null }) {
const { data: activities = [], isLoading } =
api.dashboard.getRecentActivity.useQuery({
limit: 8,
studyId: studyFilter ?? undefined,
});
const getStatusIcon = (status: string) => {
@@ -236,10 +246,11 @@ function QuickActions() {
}
// Study Progress Component
function StudyProgress() {
function StudyProgress({ studyFilter }: { studyFilter: string | null }) {
const { data: studies = [], isLoading } =
api.dashboard.getStudyProgress.useQuery({
limit: 5,
studyId: studyFilter ?? undefined,
});
return (
@@ -313,17 +324,59 @@ function StudyProgress() {
}
export default function DashboardPage() {
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
// Get user studies for filter dropdown
const { data: userStudiesData } = api.studies.list.useQuery({
memberOnly: true,
limit: 100,
});
const userStudies = userStudiesData?.studies ?? [];
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>
<h1 className="text-3xl font-bold tracking-tight">
Dashboard
{studyFilter && (
<Badge variant="secondary" className="ml-2">
{userStudies.find((s) => s.id === studyFilter)?.name}
</Badge>
)}
</h1>
<p className="text-muted-foreground">
Welcome to your HRI Studio research platform
{studyFilter
? "Study-specific dashboard view"
: "Welcome to your HRI Studio research platform"}
</p>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-muted-foreground text-sm">
Filter by study:
</span>
<Select
value={studyFilter ?? "all"}
onValueChange={(value) =>
setStudyFilter(value === "all" ? null : value)
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Studies" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Studies</SelectItem>
{userStudies.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Badge variant="outline" className="text-xs">
<Calendar className="mr-1 h-3 w-3" />
{new Date().toLocaleDateString()}
@@ -332,13 +385,13 @@ export default function DashboardPage() {
</div>
{/* Overview Cards */}
<OverviewCards />
<OverviewCards studyFilter={studyFilter} />
{/* Main Content Grid */}
<div className="grid gap-4 lg:grid-cols-7">
<StudyProgress />
<StudyProgress studyFilter={studyFilter} />
<div className="col-span-4 space-y-4">
<RecentActivity />
<RecentActivity studyFilter={studyFilter} />
</div>
</div>

View File

@@ -4,6 +4,7 @@ import React, { useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { signOut, useSession } from "next-auth/react";
import { toast } from "sonner";
import {
BarChart3,
Building,
@@ -14,7 +15,10 @@ import {
MoreHorizontal,
Puzzle,
Settings,
TestTube,
User,
UserCheck,
Users,
} from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
@@ -53,8 +57,8 @@ import { useStudyManagement } from "~/hooks/useStudyManagement";
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
import { api } from "~/trpc/react";
// Navigation items
const navigationItems = [
// Global items - always available
const globalItems = [
{
title: "Overview",
url: "/dashboard",
@@ -65,22 +69,40 @@ const navigationItems = [
url: "/studies",
icon: Building,
},
{
title: "Profile",
url: "/profile",
icon: User,
},
];
// Current Study Work section - only shown when study is selected
const studyWorkItems = [
{
title: "Participants",
url: "/participants",
icon: Users,
},
{
title: "Trials",
url: "/trials",
icon: TestTube,
},
{
title: "Experiments",
url: "/experiments",
icon: FlaskConical,
},
{
title: "Plugins",
url: "/plugins",
icon: Puzzle,
},
{
title: "Analytics",
url: "/analytics",
icon: BarChart3,
},
{
title: "Plugins",
url: "/plugins",
icon: Puzzle,
},
];
const adminItems = [
@@ -118,15 +140,13 @@ export function AppSidebar({
name: string;
};
// Filter navigation items based on study selection
const availableNavigationItems = navigationItems.filter((item) => {
// These items are always available
if (item.url === "/dashboard" || item.url === "/studies") {
return true;
}
// These items require a selected study
return selectedStudyId !== null;
});
// Build study work items with proper URLs when study is selected
const studyWorkItemsWithUrls = selectedStudyId
? studyWorkItems.map((item) => ({
...item,
url: `/studies/${selectedStudyId}${item.url}`,
}))
: [];
const handleSignOut = async () => {
await signOut({ callbackUrl: "/" });
@@ -147,6 +167,25 @@ export function AppSidebar({
}
};
const handleClearStudy = async (event: React.MouseEvent) => {
try {
event.preventDefault();
event.stopPropagation();
console.log("Clearing study selection...");
await selectStudy(null);
console.log("Study selection cleared successfully");
toast.success("Study selection cleared");
} catch (error) {
console.error("Failed to clear study:", error);
// Handle auth errors first
if (isAuthError(error)) {
await handleAuthError(error, "Session expired while clearing study");
return;
}
toast.error("Failed to clear study selection");
}
};
const selectedStudy = userStudies.find(
(study: Study) => study.id === selectedStudyId,
);
@@ -248,11 +287,7 @@ export function AppSidebar({
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<DropdownMenuItem onClick={handleClearStudy}>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
@@ -301,11 +336,7 @@ export function AppSidebar({
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<DropdownMenuItem onClick={handleClearStudy}>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
@@ -325,11 +356,12 @@ export function AppSidebar({
</SidebarGroup>
{/* Main Navigation */}
{/* Global Section */}
<SidebarGroup>
<SidebarGroupLabel>Research</SidebarGroupLabel>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{availableNavigationItems.map((item) => {
{globalItems.map((item) => {
const isActive =
pathname === item.url ||
(item.url !== "/dashboard" && pathname.startsWith(item.url));
@@ -364,16 +396,61 @@ export function AppSidebar({
</SidebarGroupContent>
</SidebarGroup>
{/* Study-specific items hint */}
{!selectedStudyId && !isCollapsed && (
{/* Current Study Work Section */}
{selectedStudyId && selectedStudy ? (
<SidebarGroup>
<SidebarGroupLabel>Current Study Work</SidebarGroupLabel>
<SidebarGroupContent>
<div className="text-muted-foreground px-3 py-2 text-xs">
Select a study to access experiments, participants, trials, and
analytics.
</div>
<SidebarMenu>
{studyWorkItemsWithUrls.map((item) => {
const isActive =
pathname === item.url ||
(item.url !== "/dashboard" &&
pathname.startsWith(item.url));
const menuButton = (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return (
<SidebarMenuItem key={item.title}>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{menuButton}
</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{item.title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
menuButton
)}
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : (
!isCollapsed && (
<SidebarGroup>
<SidebarGroupLabel>Current Study Work</SidebarGroupLabel>
<SidebarGroupContent>
<div className="text-muted-foreground px-3 py-2 text-xs">
Select a study to access participants, trials, experiments,
and analytics.
</div>
</SidebarGroupContent>
</SidebarGroup>
)
)}
{/* Admin Section */}

View File

@@ -1,12 +1,9 @@
"use client";
import { FlaskConical, Plus } from "lucide-react";
import React from "react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import { ActionButton, PageHeader } from "~/components/ui/page-header";
import {
Select,
SelectContent,
@@ -50,21 +47,6 @@ export function ExperimentsDataTable() {
return () => clearInterval(interval);
}, [refetch, selectedStudyId]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId
? [
{
label: "Experiments",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments" },
]
: [{ label: "Experiments" }]),
]);
// Transform experiments data (already filtered by studyId) to match columns
const experiments: Experiment[] = React.useMemo(() => {
if (!experimentsData) return [];
@@ -149,61 +131,34 @@ export function ExperimentsDataTable() {
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Experiments"
description="Design and manage experimental protocols for your HRI studies"
icon={FlaskConical}
actions={
<ActionButton href="/experiments/new">
<Plus className="mr-2 h-4 w-4" />
New Experiment
</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 Experiments
</h3>
<p className="mb-4">
{error.message ||
"An error occurred while loading your experiments."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
<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 Experiments
</h3>
<p className="mb-4">
{error.message ||
"An error occurred while loading your experiments."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Experiments"
description="Design and manage experimental protocols for your HRI studies"
icon={FlaskConical}
actions={
<ActionButton href="/experiments/new">
<Plus className="mr-2 h-4 w-4" />
New Experiment
</ActionButton>
}
<div className="space-y-4">
<DataTable
columns={columns}
data={filteredExperiments}
searchKey="name"
searchPlaceholder="Search experiments..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
<div className="space-y-4">
<DataTable
columns={columns}
data={filteredExperiments}
searchKey="name"
searchPlaceholder="Search experiments..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
"use client";
import { Plus, Puzzle } from "lucide-react";
import Link from "next/link";
import React from "react";
@@ -8,8 +7,6 @@ import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import { EmptyState } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { ActionButton, PageHeader } from "~/components/ui/page-header";
import {
Select,
SelectContent,
@@ -51,22 +48,6 @@ export function PluginsDataTable() {
}, [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: "Plugins" },
]
: [{ label: "Plugins" }]),
]);
// Transform plugins data to match the Plugin type expected by columns
const plugins: Plugin[] = React.useMemo(() => {
@@ -135,53 +116,31 @@ export function PluginsDataTable() {
// Show message if no study is selected
if (!selectedStudyId) {
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
/>
<EmptyState
icon="Building"
title="No Study Selected"
description="Please select a study from the sidebar to view and manage plugins."
action={
<Button asChild>
<Link href="/studies">Select Study</Link>
</Button>
}
/>
</div>
<EmptyState
icon="Building"
title="No Study Selected"
description="Please select a study from the sidebar to view and manage plugins."
action={
<Button asChild>
<Link href="/studies">Select Study</Link>
</Button>
}
/>
);
}
// Show error state
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
actions={
<ActionButton href="/plugins/browse">
<Plus className="mr-2 h-4 w-4" />
Browse Plugins
</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 Plugins
</h3>
<p className="mb-4">
{error.message || "An error occurred while loading plugins."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
<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 Plugins</h3>
<p className="mb-4">
{error.message || "An error occurred while loading plugins."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
);
@@ -190,58 +149,30 @@ export function PluginsDataTable() {
// Show empty state if no plugins
if (!isLoading && plugins.length === 0) {
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
actions={
<ActionButton href="/plugins/browse">
<Plus className="mr-2 h-4 w-4" />
Browse Plugins
</ActionButton>
}
/>
<EmptyState
icon="Puzzle"
title="No Plugins Installed"
description="Install plugins to extend robot capabilities for your experiments."
action={
<Button asChild>
<Link href="/plugins/browse">Browse Plugin Store</Link>
</Button>
}
/>
</div>
<EmptyState
icon="Puzzle"
title="No plugins installed"
description="Browse and install plugins to extend your robot's capabilities for this study."
action={
<Button asChild>
<Link href="/plugins/browse">Browse Plugins</Link>
</Button>
}
/>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
actions={
<ActionButton href="/plugins/browse">
<Plus className="mr-2 h-4 w-4" />
Browse Plugins
</ActionButton>
}
<div className="space-y-4">
<DataTable
columns={pluginsColumns}
data={filteredPlugins}
searchKey="name"
searchPlaceholder="Search plugins..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
<div className="space-y-4">
{/* Data Table */}
<DataTable
columns={pluginsColumns}
data={filteredPlugins}
searchKey="name"
searchPlaceholder="Search plugins..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}

View File

@@ -3,14 +3,7 @@ import { type LucideIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import type { BreadcrumbItem } from "~/components/ui/breadcrumb";
interface BreadcrumbItem {
label: string;
@@ -83,7 +76,7 @@ export function PageLayout({
description,
userName: _userName,
userRole: _userRole,
breadcrumb,
breadcrumb: _breadcrumb,
createButton,
quickActions,
stats,
@@ -92,28 +85,6 @@ export function PageLayout({
}: PageLayoutProps) {
return (
<div className={cn("space-y-6", className)}>
{/* Breadcrumb */}
{breadcrumb && breadcrumb.length > 0 && (
<Breadcrumb>
<BreadcrumbList>
{breadcrumb.map((item, index) => (
<div key={index} className="flex items-center">
{index > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{item.href ? (
<BreadcrumbLink href={item.href}>
{item.label}
</BreadcrumbLink>
) : (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</div>
))}
</BreadcrumbList>
</Breadcrumb>
)}
{/* Header */}
{title && (
<div className="flex items-center justify-between">
@@ -260,26 +231,37 @@ export const DetailPageLayout = PageLayout;
export const FormPageLayout = PageLayout;
// Simple components for basic usage
interface SimplePageHeaderProps {
interface PageHeaderProps {
title: string;
description?: string;
children?: ReactNode;
icon?: LucideIcon;
actions?: ReactNode;
className?: string;
}
export function PageHeader({
title,
description,
children,
icon: Icon,
actions,
className,
}: SimplePageHeaderProps) {
}: PageHeaderProps) {
return (
<div className={cn("flex items-center justify-between", className)}>
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
{description && <p className="text-muted-foreground">{description}</p>}
<div className="flex items-center gap-3">
{Icon && (
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Icon className="text-primary h-6 w-6" />
</div>
)}
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
</div>
{children && <div>{children}</div>}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

View File

@@ -78,11 +78,21 @@ export const dashboardRouter = createTRPCRouter({
.input(
z.object({
limit: z.number().min(1).max(10).default(5),
studyId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Build where conditions
const whereConditions = input.studyId
? and(
eq(studyMembers.userId, userId),
eq(studies.status, "active"),
eq(studies.id, input.studyId),
)
: and(eq(studyMembers.userId, userId), eq(studies.status, "active"));
// Get studies the user has access to with participant counts
const studyProgress = await ctx.db
.select({
@@ -95,9 +105,7 @@ export const dashboardRouter = createTRPCRouter({
.from(studies)
.innerJoin(studyMembers, eq(studies.id, studyMembers.studyId))
.leftJoin(participants, eq(studies.id, participants.studyId))
.where(
and(eq(studyMembers.userId, userId), eq(studies.status, "active")),
)
.where(whereConditions)
.groupBy(studies.id, studies.name, studies.status, studies.createdAt)
.orderBy(desc(studies.createdAt))
.limit(input.limit);
@@ -152,101 +160,118 @@ export const dashboardRouter = createTRPCRouter({
});
}),
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
getStats: protectedProcedure
.input(
z.object({
studyId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Get studies the user has access to
const accessibleStudies = await ctx.db
.select({ studyId: studyMembers.studyId })
.from(studyMembers)
.where(eq(studyMembers.userId, userId));
// Get studies the user has access to
const accessibleStudies = await ctx.db
.select({ studyId: studyMembers.studyId })
.from(studyMembers)
.where(eq(studyMembers.userId, userId));
const studyIds = accessibleStudies.map((s) => s.studyId);
let studyIds = accessibleStudies.map((s) => s.studyId);
// Filter to specific study if provided
if (input.studyId) {
// Verify user has access to the specific study
if (studyIds.includes(input.studyId)) {
studyIds = [input.studyId];
} else {
// User doesn't have access to this study
studyIds = [];
}
}
if (studyIds.length === 0) {
return {
totalStudies: 0,
totalExperiments: 0,
totalParticipants: 0,
totalTrials: 0,
activeTrials: 0,
scheduledTrials: 0,
completedToday: 0,
};
}
// Get total counts
const [studyCount] = await ctx.db
.select({ count: count() })
.from(studies)
.where(inArray(studies.id, studyIds));
const [experimentCount] = await ctx.db
.select({ count: count() })
.from(experiments)
.where(inArray(experiments.studyId, studyIds));
const [participantCount] = await ctx.db
.select({ count: count() })
.from(participants)
.where(inArray(participants.studyId, studyIds));
const [trialCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(inArray(experiments.studyId, studyIds));
// Get active trials count
const [activeTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "in_progress"),
),
);
// Get scheduled trials count
const [scheduledTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "scheduled"),
),
);
// Get today's completed trials
const today = new Date();
today.setHours(0, 0, 0, 0);
const [completedTodayCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "completed"),
gte(trials.completedAt, today),
),
);
if (studyIds.length === 0) {
return {
totalStudies: 0,
totalExperiments: 0,
totalParticipants: 0,
totalTrials: 0,
activeTrials: 0,
scheduledTrials: 0,
completedToday: 0,
totalStudies: studyCount?.count ?? 0,
totalExperiments: experimentCount?.count ?? 0,
totalParticipants: participantCount?.count ?? 0,
totalTrials: trialCount?.count ?? 0,
activeTrials: activeTrialsCount?.count ?? 0,
scheduledTrials: scheduledTrialsCount?.count ?? 0,
completedToday: completedTodayCount?.count ?? 0,
};
}
// Get total counts
const [studyCount] = await ctx.db
.select({ count: count() })
.from(studies)
.where(inArray(studies.id, studyIds));
const [experimentCount] = await ctx.db
.select({ count: count() })
.from(experiments)
.where(inArray(experiments.studyId, studyIds));
const [participantCount] = await ctx.db
.select({ count: count() })
.from(participants)
.where(inArray(participants.studyId, studyIds));
const [trialCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(inArray(experiments.studyId, studyIds));
// Get active trials count
const [activeTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "in_progress"),
),
);
// Get scheduled trials count
const [scheduledTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "scheduled"),
),
);
// Get today's completed trials
const today = new Date();
today.setHours(0, 0, 0, 0);
const [completedTodayCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "completed"),
gte(trials.completedAt, today),
),
);
return {
totalStudies: studyCount?.count ?? 0,
totalExperiments: experimentCount?.count ?? 0,
totalParticipants: participantCount?.count ?? 0,
totalTrials: trialCount?.count ?? 0,
activeTrials: activeTrialsCount?.count ?? 0,
scheduledTrials: scheduledTrialsCount?.count ?? 0,
completedToday: completedTodayCount?.count ?? 0,
};
}),
}),
debug: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;