mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat(analytics): refine timeline visualization and add print support
This commit is contained in:
145
src/app/(dashboard)/help/page.tsx
Normal file
145
src/app/(dashboard)/help/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
BookOpen,
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
BarChart3,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Video,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageLayout } from "~/components/ui/page-layout";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HelpCenterPage() {
|
||||
const guides = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
description: "Learn the basics of HRIStudio and set up your first study.",
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{ label: "Platform Overview", href: "#" },
|
||||
{ label: "Creating a New Study", href: "#" },
|
||||
{ label: "Managing Team Members", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Designing Experiments",
|
||||
description: "Master the visual experiment designer and flow control.",
|
||||
icon: FlaskConical,
|
||||
items: [
|
||||
{ label: "Using the Visual Designer", href: "#" },
|
||||
{ label: "Robot Actions & Plugins", href: "#" },
|
||||
{ label: "Variables & Logic", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Running Trials",
|
||||
description: "Execute experiments and manage Wizard of Oz sessions.",
|
||||
icon: PlayCircle,
|
||||
items: [
|
||||
{ label: "Wizard Interface Guide", href: "#" },
|
||||
{ label: "Participant Management", href: "#" },
|
||||
{ label: "Handling Robot Errors", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Analysis & Data",
|
||||
description: "Analyze trial results and export research data.",
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{ label: "Understanding Analytics", href: "#" },
|
||||
{ label: "Exporting Data (CSV/JSON)", href: "#" },
|
||||
{ label: "Video Replay & Annotation", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Help Center"
|
||||
description="Documentation, guides, and support for HRIStudio researchers."
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{guides.map((guide, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<guide.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">{guide.title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{guide.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{guide.items.map((item, i) => (
|
||||
<li key={i}>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-foreground hover:text-primary justify-start font-normal"
|
||||
asChild
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<FileText className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-4">
|
||||
Video Tutorials
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
"Introduction to HRIStudio",
|
||||
"Advanced Flow Control",
|
||||
"ROS2 Integration Deep Dive",
|
||||
].map((title, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<div className="aspect-video bg-muted flex items-center justify-center relative group cursor-pointer hover:bg-muted/80 transition-colors">
|
||||
<PlayCircle className="h-12 w-12 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-muted/50 rounded-xl p-8 text-center border">
|
||||
<div className="mx-auto w-12 h-12 bg-background rounded-full flex items-center justify-center mb-4 shadow-sm">
|
||||
<HelpCircle className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Still need help?</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
Contact your system administrator or check the official documentation for technical support.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Official Docs
|
||||
</Button>
|
||||
<Button className="gap-2">Contact Support</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
|
||||
import { useActionRegistry } from "~/components/experiments/designer/ActionRegistry";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import type { ExperimentStep } from "~/lib/experiment-designer/types";
|
||||
|
||||
@@ -9,6 +11,10 @@ interface DesignerPageClientProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
studyId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,6 +34,22 @@ export function DesignerPageClient({
|
||||
experiment,
|
||||
initialDesign,
|
||||
}: DesignerPageClientProps) {
|
||||
// Initialize action registry early to prevent CLS
|
||||
useActionRegistry();
|
||||
|
||||
// Calculate design statistics
|
||||
const designStats = useMemo(() => {
|
||||
if (!initialDesign) return undefined;
|
||||
|
||||
const stepCount = initialDesign.steps.length;
|
||||
const actionCount = initialDesign.steps.reduce(
|
||||
(sum, step) => sum + step.actions.length,
|
||||
0
|
||||
);
|
||||
|
||||
return { stepCount, actionCount };
|
||||
}, [initialDesign]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
@@ -55,5 +77,12 @@ export function DesignerPageClient({
|
||||
},
|
||||
]);
|
||||
|
||||
return <DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />;
|
||||
return (
|
||||
<DesignerRoot
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
experiment={experiment}
|
||||
designStats={designStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,8 @@ export default async function ExperimentDesignerPage({
|
||||
};
|
||||
});
|
||||
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
||||
// Recursive function to hydrate actions with children
|
||||
const hydrateAction = (a: any): ExperimentAction => {
|
||||
// Normalize legacy plugin action ids and provenance
|
||||
const rawType = a.type ?? "";
|
||||
|
||||
@@ -188,11 +189,24 @@ export default async function ExperimentDesignerPage({
|
||||
const pluginId = legacy?.pluginId;
|
||||
const pluginVersion = legacy?.pluginVersion;
|
||||
|
||||
// Extract children from parameters for control flow actions
|
||||
const params = (a.parameters ?? {}) as Record<string, unknown>;
|
||||
let children: ExperimentAction[] | undefined = undefined;
|
||||
|
||||
// Handle control flow structures (sequence, parallel, loop only)
|
||||
// Branch actions control step routing, not nested actions
|
||||
const childrenRaw = params.children;
|
||||
|
||||
// Recursively hydrate nested children for container actions
|
||||
if (Array.isArray(childrenRaw) && childrenRaw.length > 0) {
|
||||
children = childrenRaw.map((child: any) => hydrateAction(child));
|
||||
}
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
type: typeOut,
|
||||
name: a.name,
|
||||
parameters: (a.parameters ?? {}) as Record<string, unknown>,
|
||||
parameters: params,
|
||||
category: categoryOut,
|
||||
source: {
|
||||
kind: sourceKind,
|
||||
@@ -202,8 +216,11 @@ export default async function ExperimentDesignerPage({
|
||||
baseActionId: legacy?.baseId,
|
||||
},
|
||||
execution,
|
||||
children, // Add children at top level
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => hydrateAction(a));
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type experiments, experimentStatusEnum } from "~/server/db/schema";
|
||||
import { type InferSelectModel } from "drizzle-orm";
|
||||
|
||||
type Experiment = InferSelectModel<typeof experiments>;
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2, {
|
||||
message: "Name must be at least 2 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(experimentStatusEnum.enumValues),
|
||||
});
|
||||
|
||||
interface ExperimentFormProps {
|
||||
experiment: Experiment;
|
||||
}
|
||||
|
||||
export function ExperimentForm({ experiment }: ExperimentFormProps) {
|
||||
const router = useRouter();
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment updated successfully");
|
||||
router.refresh();
|
||||
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error updating experiment: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
status: experiment.status,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
updateExperiment.mutate({
|
||||
id: experiment.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
status: values.status,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Experiment name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of your experiment.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe your experiment..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A short description of the experiment goals.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="testing">Testing</SelectItem>
|
||||
<SelectItem value="ready">Ready</SelectItem>
|
||||
<SelectItem value="deprecated">Deprecated</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The current status of the experiment.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" disabled={updateExperiment.isPending}>
|
||||
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { type experiments } from "~/server/db/schema";
|
||||
import { type InferSelectModel } from "drizzle-orm";
|
||||
|
||||
type Experiment = InferSelectModel<typeof experiments>;
|
||||
import { api } from "~/trpc/server";
|
||||
import { ExperimentForm } from "./experiment-form";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
interface ExperimentEditPageProps {
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
}
|
||||
|
||||
export default async function ExperimentEditPage({
|
||||
params,
|
||||
}: ExperimentEditPageProps) {
|
||||
const { id: studyId, experimentId } = await params;
|
||||
|
||||
const experiment = await api.experiments.get({ id: experimentId });
|
||||
|
||||
if (!experiment) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Ensure experiment belongs to study
|
||||
if (experiment.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Convert to type expected by form
|
||||
const experimentData: Experiment = {
|
||||
...experiment,
|
||||
status: experiment.status as Experiment["status"],
|
||||
};
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title="Edit Experiment"
|
||||
subtitle={`Update settings for ${experiment.name}`}
|
||||
icon="Edit"
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl">
|
||||
<EntityViewSection title="Experiment Details" icon="Settings">
|
||||
<ExperimentForm experiment={experimentData} />
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -195,12 +195,6 @@ export default function ExperimentDetailPage({
|
||||
actions={
|
||||
canEdit ? (
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
@@ -442,15 +436,9 @@ export default function ExperimentDetailPage({
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Download" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/export`,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit Experiment",
|
||||
icon: "Edit" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Logo } from "~/components/ui/logo";
|
||||
@@ -21,6 +22,7 @@ export default function SignInPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notRobot, setNotRobot] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -28,6 +30,12 @@ export default function SignInPage() {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
if (!notRobot) {
|
||||
setError("Please confirm you're not a robot");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
@@ -62,7 +70,7 @@ export default function SignInPage() {
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
||||
<Logo iconSize="lg" showText={false} />
|
||||
<Logo iconSize="lg" showText={true} />
|
||||
</Link>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
@@ -116,6 +124,22 @@ export default function SignInPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Checkbox
|
||||
id="not-robot"
|
||||
checked={notRobot}
|
||||
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="not-robot"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
I'm not a robot{" "}
|
||||
<span className="text-muted-foreground text-xs italic">(ironic, isn't it?)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
|
||||
@@ -7,16 +7,18 @@ import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FlaskConical,
|
||||
HelpCircle,
|
||||
LayoutDashboard,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -49,9 +51,11 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { startTour } = useTour();
|
||||
const { data: session } = useSession();
|
||||
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
|
||||
|
||||
// --- Data Fetching ---
|
||||
@@ -81,14 +85,27 @@ export default function DashboardPage() {
|
||||
studyId: studyFilter ?? undefined,
|
||||
});
|
||||
|
||||
const userName = session?.user?.name ?? "Researcher";
|
||||
|
||||
const getWelcomeMessage = () => {
|
||||
const hour = new Date().getHours();
|
||||
let greeting = "Good evening";
|
||||
if (hour < 12) greeting = "Good morning";
|
||||
else if (hour < 18) greeting = "Good afternoon";
|
||||
|
||||
return `${greeting}, ${userName.split(" ")[0]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-8 animate-in fade-in duration-500">
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
{/* Header Section */}
|
||||
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{getWelcomeMessage()}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Overview of your research activities and upcoming tasks.
|
||||
Here's what's happening with your research today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -123,166 +140,218 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{/* Main Stats Grid */}
|
||||
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Total Participants"
|
||||
value={stats?.totalParticipants ?? 0}
|
||||
icon={Users}
|
||||
description="Across all studies"
|
||||
trend="+2 this week"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Trials"
|
||||
value={stats?.activeTrials ?? 0}
|
||||
icon={Activity}
|
||||
description="Currently in progress"
|
||||
|
||||
description="Currently running sessions"
|
||||
iconColor="text-emerald-500"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Completed Trials"
|
||||
title="Completed Today"
|
||||
value={stats?.completedToday ?? 0}
|
||||
icon={CheckCircle2}
|
||||
description="Completed today"
|
||||
icon={CheckCircle}
|
||||
description="Successful completions"
|
||||
iconColor="text-blue-500"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Scheduled"
|
||||
value={stats?.scheduledTrials ?? 0}
|
||||
icon={Calendar}
|
||||
description="Upcoming sessions"
|
||||
iconColor="text-violet-500"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total Studies"
|
||||
value={userStudies.length}
|
||||
icon={FlaskConical}
|
||||
description="Active research projects"
|
||||
iconColor="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Center & Recent Activity */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
|
||||
{/* Main Column: Scheduled Trials & Study Progress */}
|
||||
<div className="col-span-4 space-y-4">
|
||||
|
||||
{/* Scheduled Trials */}
|
||||
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Upcoming Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
You have {scheduledTrials?.length ?? 0} scheduled trials coming up.
|
||||
</CardDescription>
|
||||
{/* Quick Actions Card */}
|
||||
<Card className="col-span-3 bg-gradient-to-br from-primary/5 to-background border-primary/20 h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common tasks to get you started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-4 px-4 border-primary/20 hover:border-primary/50 hover:bg-primary/5 group"
|
||||
asChild
|
||||
>
|
||||
<Link href="/studies/new">
|
||||
<div className="p-2 bg-primary/10 rounded-full mr-4 group-hover:bg-primary/20 transition-colors">
|
||||
<FlaskConical className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/trials?status=scheduled">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Create New Study</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">
|
||||
Design a new experiment protocol
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="ml-auto h-4 w-4 text-muted-foreground group-hover:text-primary opacity-0 group-hover:opacity-100 transition-all" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-4 px-4 group"
|
||||
asChild
|
||||
>
|
||||
<Link href="/studies">
|
||||
<div className="p-2 bg-secondary rounded-full mr-4">
|
||||
<Search className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Browse Studies</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">
|
||||
Find and manage existing studies
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-4 px-4 group"
|
||||
asChild
|
||||
>
|
||||
<Link href="/trials">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-full mr-4">
|
||||
<Activity className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Monitor Active Trials</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">
|
||||
Jump into the Wizard Interface
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity Card */}
|
||||
<Card id="tour-recent-activity" className="col-span-4 border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Your latest interactions across the platform
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-4">
|
||||
{recentActivity?.map((activity) => (
|
||||
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
|
||||
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
|
||||
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase">
|
||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!recentActivity?.length && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<Clock className="h-10 w-10 mb-3 opacity-20" />
|
||||
<p>No recent activity recorded.</p>
|
||||
<p className="text-sm">Start a trial to see updates here.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
{/* Scheduled Trials (Restored from previous page.tsx but styled to fit) */}
|
||||
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Upcoming Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
You have {scheduledTrials?.length ?? 0} scheduled trials coming up.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/trials?status=scheduled">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!scheduledTrials?.length ? (
|
||||
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center animate-in fade-in-50">
|
||||
<Calendar className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No scheduled trials found.</p>
|
||||
<Button variant="link" size="sm" asChild className="mt-1">
|
||||
<Link href="/trials/new">Schedule a Trial</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!scheduledTrials?.length ? (
|
||||
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center animate-in fade-in-50">
|
||||
<Calendar className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No scheduled trials found.</p>
|
||||
<Button variant="link" size="sm" asChild className="mt-1">
|
||||
<Link href="/trials/new">Schedule a Trial</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{scheduledTrials.map((trial) => (
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 bg-muted/10 hover:bg-muted/50 hover:shadow-sm transition-all duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{trial.participant.participantCode}
|
||||
<span className="ml-2 text-muted-foreground font-normal text-xs">• {trial.experiment.name}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{trial.scheduledAt ? format(trial.scheduledAt, "MMM d, h:mm a") : "Unscheduled"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{scheduledTrials.map((trial) => (
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 bg-muted/10 hover:bg-muted/50 hover:shadow-sm transition-all duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{trial.participant.participantCode}
|
||||
<span className="ml-2 text-muted-foreground font-normal text-xs">• {trial.experiment.name}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{trial.scheduledAt ? format(trial.scheduledAt, "MMM d, h:mm a") : "Unscheduled"}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="gap-2" asChild>
|
||||
<Link href={`/wizard/${trial.id}`}>
|
||||
<Play className="h-3.5 w-3.5" /> Start
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Study Progress */}
|
||||
<Card className="border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Study Progress</CardTitle>
|
||||
<CardDescription>
|
||||
Completion tracking for active studies
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{studyProgress?.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="font-medium">{study.name}</div>
|
||||
<div className="text-muted-foreground">{study.participants} / {study.totalParticipants} Participants</div>
|
||||
<Button size="sm" className="gap-2" asChild>
|
||||
<Link href={`/wizard/${trial.id}`}>
|
||||
<Play className="h-3.5 w-3.5" /> Start
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Study Progress */}
|
||||
<Card className="col-span-3 border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Study Progress</CardTitle>
|
||||
<CardDescription>
|
||||
Completion tracking for active studies
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{studyProgress?.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="font-medium">{study.name}</div>
|
||||
<div className="text-muted-foreground">{study.participants} / {study.totalParticipants} Participants</div>
|
||||
</div>
|
||||
))}
|
||||
{!studyProgress?.length && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No active studies to track.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Side Column: Recent Activity & Quick Actions */}
|
||||
<div className="col-span-3 space-y-4">
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
|
||||
<Link href="/experiments/new">
|
||||
<Bot className="h-6 w-6 mb-1" />
|
||||
<span>New Experim.</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
|
||||
<Link href="/trials/new">
|
||||
<PlayCircle className="h-6 w-6 mb-1" />
|
||||
<span>Run Trial</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card id="tour-recent-activity" className="border-muted/40 shadow-sm h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-4">
|
||||
{recentActivity?.map((activity) => (
|
||||
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
|
||||
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
|
||||
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase">
|
||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!recentActivity?.length && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No recent activity.</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
{!studyProgress?.length && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No active studies to track.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -294,18 +363,20 @@ function StatsCard({
|
||||
icon: Icon,
|
||||
description,
|
||||
trend,
|
||||
iconColor,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
trend?: string;
|
||||
iconColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-muted/40 shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Icon className={`h-4 w-4 ${iconColor || "text-muted-foreground"}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
|
||||
Reference in New Issue
Block a user