feat: Enhance trial event display with improved formatting and icons, refine trial wizard panels, and update dashboard page layouts.

This commit is contained in:
2026-02-20 00:37:33 -05:00
parent 72971a4b49
commit 60d4fae72c
20 changed files with 1202 additions and 688 deletions

View File

@@ -16,8 +16,19 @@ 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 { User, Shield, Download, Trash2, ExternalLink } from "lucide-react";
import {
User,
Shield,
Download,
Trash2,
ExternalLink,
Lock,
UserCog,
Mail,
Fingerprint
} from "lucide-react";
import { useSession } from "next-auth/react";
import { cn } from "~/lib/utils";
interface ProfileUser {
id: string;
@@ -32,185 +43,141 @@ interface ProfileUser {
function ProfileContent({ user }: { user: ProfileUser }) {
return (
<div className="space-y-6">
<div className="space-y-8 animate-in fade-in duration-500">
<PageHeader
title="Profile"
description="Manage your account settings and preferences"
title={user.name ?? "User"}
description={user.email}
icon={User}
badges={[
{ label: `ID: ${user.id}`, variant: "outline" },
...(user.roles?.map((r) => ({
label: formatRole(r.role),
variant: "secondary" as const,
})) ?? []),
]}
/>
<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 className="hover:shadow-md transition-shadow duration-200">
<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>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Main Content (Left Column) */}
<div className="space-y-8 lg:col-span-2">
{/* Password Change */}
<Card className="hover:shadow-md transition-shadow duration-200">
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>Change your account password</CardDescription>
</CardHeader>
<CardContent>
<PasswordChangeForm />
</CardContent>
</Card>
{/* Personal Information */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<User className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Personal Information</h3>
</div>
<Card className="border-border/60 hover:border-border transition-colors">
<CardHeader>
<CardTitle className="text-base">Contact Details</CardTitle>
<CardDescription>Update your public profile information</CardDescription>
</CardHeader>
<CardContent>
<ProfileEditForm
user={{
id: user.id,
name: user.name,
email: user.email,
image: user.image,
}}
/>
</CardContent>
</Card>
</section>
{/* 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>
{/* Security */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<Lock className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Security</h3>
</div>
<Card className="border-border/60 hover:border-border transition-colors">
<CardHeader>
<CardTitle className="text-base">Password</CardTitle>
<CardDescription>Ensure your account stays secure</CardDescription>
</CardHeader>
<CardContent>
<PasswordChangeForm />
</CardContent>
</Card>
</section>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* User Summary */}
<Card className="hover:shadow-md transition-shadow duration-200">
<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>
{/* Sidebar (Right Column) */}
<div className="space-y-8">
<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>
{/* Permissions */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<Shield className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Permissions</h3>
</div>
<Card>
<CardContent className="pt-6">
{user.roles && user.roles.length > 0 ? (
<div className="space-y-4">
{user.roles.map((roleInfo, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{formatRole(roleInfo.role)}</span>
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
Since {new Date(roleInfo.grantedAt).toLocaleDateString()}
</span>
</div>
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground leading-relaxed">
{getRoleDescription(roleInfo.role)}
</p>
<p className="text-muted-foreground/80 mt-1 text-xs">
Granted{" "}
{new Date(roleInfo.grantedAt).toLocaleDateString()}
</p>
{index < (user.roles?.length || 0) - 1 && <Separator className="my-2" />}
</div>
))}
<div className="bg-blue-50/50 dark:bg-blue-900/10 p-3 rounded-lg border border-blue-100 dark:border-blue-900/30 text-xs text-muted-foreground mt-4">
<div className="flex items-center gap-2 mb-1 text-primary font-medium">
<Shield className="h-3 w-3" />
<span>Role Management</span>
</div>
System roles are managed by administrators. Contact support if you need access adjustments.
</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 className="text-center py-4">
<p className="text-sm font-medium">No Roles Assigned</p>
<p className="text-xs text-muted-foreground mt-1">Contact an admin to request access.</p>
<Button size="sm" variant="outline" className="mt-3 w-full">Request Access</Button>
</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
)}
</CardContent>
</Card>
</section>
{/* Data & Privacy */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<Download className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Data & Privacy</h3>
</div>
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
<CardContent className="pt-6 space-y-4">
<div>
<h4 className="text-sm font-semibold mb-1">Export Data</h4>
<p className="text-xs text-muted-foreground mb-3">Download a copy of your personal data.</p>
<Button variant="outline" size="sm" className="w-full bg-background" disabled>
<Download className="mr-2 h-3 w-3" />
Download Archive
</Button>
</div>
)}
</CardContent>
</Card>
<Separator className="bg-destructive/10" />
<div>
<h4 className="text-sm font-semibold text-destructive mb-1">Delete Account</h4>
<p className="text-xs text-muted-foreground mb-3">This action is irreversible.</p>
<Button variant="destructive" size="sm" className="w-full" disabled>
<Trash2 className="mr-2 h-3 w-3" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</section>
</div>
</div>
</div>
@@ -218,13 +185,17 @@ function ProfileContent({ user }: { user: ProfileUser }) {
}
export default function ProfilePage() {
const { data: session } = useSession();
const { data: session, status } = useSession();
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Profile" },
]);
if (status === "loading") {
return <div className="p-8 text-muted-foreground animate-pulse">Loading profile...</div>;
}
if (!session?.user) {
redirect("/auth/signin");
}

View File

@@ -1,12 +1,13 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
import { Calendar, Clock, Edit, Play, Settings, Users, TestTube } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/ui/page-header";
import {
EntityView,
EntityViewHeader,
@@ -183,18 +184,19 @@ export default function ExperimentDetailPage({
return (
<EntityView>
<EntityViewHeader
<PageHeader
title={displayName}
subtitle={description ?? undefined}
icon="TestTube"
status={{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "TestTube",
}}
description={description ?? undefined}
icon={TestTube}
badges={[
{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
}
]}
actions={
canEdit ? (
<>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Settings className="mr-2 h-4 w-4" />
@@ -209,7 +211,7 @@ export default function ExperimentDetailPage({
Start Trial
</Link>
</Button>
</>
</div>
) : undefined
}
/>

View File

@@ -1,7 +1,7 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Plus, Settings, Shield } from "lucide-react";
import { Plus, Settings, Shield, Building } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
@@ -16,6 +16,7 @@ import {
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
@@ -167,17 +168,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
return (
<EntityView>
{/* Header */}
<EntityViewHeader
<PageHeader
title={study.name}
subtitle={study.description ?? undefined}
icon="Building"
status={{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "FileText",
}}
description={study.description ?? undefined}
icon={Building}
badges={[
{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
}
]}
actions={
<>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" />
@@ -190,7 +192,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
New Experiment
</Link>
</Button>
</>
</div>
}
/>
@@ -271,10 +273,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</h4>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
>
{experiment.status}

View File

@@ -10,8 +10,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/com
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react";
import { Edit, Users } from "lucide-react";
import Link from "next/link";
import { PageHeader } from "~/components/ui/page-header";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
@@ -37,14 +38,16 @@ export default async function ParticipantDetailPage({
return (
<EntityView>
<EntityViewHeader
<PageHeader
title={participant.participantCode}
subtitle={participant.name ?? "Unnamed Participant"}
icon="Users"
status={{
label: participant.consentGiven ? "Consent Given" : "No Consent",
variant: participant.consentGiven ? "default" : "secondary"
}}
description={participant.name ?? "Unnamed Participant"}
icon={Users}
badges={[
{
label: participant.consentGiven ? "Consent Given" : "No Consent",
variant: participant.consentGiven ? "default" : "secondary"
}
]}
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>

View File

@@ -86,7 +86,7 @@ function AnalysisPageContent() {
);
}
const trialData = {
const customTrialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
@@ -96,7 +96,7 @@ function AnalysisPageContent() {
return (
<TrialAnalysisView
trial={trialData}
trial={customTrialData}
backHref={`/studies/${studyId}/trials/${trialId}`}
/>
);

View File

@@ -140,6 +140,12 @@ function TrialDetailContent() {
title={`Trial: ${trial.participant.participantCode}`}
description={`${trial.experiment.name} - Session ${trial.sessionNumber}`}
icon={Play}
badges={[
{
label: trial.status.replace("_", " ").toUpperCase(),
variant: getStatusBadgeVariant(trial.status),
}
]}
actions={
<div className="flex gap-2">
{trial.status === "scheduled" && (

View File

@@ -39,11 +39,15 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
id: "timestamp",
header: "Time",
accessorKey: "timestamp",
size: 90,
meta: {
style: { width: '90px', minWidth: '90px' }
},
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col">
<span className="font-mono font-medium">
<div className="flex flex-col py-0.5">
<span className="font-mono font-medium text-xs">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
@@ -56,6 +60,10 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{
accessorKey: "eventType",
header: "Event Type",
size: 160,
meta: {
style: { width: '160px', minWidth: '160px' }
},
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
@@ -63,25 +71,33 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation = type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump"); // intervention_step_jump
const isActionComplete = type.includes("marked_complete");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention) Icon = User; // Wizard/Intervention often User
else if (isIntervention || isJump) Icon = User; // Jumps are interventions
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (type.includes("note")) Icon = MessageSquare;
else if (type.includes("completed")) Icon = CheckCircle;
else if (isObservation) Icon = MessageSquare;
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
return (
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
isIntervention && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
<div className="flex items-center py-0.5">
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
</div>
);
},
filterFn: (row, id, value) => {
@@ -93,14 +109,42 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
if (!data || Object.keys(data).length === 0) return <span className="text-muted-foreground text-xs">-</span>;
const type = row.getValue("eventType") as string;
// Wrapper for density and alignment
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div className="py-0.5 min-w-[300px] whitespace-normal break-words text-xs leading-normal">
{children}
</div>
);
if (!data || Object.keys(data).length === 0) return <Wrapper><span className="text-muted-foreground">-</span></Wrapper>;
// Smart Formatting
if (type.includes("jump")) {
return (
<Wrapper>
Jumped to step <strong>{data.stepName || (data.toIndex !== undefined ? data.toIndex + 1 : "?")}</strong>
<span className="text-muted-foreground ml-1">(Manual)</span>
</Wrapper>
);
}
if (type.includes("skipped")) {
return <Wrapper><span className="text-orange-600 dark:text-orange-400">Skipped: {data.actionId}</span></Wrapper>;
}
if (type.includes("marked_complete")) {
return <Wrapper><span className="text-green-600 dark:text-green-400">Manually marked complete</span></Wrapper>;
}
if (type.includes("annotation") || type.includes("note")) {
return <Wrapper><span className="italic text-foreground/80">{data.description || data.note || data.message || "No content"}</span></Wrapper>;
}
// Simplistic view for now: JSON stringify but truncated?
// Or meaningful extraction based on event type.
return (
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border block max-w-[400px] truncate">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
<Wrapper>
<code className="font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border inline-block max-w-full truncate align-middle">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
</Wrapper>
);
},
},

View File

@@ -1,22 +1,61 @@
"use client";
import * as React from "react";
import { DataTable } from "~/components/ui/data-table";
import { type TrialEvent, eventsColumns } from "./events-columns";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { usePlayback } from "../playback/PlaybackContext";
import { cn } from "~/lib/utils";
import {
CheckCircle,
AlertTriangle,
Bot,
User,
Flag,
MessageSquare,
Activity,
Video
} from "lucide-react";
import { type TrialEvent } from "./events-columns";
interface EventsDataTableProps {
data: TrialEvent[];
startTime?: Date;
}
// Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const { seekTo, events, currentEventIndex } = usePlayback();
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>("");
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
// Enhanced filtering logic
const filteredData = React.useMemo(() => {
return data.filter(event => {
@@ -40,46 +79,39 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
});
}, [data, eventTypeFilter, globalFilter]);
// Custom Filters UI
const filters = (
<div className="flex items-center gap-2">
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="action_executed">Actions</SelectItem>
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
</SelectContent>
</Select>
</div>
);
// Active Event Logic & Auto-scroll
// Match filtered events with global playback "active event" via ID
const activeEventId = React.useMemo(() => {
if (currentEventIndex >= 0 && currentEventIndex < events.length) {
// We need to match the type of ID used in data/events
// Assuming events from context are TrialEvent compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const evt = events[currentEventIndex] as any;
return evt?.id;
}
return null;
}, [events, currentEventIndex]);
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>({});
React.useEffect(() => {
if (activeEventId && rowRefs.current[activeEventId]) {
rowRefs.current[activeEventId]?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, [activeEventId]);
const handleRowClick = (event: TrialEvent) => {
if (!startTime) return;
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime.getTime()) / 1000;
seekTo(Math.max(0, seekSeconds));
};
return (
<div className="space-y-4">
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering.
Since we implemented custom external filtering for "type" dropdown and "global" search,
we pass the filtered data directly.
However, the shared DataTable component has a `searchKey` prop that drives an internal Input.
If we want to use OUR custom search input (to search JSON data), we should probably NOT use
DataTable's internal search or pass a custom filter.
The shared DataTable's `searchKey` only filters a specific column string value.
Since "data" is an object, we can't easily use the built-in single-column search.
So we'll implement our own search input and pass `filters={filters}` which creates
additional dropdowns, but we might want to REPLACE the standard search input.
Looking at `DataTable` implementation:
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`,
no input is rendered, and we can put ours in `filters`.
*/}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
@@ -88,16 +120,176 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
{filters}
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="action_executed">Actions</SelectItem>
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
<SelectItem value="annotation">Notes</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-xs text-muted-foreground mr-2">
{filteredData.length} events
</div>
</div>
<DataTable
columns={columns}
data={filteredData}
// No searchKey, we handle it externally
isLoading={false}
/>
<div className="rounded-md border bg-background">
<div>
<Table className="w-full">
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="w-[100px]">Time</TableHead>
<TableHead className="w-[180px]">Event Type</TableHead>
<TableHead className="w-auto">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
) : (
filteredData.map((event, index) => {
const type = event.eventType;
const data = event.data;
// Type Logic
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation = type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump");
const isActionComplete = type.includes("marked_complete");
const isCamera = type.includes("camera");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump) Icon = User;
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (isCamera) Icon = Video;
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
// Details Logic
let detailsContent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any; // Cast for easier access
if (type.includes("jump")) {
detailsContent = (
<>Jumped to step <strong>{d?.stepName || (d?.toIndex !== undefined ? d.toIndex + 1 : "?")}</strong> <span className="text-muted-foreground ml-1">(Manual)</span></>
);
} else if (type.includes("skipped")) {
detailsContent = <span className="text-orange-600 dark:text-orange-400">Skipped: {d?.actionId}</span>;
} else if (type.includes("marked_complete")) {
detailsContent = <span className="text-green-600 dark:text-green-400">Manually marked complete</span>;
} else if (type.includes("annotation") || type.includes("note")) {
detailsContent = <span className="italic text-foreground/80">{d?.description || d?.note || d?.message || "No content"}</span>;
} else if (type.includes("step")) {
detailsContent = <span>Step: <strong>{d?.stepName || d?.name || (d?.index !== undefined ? `Index ${d.index}` : "")}</strong></span>;
} else if (type.includes("action_executed")) {
const name = d?.actionName || d?.actionId;
const meta = d?.actionType ? `(${d.actionType})` : d?.type ? `(${d.type})` : "";
detailsContent = <span>Executed: <strong>{name}</strong> <span className="text-muted-foreground text-[10px] ml-1">{meta}</span></span>;
} else if (type.includes("robot") || type.includes("say") || type.includes("speech")) {
const text = d?.text || d?.message || d?.data?.text;
detailsContent = (
<span>
Robot: <strong>{d?.command || d?.type || "Action"}</strong>
{text && <span className="text-muted-foreground ml-1">"{text}"</span>}
</span>
);
} else if (type.includes("intervention")) {
detailsContent = <span className="text-orange-600 dark:text-orange-400">Intervention: {d?.type || "Manual Action"}</span>;
} else if (type === "trial_started") {
detailsContent = <span className="text-green-600 font-medium">Trial Started</span>;
} else if (type === "trial_completed") {
detailsContent = <span className="text-blue-600 font-medium">Trial Completed</span>;
} else if (type === "trial_paused") {
detailsContent = <span className="text-yellow-600 font-medium">Trial Paused</span>;
} else if (isCamera) {
detailsContent = <span className="font-medium text-teal-600 dark:text-teal-400">{type === "camera_started" ? "Recording Started" : type === "camera_stopped" ? "Recording Stopped" : "Camera Event"}</span>;
} else {
// Default
if (d && Object.keys(d).length > 0) {
detailsContent = (
<code className="font-mono text-muted-foreground bg-muted/50 px-1 py-0.5 rounded border inline-block max-w-full truncate align-middle text-[10px]">
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
</code>
);
} else {
detailsContent = <span className="text-muted-foreground text-xs">-</span>;
}
}
const isActive = activeEventId === event.id;
return (
<TableRow
key={event.id || index}
ref={(el) => {
if (event.id) rowRefs.current[event.id] = el;
}}
className={cn(
"cursor-pointer h-auto border-l-2 border-transparent transition-colors",
isActive
? "bg-muted border-l-primary"
: "hover:bg-muted/50"
)}
onClick={() => handleRowClick(event)}
>
<TableCell className="py-1 align-top w-[100px]">
<div className="flex flex-col">
<span className="font-mono font-medium text-xs">
{formatRelativeTime(event.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
</TableCell>
<TableCell className="py-1 align-top w-[180px]">
<div className="flex items-center">
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isCamera && "border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-900/50 dark:bg-teal-900/20 dark:text-teal-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
</div>
</TableCell>
<TableCell className="py-1 align-top w-auto">
<div className="text-xs break-words whitespace-normal leading-normal min-w-0">
{detailsContent}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -83,20 +83,21 @@ export function EventTimeline() {
}, [effectiveDuration]);
const getEventIcon = (type: string) => {
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-4 w-4" />;
if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return <User className="h-4 w-4" />;
if (type.includes("robot") || type.includes("action")) return <Bot className="h-4 w-4" />;
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
if (type.includes("start")) return <Flag className="h-4 w-4" />;
if (type.includes("note")) return <MessageSquare className="h-4 w-4" />;
if (type.includes("note") || type.includes("annotation")) return <MessageSquare className="h-4 w-4" />;
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
return <Activity className="h-4 w-4" />;
};
const getEventColor = (type: string) => {
if (type.includes("intervention") || type.includes("wizard")) return "bg-orange-100 text-orange-600 border-orange-200";
if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return "bg-orange-100 text-orange-600 border-orange-200";
if (type.includes("robot") || type.includes("action")) return "bg-purple-100 text-purple-600 border-purple-200";
if (type.includes("completed")) return "bg-green-100 text-green-600 border-green-200";
if (type.includes("start")) return "bg-blue-100 text-blue-600 border-blue-200";
if (type.includes("note") || type.includes("annotation")) return "bg-yellow-100 text-yellow-600 border-yellow-200";
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
return "bg-slate-100 text-slate-600 border-slate-200";
};
@@ -132,19 +133,37 @@ export function EventTimeline() {
{sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime());
// Smart Formatting Logic
const details = (() => {
const { eventType, data } = event;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
if (eventType.includes("jump")) return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
if (eventType.includes("skipped")) return `Skipped: ${d?.actionId}`;
if (eventType.includes("marked_complete")) return "Manually marked complete";
if (eventType.includes("annotation") || eventType.includes("note")) return d?.description || d?.note || d?.message || "Note";
if (!d || Object.keys(d).length === 0) return null;
return JSON.stringify(d).slice(0, 100).replace(/[{""}]/g, " ").trim();
})();
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className="absolute z-20 top-1/2 left-0 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group/event"
className="absolute z-20 top-1/2 left-0 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group/event cursor-pointer p-2"
style={{ left: `${pct}%` }}
onClick={(e) => {
e.stopPropagation();
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
// startTime is in ms, timestamp is Date string or obj
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime) / 1000;
seekTo(Math.max(0, seekSeconds));
}}
>
<div className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
"flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
getEventColor(event.eventType)
)}>
{getEventIcon(event.eventType)}
@@ -156,9 +175,9 @@ export function EventTimeline() {
<div className="text-[10px] font-mono opacity-70 mb-1">
{new Date(event.timestamp).toLocaleTimeString()}
</div>
{!!event.data && (
<div className="bg-muted/50 p-1 rounded font-mono text-[9px] max-w-[200px] break-all">
{JSON.stringify(event.data as object).slice(0, 100)}
{!!details && (
<div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
{details}
</div>
)}
</TooltipContent>

View File

@@ -85,13 +85,14 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
<video
ref={videoRef}
src={src}
controls
muted={muted}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
onEnded={handleEnded}
onClick={togglePlay}
/>
{/* Overlay Controls (Visible on Hover/Pause) */}

View File

@@ -4,6 +4,7 @@ import { PageHeader } from "~/components/ui/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import Link from "next/link";
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import { useEffect } from "react";
@@ -31,7 +32,7 @@ interface TrialAnalysisViewProps {
participant: { participantCode: string };
eventCount?: number;
mediaCount?: number;
media?: { url: string; contentType: string }[];
media?: { url: string; mediaType: string; format?: string; contentType?: string }[];
};
backHref: string;
}
@@ -41,6 +42,8 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id,
limit: 1000
}, {
refetchInterval: 5000
});
// Auto-print effect
@@ -54,7 +57,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
}
}, []);
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
const videoMedia = trial.media?.find(m => m.mediaType === "video" || (m as any).contentType?.startsWith("video/"));
const videoUrl = videoMedia?.url;
// Metrics
@@ -64,7 +67,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div id="trial-analysis-content" className="flex h-full flex-col gap-4 p-4 text-sm">
<div id="trial-analysis-content" className="flex h-full flex-col gap-2 p-3 text-sm">
{/* Header Context */}
<PageHeader
title={trial.experiment.name}
@@ -185,65 +188,56 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
}
/>
{/* Metrics Header */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4" id="tour-trial-metrics">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
<Clock className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? (
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span>
) : (
"--:--"
)}
</div>
<p className="text-xs text-muted-foreground">Total session time</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
<Bot className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{robotActionCount}</div>
<p className="text-xs text-muted-foreground">Executed autonomous behaviors</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
<AlertTriangle className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{interventionCount}</div>
<p className="text-xs text-muted-foreground">Manual wizard overrides</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
<Activity className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn(
"inline-block h-2 w-2 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
{/* Top Section: Metrics & Optional Video Grid */}
<div className="flex flex-col xl:flex-row gap-3 shrink-0">
<Card id="tour-trial-metrics" className="shadow-sm flex-1">
<CardContent className="p-0 h-full">
<div className="flex flex-row divide-x h-full">
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<Clock className="h-3.5 w-3.5 text-blue-500" /> Duration
</p>
<p className="text-base font-bold">
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
</p>
</div>
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<Bot className="h-3.5 w-3.5 text-purple-500" /> Robot Actions
</p>
<p className="text-base font-bold">{robotActionCount}</p>
</div>
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<AlertTriangle className="h-3.5 w-3.5 text-orange-500" /> Interventions
</p>
<p className="text-base font-bold">{interventionCount}</p>
</div>
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<Activity className="h-3.5 w-3.5 text-green-500" /> Completeness
</p>
<div className="flex items-center gap-1.5 text-base font-bold">
<span className={cn(
"inline-block h-2 w-2 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
</div>
</div>
</CardContent>
</Card>
{videoUrl && (
<Card id="tour-trial-video" className="shadow-sm w-full xl:w-[500px] overflow-hidden shrink-0 bg-black/5 dark:bg-black/40 border">
<div className="aspect-video w-full h-full relative flex items-center justify-center bg-black">
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
</div>
</Card>
)}
</div>
{/* Main Workspace: Vertical Layout */}
@@ -254,51 +248,89 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
<EventTimeline />
</div>
<ResizablePanelGroup direction="vertical">
{/* TOP: Video (Optional) */}
{videoUrl && (
<>
<ResizablePanel defaultSize={40} minSize={20} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40" id="tour-trial-video">
<div className="relative flex-1 min-h-0 flex items-center justify-center">
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle className="bg-border/50" />
</>
)}
{/* BOTTOM: Events Table */}
<ResizablePanel defaultSize={videoUrl ? 60 : 100} minSize={20} className="flex flex-col min-h-0 bg-background" id="tour-trial-events">
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
{/* BOTTOM: Events Table */}
<div className="flex-1 flex flex-col min-h-0 bg-background" id="tour-trial-events">
<Tabs defaultValue="events" className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0 bg-muted/10">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">Event Log</h3>
<TabsList className="h-8">
<TabsTrigger value="events" className="text-xs">All Events</TabsTrigger>
<TabsTrigger value="observations" className="text-xs">Observations ({events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length})</TabsTrigger>
</TabsList>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Filter events..."
className="h-8 w-[200px]"
placeholder="Filter..."
className="h-7 w-[150px] text-xs"
disabled
style={{ display: 'none' }}
/>
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
<Badge variant="outline" className="text-[10px] font-normal">{events.length} Total</Badge>
</div>
</div>
<div className="flex-1 min-h-0">
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
<ScrollArea className="h-full">
<div className="p-4">
<div className="p-0">
<EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</TabsContent>
<TabsContent value="observations" className="flex-1 min-h-0 mt-0 bg-muted/5">
<ScrollArea className="h-full">
<div className="p-4 space-y-3 max-w-2xl mx-auto">
{events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length > 0 ? (
events
.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note')
.map((e, i) => {
const data = e.data as any;
return (
<Card key={i} className="border shadow-none">
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
{data?.category || "Note"}
</Badge>
<span className="text-xs text-muted-foreground font-mono">
{trial.startedAt ? formatTime(new Date(e.timestamp).getTime() - new Date(trial.startedAt).getTime()) : '--:--'}
</span>
</div>
<span className="text-[10px] text-muted-foreground">
{new Date(e.timestamp).toLocaleTimeString()}
</span>
</CardHeader>
<CardContent className="p-3 pt-2">
<p className="text-sm">
{data?.description || data?.note || data?.message || "No content"}
</p>
{data?.tags && data.tags.length > 0 && (
<div className="flex gap-1 mt-2">
{data.tags.map((t: string, ti: number) => (
<Badge key={ti} variant="secondary" className="text-[10px] h-5 px-1.5">
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
})
) : (
<div className="text-center py-12 text-muted-foreground text-sm">
<Info className="h-8 w-8 mx-auto mb-2 opacity-20" />
No observations recorded for this session.
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</PlaybackProvider>

View File

@@ -10,6 +10,7 @@ import {
Play,
Target,
Users,
SkipForward
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -21,15 +22,17 @@ interface TrialProgressProps {
id: string;
name: string;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
description?: string;
duration?: number;
parameters?: Record<string, unknown>;
}>;
currentStepIndex: number;
completedSteps: Set<number>;
skippedSteps: Set<number>;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
}
@@ -71,6 +74,8 @@ const stepTypeConfig = {
export function TrialProgress({
steps,
currentStepIndex,
completedSteps,
skippedSteps,
trialStatus,
}: TrialProgressProps) {
if (!steps || steps.length === 0) {
@@ -93,7 +98,7 @@ export function TrialProgress({
? 0
: ((currentStepIndex + 1) / steps.length) * 100;
const completedSteps =
const completedCount =
trialStatus === "completed"
? steps.length
: trialStatus === "aborted" || trialStatus === "failed"
@@ -102,12 +107,19 @@ export function TrialProgress({
const getStepStatus = (index: number) => {
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
if (trialStatus === "completed" || index < currentStepIndex)
return "completed";
if (trialStatus === "completed") return "completed";
if (skippedSteps.has(index)) return "skipped";
if (completedSteps.has(index)) return "completed";
if (index === currentStepIndex && trialStatus === "in_progress")
return "active";
if (index === currentStepIndex && trialStatus === "scheduled")
return "pending";
// Default fallback if jumping around without explicitly adding to sets
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
return "upcoming";
};
@@ -145,6 +157,14 @@ export function TrialProgress({
borderColor: "border-red-300",
textColor: "text-red-800",
};
case "skipped":
return {
icon: Circle,
iconColor: "text-slate-400 opacity-50",
bgColor: "bg-slate-50 opacity-50",
borderColor: "border-slate-200 border-dashed",
textColor: "text-slate-500",
};
default: // upcoming
return {
icon: Circle,
@@ -171,7 +191,7 @@ export function TrialProgress({
</CardTitle>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{completedSteps}/{steps.length} steps
{completedCount}/{steps.length} steps
</Badge>
{totalDuration > 0 && (
<Badge variant="outline" className="text-xs">
@@ -191,13 +211,12 @@ export function TrialProgress({
</div>
<Progress
value={progress}
className={`h-2 ${
trialStatus === "completed"
? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100"
: "bg-blue-100"
}`}
className={`h-2 ${trialStatus === "completed"
? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100"
: "bg-blue-100"
}`}
/>
<div className="flex justify-between text-xs text-slate-500">
<span>Start</span>
@@ -236,51 +255,47 @@ export function TrialProgress({
{/* Connection Line */}
{index < steps.length - 1 && (
<div
className={`absolute top-12 left-6 h-6 w-0.5 ${
getStepStatus(index + 1) === "completed" ||
className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
(getStepStatus(index + 1) === "active" &&
status === "completed")
? "bg-green-300"
: "bg-slate-300"
}`}
? "bg-green-300"
: "bg-slate-300"
}`}
/>
)}
{/* Step Card */}
<div
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "border-slate-200 bg-slate-50"
}`}
: "border-slate-200 bg-slate-50"
}`}
>
{/* Step Number & Status */}
<div className="flex-shrink-0 space-y-1">
<div
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
status === "active"
? statusConfig.bgColor
: status === "completed"
? "bg-green-100"
: status === "aborted"
? "bg-red-100"
: "bg-slate-100"
}`}
className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
? statusConfig.bgColor
: status === "completed"
? "bg-green-100"
: status === "aborted"
? "bg-red-100"
: "bg-slate-100"
}`}
>
<span
className={`text-sm font-medium ${
status === "active"
? statusConfig.textColor
: status === "completed"
? "text-green-700"
: status === "aborted"
? "text-red-700"
: "text-slate-600"
}`}
className={`text-sm font-medium ${status === "active"
? statusConfig.textColor
: status === "completed"
? "text-green-700"
: status === "aborted"
? "text-red-700"
: "text-slate-600"
}`}
>
{index + 1}
</span>
@@ -297,15 +312,14 @@ export function TrialProgress({
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<h5
className={`truncate font-medium ${
status === "active"
? "text-slate-900"
: status === "completed"
? "text-green-900"
: status === "aborted"
? "text-red-900"
: "text-slate-700"
}`}
className={`truncate font-medium ${status === "active"
? "text-slate-900"
: status === "completed"
? "text-green-900"
: status === "aborted"
? "text-red-900"
: "text-slate-700"
}`}
>
{step.name}
</h5>
@@ -352,6 +366,12 @@ export function TrialProgress({
<span>Completed</span>
</div>
)}
{status === "skipped" && (
<div className="mt-2 flex items-center space-x-1 text-sm text-slate-500 opacity-80">
<SkipForward className="h-3 w-3" />
<span>Skipped</span>
</div>
)}
</div>
</div>
</div>
@@ -365,7 +385,7 @@ export function TrialProgress({
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-green-600">
{completedSteps}
{completedCount}
</div>
<div className="text-xs text-slate-600">Completed</div>
</div>
@@ -378,7 +398,7 @@ export function TrialProgress({
<div>
<div className="text-2xl font-bold text-slate-600">
{steps.length -
completedSteps -
completedCount -
(trialStatus === "in_progress" ? 1 : 0)}
</div>
<div className="text-xs text-slate-600">Remaining</div>

View File

@@ -25,10 +25,10 @@ import Link from "next/link";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane";
import { WebcamPanel } from "./panels/WebcamPanel";
import { TrialStatusBar } from "./panels/TrialStatusBar";
import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos";
@@ -121,20 +121,22 @@ export const WizardInterface = React.memo(function WizardInterface({
const [completedActionsCount, setCompletedActionsCount] = useState(0);
// Collapse state for panels
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [obsCollapsed, setObsCollapsed] = useState(false);
// Center tabs (Timeline | Actions)
const [centerTab, setCenterTab] = useState<"timeline" | "actions">("timeline");
// Reset completed actions when step changes
useEffect(() => {
setCompletedActionsCount(0);
}, [currentStepIndex]);
// Track completed steps
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [skippedSteps, setSkippedSteps] = useState<Set<number>>(new Set());
// Track the last response value from wizard_wait_for_response for branching
const [lastResponse, setLastResponse] = useState<string | null>(null);
const [isPaused, setIsPaused] = useState(false);
const utils = api.useUtils();
// Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -492,16 +494,27 @@ export const WizardInterface = React.memo(function WizardInterface({
const handlePauseTrial = async () => {
try {
await pauseTrialMutation.mutateAsync({ id: trial.id });
logEventMutation.mutate({
trialId: trial.id,
type: "trial_paused",
data: { timestamp: new Date() }
});
setIsPaused(true);
toast.info("Trial paused");
} catch (error) {
console.error("Failed to pause trial:", error);
}
};
const handleResumeTrial = async () => {
try {
logEventMutation.mutate({
trialId: trial.id,
type: "trial_resumed",
data: { timestamp: new Date() }
});
setIsPaused(false);
toast.success("Trial resumed");
} catch (error) {
console.error("Failed to resume trial:", error);
}
};
const handleNextStep = (targetIndex?: number) => {
// If explicit target provided (from branching choice), use it
if (typeof targetIndex === 'number') {
@@ -577,6 +590,24 @@ export const WizardInterface = React.memo(function WizardInterface({
}
});
// Mark steps as skipped
setSkippedSteps(prev => {
const next = new Set(prev);
for (let i = currentStepIndex + 1; i < targetIndex; i++) {
if (!completedSteps.has(i)) {
next.add(i);
}
}
return next;
});
// Mark current as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
setCurrentStepIndex(targetIndex);
setCompletedActionsCount(0);
return;
@@ -590,6 +621,13 @@ export const WizardInterface = React.memo(function WizardInterface({
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) {
// Mark current step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
// Log step change
logEventMutation.mutate({
trialId: trial.id,
@@ -600,6 +638,7 @@ export const WizardInterface = React.memo(function WizardInterface({
fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name,
method: "auto"
}
});
@@ -609,14 +648,51 @@ export const WizardInterface = React.memo(function WizardInterface({
}
};
const handleStepSelect = (index: number) => {
if (index === currentStepIndex) return;
// Log manual jump
logEventMutation.mutate({
trialId: trial.id,
type: "intervention_step_jump",
data: {
fromIndex: currentStepIndex,
toIndex: index,
fromStepId: currentStep?.id,
toStepId: steps[index]?.id,
stepName: steps[index]?.name,
method: "manual"
}
});
// Mark current as complete if leaving it?
// Maybe better to only mark on "Next" or explicit complete.
// If I jump away, I might not be done.
// I'll leave 'completedSteps' update to explicit actions or completion.
setCurrentStepIndex(index);
};
const handleCompleteTrial = async () => {
try {
// Mark final step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
await completeTrialMutation.mutateAsync({ id: trial.id });
// Invalidate queries so the analysis page sees the completed state immediately
await utils.trials.get.invalidate({ id: trial.id });
await utils.trials.getEvents.invalidate({ trialId: trial.id });
// Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id });
// Immediately navigate to analysis
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
} catch (error) {
console.error("Failed to complete trial:", error);
}
@@ -707,12 +783,60 @@ export const WizardInterface = React.memo(function WizardInterface({
category: String(parameters?.category || "quick_note")
});
} else {
// Generic action logging
// Generic action logging - now with more details
// Find the action definition to get its name
let actionName = actionId;
let actionType = "unknown";
// Helper to search recursively
const findAction = (actions: ActionData[], id: string): ActionData | undefined => {
for (const action of actions) {
if (action.id === id) return action;
if (action.parameters?.children) {
const found = findAction(action.parameters.children as ActionData[], id);
if (found) return found;
}
}
return undefined;
};
// Search in current step first
let foundAction: ActionData | undefined;
if (steps[currentStepIndex]?.actions) {
foundAction = findAction(steps[currentStepIndex]!.actions!, actionId);
}
// If not found, search all steps (less efficient but safer)
if (!foundAction) {
for (const step of steps) {
if (step.actions) {
foundAction = findAction(step.actions, actionId);
if (foundAction) break;
}
}
}
if (foundAction) {
actionName = foundAction.name;
actionType = foundAction.type;
} else {
// Fallback for Wizard Actions (often have label/value in parameters)
if (parameters?.label && typeof parameters.label === 'string') {
actionName = parameters.label;
actionType = "wizard_button";
} else if (parameters?.value && typeof parameters.value === 'string') {
actionName = parameters.value;
actionType = "wizard_input";
}
}
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "action_executed",
data: {
actionId,
actionName,
actionType,
parameters
}
});
@@ -734,6 +858,22 @@ export const WizardInterface = React.memo(function WizardInterface({
) => {
try {
setIsExecutingAction(true);
// Core actions execute directly via tRPC (no ROS needed)
if (pluginName === "hristudio-core" || pluginName === "hristudio-woz") {
await executeRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
});
if (options?.autoAdvance) {
handleNextStep();
}
setIsExecutingAction(false);
return;
}
// Try direct WebSocket execution first for better performance
if (rosConnected) {
try {
@@ -778,18 +918,12 @@ export const WizardInterface = React.memo(function WizardInterface({
}
}
} else {
// Use tRPC execution if WebSocket not connected
await executeRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
// Not connected - show error and don't try to execute
const errorMsg = "Robot not connected. Cannot execute action.";
toast.error(errorMsg);
console.warn(errorMsg);
// Throw to stop execution flow
throw new Error(errorMsg);
}
} catch (error) {
console.error("Failed to execute robot action:", error);
@@ -825,7 +959,7 @@ export const WizardInterface = React.memo(function WizardInterface({
// Generic skip logging
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "action_skipped",
type: "intervention_action_skipped",
data: {
actionId,
parameters
@@ -842,9 +976,17 @@ export const WizardInterface = React.memo(function WizardInterface({
toast.error("Failed to skip action");
}
},
[logRobotActionMutation, trial.id],
[logRobotActionMutation, trial.id, logEventMutation, handleNextStep],
);
const handleLogEvent = useCallback((type: string, data?: any) => {
logEventMutation.mutate({
trialId: trial.id,
type,
data
});
}, [logEventMutation, trial.id]);
return (
@@ -869,13 +1011,13 @@ export const WizardInterface = React.memo(function WizardInterface({
{trial.status === "in_progress" && (
<>
<Button
variant="outline"
variant={isPaused ? "default" : "outline"}
size="sm"
onClick={handlePauseTrial}
onClick={isPaused ? handleResumeTrial : handlePauseTrial}
className="gap-2"
>
<Pause className="h-4 w-4" />
Pause
{isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
{isPaused ? "Resume" : "Pause"}
</Button>
<Button
@@ -922,190 +1064,128 @@ export const WizardInterface = React.memo(function WizardInterface({
className="flex-none px-2 pb-2"
/>
{/* Main Grid - 2 rows */}
<div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2">
{/* Top Row - 3 Column Layout */}
<div className="flex-1 min-h-0 flex gap-2">
{/* Left Sidebar - Control Panel (Collapsible) */}
{!leftCollapsed && (
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Control</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setLeftCollapsed(true)}
>
<PanelLeftClose className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
<div id="tour-wizard-controls-wrapper" className="h-full">
<WizardControlPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
onStartTrial={handleStartTrial}
onPauseTrial={handlePauseTrial}
onNextStep={handleNextStep}
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
isStarting={startTrialMutation.isPending}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
</div>
</div>
)}
{/* Main Grid - Single Row */}
<div className="flex-1 min-h-0 flex gap-2 px-2 pb-2">
{/* Center - Tabbed Workspace */}
{/* Center - Execution Workspace */}
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
{leftCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 mr-2"
onClick={() => setLeftCollapsed(false)}
title="Open Tools Panel"
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Trial Execution</span>
{currentStep && (
<Badge variant="outline" className="text-xs font-normal">
{currentStep.name}
</Badge>
)}
</div>
<div className="flex-1" />
<div className="mr-2 text-xs text-muted-foreground font-medium">
Step {currentStepIndex + 1} / {steps.length}
</div>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(false)}
title="Open Robot Status"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
{/* Center - Execution Workspace */}
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Trial Execution</span>
{currentStep && (
<Badge variant="outline" className="text-xs font-normal">
{currentStep.name}
</Badge>
)}
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
onSkipAction={handleSkipAction}
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
rosConnected={rosConnected}
/>
</div>
</div>
</div>
{/* Right Sidebar - Robot Status (Collapsible) */}
{!rightCollapsed && (
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Robot Control & Status</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div id="tour-wizard-robot-status" className="h-full">
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
onSetAutonomousLife={setAutonomousLife}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
trialId={trial.id}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex-1" />
{/* Bottom Row - Observations (Full Width, Collapsible) */}
{!obsCollapsed && (
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
<span className="text-sm font-medium">Observations</span>
<div className="flex-1" />
<div className="mr-2 text-xs text-muted-foreground font-medium">
Step {currentStepIndex + 1} / {steps.length}
</div>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setObsCollapsed(true)}
onClick={() => setRightCollapsed(false)}
title="Open Status & Tools"
>
<ChevronDown className="h-4 w-4" />
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending}
)}
</div>
<div className="flex-1 overflow-auto bg-muted/10 pb-0">
<div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
completedStepIndices={completedSteps}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
isPaused={isPaused}
onStepSelect={handleStepSelect}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
onSkipAction={handleSkipAction}
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
rosConnected={rosConnected}
onLogEvent={handleLogEvent}
/>
</div>
</div>
)}
{
obsCollapsed && (
</div>
{/* Right Sidebar - Tools Tabs (Collapsible) */}
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-[350px] lg:w-[400px]",
rightCollapsed && "hidden"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30 shrink-0">
<span className="text-sm font-medium">Tools</span>
<Button
variant="outline"
size="sm"
onClick={() => setObsCollapsed(false)}
className="w-full flex-none"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<ChevronUp className="h-4 w-4 mr-2" />
Show Observations
<PanelRightClose className="h-4 w-4" />
</Button>
)
}
</div >
</div >
</div>
<div className="flex-1 overflow-hidden bg-background">
<Tabs defaultValue="camera_obs" className="flex flex-col h-full w-full">
<TabsList className="w-full justify-start rounded-none border-b bg-muted/30 px-3 py-1 shrink-0 h-10">
<TabsTrigger value="camera_obs" className="text-xs flex-1">Camera & Obs</TabsTrigger>
<TabsTrigger value="robot" className="text-xs flex-1">Robot Control</TabsTrigger>
</TabsList>
<TabsContent value="camera_obs" className="flex-1 flex flex-col m-0 p-0 h-full overflow-hidden min-h-0">
<div className="flex-none bg-muted/30 border-b h-48 sm:h-56 relative group shrink-0">
<WebcamPanel readOnly={trial.status === 'completed'} trialId={trial.id} trialStatus={trial.status} />
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
onFlagIntervention={() => handleExecuteAction("intervene")}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
/>
</div>
</TabsContent>
<TabsContent value="robot" className="flex-1 m-0 h-full overflow-hidden">
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
onSetAutonomousLife={setAutonomousLife}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
trialId={trial.id}
trialStatus={trial.status}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
);
});

View File

@@ -16,7 +16,7 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner";
import { api } from "~/trpc/react";
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
const [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
@@ -31,6 +31,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
// TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
// Mutation to save recording metadata to DB
const saveRecordingMutation = api.storage.saveRecording.useMutation();
const logEventMutation = api.trials.logEvent.useMutation();
const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
@@ -38,7 +42,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
[setDevices],
);
const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => {
setIsMounted(true);
navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]);
@@ -54,6 +61,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
setIsCameraEnabled(false);
};
// Auto-record based on trial status
React.useEffect(() => {
if (!trialStatus || readOnly) return;
if (trialStatus === "in_progress") {
if (!isCameraEnabled) {
console.log("Auto-enabling camera for trial start");
setIsCameraEnabled(true);
} else if (!isRecording && webcamRef.current?.stream) {
handleStartRecording();
}
} else if (trialStatus === "completed" && isRecording) {
handleStopRecording();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
const handleUserMedia = () => {
if (trialStatus === "in_progress" && !isRecording && !readOnly) {
console.log("Stream ready, auto-starting camera recording");
handleStartRecording();
}
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
@@ -78,6 +109,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
recorder.start();
mediaRecorderRef.current = recorder;
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_started",
data: { action: "recording_started" }
});
}
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
@@ -90,6 +128,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_stopped",
data: { action: "recording_stopped" }
});
}
}
};
@@ -114,7 +159,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
});
if (!response.ok) {
throw new Error("Upload failed");
const errorText = await response.text();
throw new Error(`Upload failed: ${errorText} | Status: ${response.status}`);
}
// 3. Save metadata to DB
if (trialId) {
console.log("Attempting to link recording to trial:", trialId);
try {
await saveRecordingMutation.mutateAsync({
trialId,
storagePath: filename,
mediaType: "video",
format: "webm",
fileSize: blob.size,
});
console.log("Recording successfully linked to trial:", trialId);
toast.success("Recording saved to trial log");
} catch (mutationError) {
console.error("Failed to link recording to trial:", mutationError);
toast.error("Video uploaded but failed to link to trial");
}
} else {
console.warn("No trialId provided, recording uploaded but not linked. Props:", { trialId });
toast.warning("Trial ID missing - recording not linked");
}
toast.success("Recording uploaded successfully");
@@ -137,7 +205,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
{!readOnly && (
<div className="flex items-center gap-2">
{devices.length > 0 && (
{devices.length > 0 && isMounted && (
<Select
value={deviceId ?? undefined}
onValueChange={setDeviceId}
@@ -217,6 +285,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
width="100%"
height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }}
onUserMedia={handleUserMedia}
onUserMediaError={(err) => setError(String(err))}
className="object-contain w-full h-full"
/>

View File

@@ -47,6 +47,7 @@ interface WizardActionItemProps {
isExecuting?: boolean;
depth?: number;
isRobotConnected?: boolean;
onLogEvent?: (type: string, data?: any) => void;
}
export function WizardActionItem({
@@ -62,6 +63,7 @@ export function WizardActionItem({
isExecuting,
depth = 0,
isRobotConnected = false,
onLogEvent,
}: WizardActionItemProps): React.JSX.Element {
// Local state for container children completion
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
@@ -289,13 +291,14 @@ export function WizardActionItem({
isExecuting={isExecuting}
depth={depth + 1}
isRobotConnected={isRobotConnected}
onLogEvent={onLogEvent}
/>
))}
</div>
) : null) as any}
{/* Active Action Controls */}
{isActive && !readOnly && (
{(isActive || (isCompleted && !readOnly)) && (
<div className="pt-3 flex flex-wrap items-center gap-3">
{/* Parallel Container Controls */}
{isContainer && action.type.includes("parallel") ? (
@@ -326,20 +329,22 @@ export function WizardActionItem({
title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined}
>
<Play className="mr-2 h-3.5 w-3.5" />
Run All
</Button>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Group Complete
{isCompleted ? "Rerun All" : "Run All"}
</Button>
{!isCompleted && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Group Complete
</Button>
)}
</>
) : (
/* Standard Single Action Controls */
@@ -367,7 +372,7 @@ export function WizardActionItem({
action.parameters || {},
{ autoAdvance: false }
);
onCompleted();
if (!isCompleted) onCompleted();
} catch (error) {
console.error("Action execution error:", error);
} finally {
@@ -386,39 +391,50 @@ export function WizardActionItem({
) : (
<>
<Play className="mr-2 h-3.5 w-3.5" />
Run
{isCompleted ? "Rerun" : "Run"}
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Complete
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
if (onSkip) {
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
}
onCompleted();
}}
>
Skip
</Button>
{!isCompleted && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
// Log manual completion
if (onLogEvent) {
onLogEvent("action_marked_complete", {
actionId: action.id,
formatted: "Action manually marked complete"
});
}
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Complete
</Button>
)}
{!isCompleted && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
if (onSkip) {
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
}
onCompleted();
}}
>
Skip
</Button>
)}
</>
) : (
// Manual/Wizard Actions (Leaf nodes)
!isContainer && action.type !== "wizard_wait_for_response" && (
!isContainer && action.type !== "wizard_wait_for_response" && !isCompleted && (
<Button
size="sm"
onClick={(e) => {
@@ -437,7 +453,7 @@ export function WizardActionItem({
)}
{/* Branching / Choice UI */}
{isActive &&
{(isActive || (isCompleted && !readOnly)) &&
(action.type === "wizard_wait_for_response" || isBranch) &&
action.parameters?.options &&
Array.isArray(action.parameters.options) && (

View File

@@ -113,7 +113,11 @@ interface WizardExecutionPanelProps {
completedActionsCount: number;
onActionCompleted: () => void;
readOnly?: boolean;
isPaused?: boolean;
rosConnected?: boolean;
completedStepIndices?: Set<number>;
skippedStepIndices?: Set<number>;
onLogEvent?: (type: string, data?: any) => void;
}
export function WizardExecutionPanel({
@@ -134,12 +138,17 @@ export function WizardExecutionPanel({
completedActionsCount,
onActionCompleted,
readOnly = false,
isPaused = false,
rosConnected,
completedStepIndices = new Set(),
skippedStepIndices = new Set(),
onLogEvent,
}: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0);
const activeActionIndex = completedActionsCount;
const isStepCompleted = completedStepIndices.has(currentStepIndex);
const activeActionIndex = isStepCompleted ? 9999 : completedActionsCount;
// Auto-scroll to active action
const activeActionRef = React.useRef<HTMLDivElement>(null);
@@ -210,13 +219,29 @@ export function WizardExecutionPanel({
// Active trial state
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-full flex-col overflow-hidden relative">
{/* Paused Overlay */}
{isPaused && (
<div className="absolute inset-0 z-50 bg-background/60 backdrop-blur-[2px] flex items-center justify-center">
<div className="bg-background border shadow-lg rounded-xl p-8 flex flex-col items-center max-w-sm text-center space-y-4">
<AlertCircle className="h-12 w-12 text-muted-foreground" />
<div>
<h2 className="text-xl font-bold tracking-tight">Trial Paused</h2>
<p className="text-sm text-muted-foreground mt-1">
The trial execution has been paused. Resume from the control bar to continue interacting.
</p>
</div>
</div>
</div>
)}
{/* Horizontal Step Progress Bar */}
<div className="flex-none border-b bg-muted/30 p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-2">
{steps.map((step, idx) => {
const isCurrent = idx === currentStepIndex;
const isCompleted = idx < currentStepIndex;
const isSkipped = skippedStepIndices.has(idx);
const isCompleted = completedStepIndices.has(idx) || (!isSkipped && idx < currentStepIndex);
const isUpcoming = idx > currentStepIndex;
return (
@@ -233,7 +258,9 @@ export function WizardExecutionPanel({
? "border-primary bg-primary/10 shadow-sm"
: isCompleted
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
: isSkipped
? "border-muted-foreground/30 bg-muted/20 border-dashed"
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
}
${readOnly ? "cursor-default" : "cursor-pointer"}
`}
@@ -244,9 +271,11 @@ export function WizardExecutionPanel({
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
${isCompleted
? "bg-primary text-primary-foreground"
: isCurrent
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
: "bg-muted text-muted-foreground"
: isSkipped
? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
: isCurrent
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
: "bg-muted text-muted-foreground"
}
`}
>
@@ -348,6 +377,7 @@ export function WizardExecutionPanel({
readOnly={readOnly}
isExecuting={isExecuting}
isRobotConnected={rosConnected}
onLogEvent={onLogEvent}
/>
</div>
);

View File

@@ -14,7 +14,6 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { WebcamPanel } from "./WebcamPanel";
import { RobotActionsPanel } from "../RobotActionsPanel";
interface WizardMonitoringPanelProps {
@@ -44,6 +43,7 @@ interface WizardMonitoringPanelProps {
) => Promise<void>;
studyId?: string;
trialId?: string;
trialStatus?: string;
readOnly?: boolean;
}
@@ -59,6 +59,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
onExecuteRobotAction,
studyId,
trialId,
trialStatus,
readOnly = false,
}: WizardMonitoringPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true);
@@ -78,12 +79,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}
}, [onSetAutonomousLife]);
return (
<div className="flex h-full flex-col gap-2 p-2">
{/* Camera View - Always Visible */}
<div className="shrink-0 bg-muted/30 rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
<WebcamPanel readOnly={readOnly} />
</div>
<div className="flex h-full flex-col p-2">
{/* Robot Controls - Scrollable */}
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">

View File

@@ -29,6 +29,7 @@ interface WizardObservationPaneProps {
category?: string,
tags?: string[],
) => Promise<void>;
onFlagIntervention?: () => Promise<void> | void;
isSubmitting?: boolean;
readOnly?: boolean;
@@ -36,6 +37,7 @@ interface WizardObservationPaneProps {
export function WizardObservationPane({
onAddAnnotation,
onFlagIntervention,
isSubmitting = false,
trialEvents = [],
readOnly = false,
@@ -118,11 +120,23 @@ export function WizardObservationPane({
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8"
className="h-8 shrink-0"
>
<Send className="mr-2 h-3 w-3" />
Add Note
</Button>
{onFlagIntervention && (
<Button
size="sm"
variant="outline"
onClick={() => onFlagIntervention()}
disabled={readOnly}
className="h-8 shrink-0 border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
>
<AlertTriangle className="mr-2 h-3 w-3" />
Intervention
</Button>
)}
</div>
{tags.length > 0 && (

View File

@@ -8,12 +8,12 @@ import { uuid } from "drizzle-orm/pg-core";
import { eq, desc } from "drizzle-orm";
// Initialize MinIO client
// Note: In production, ensure these ENV vars are set.
// For development with docker-compose, we use localhost:9000
const minioUrl = new URL(env.MINIO_ENDPOINT ?? "http://localhost:9000");
const minioClient = new Minio.Client({
endPoint: (env.MINIO_ENDPOINT ?? "localhost").split(":")[0] ?? "localhost",
port: parseInt((env.MINIO_ENDPOINT ?? "9000").split(":")[1] ?? "9000"),
useSSL: false, // Default to false for local dev; adjust for prod
endPoint: minioUrl.hostname,
port: parseInt(minioUrl.port) || 9000,
useSSL: minioUrl.protocol === "https:",
accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
});

View File

@@ -300,7 +300,12 @@ export const trialsRouter = createTRPCRouter({
return {
...m,
url, // Add the signed URL to the response
contentType: m.format === 'webm' ? 'video/webm' : 'application/octet-stream', // Infer or store content type
contentType: m.format === 'webm' ? 'video/webm'
: m.format === 'mp4' ? 'video/mp4'
: m.format === 'mkv' ? 'video/x-matroska'
: m.storagePath.endsWith('.webm') ? 'video/webm'
: m.storagePath.endsWith('.mp4') ? 'video/mp4'
: 'application/octet-stream', // Infer or store content type
};
})),
};
@@ -597,11 +602,23 @@ export const trialsRouter = createTRPCRouter({
"wizard",
]);
const [currentTrial] = await db
.select()
.from(trials)
.where(eq(trials.id, input.id))
.limit(1);
let durationSeconds = null;
if (currentTrial?.startedAt) {
durationSeconds = Math.floor((new Date().getTime() - currentTrial.startedAt.getTime()) / 1000);
}
const [trial] = await db
.update(trials)
.set({
status: "completed",
completedAt: new Date(),
duration: durationSeconds,
notes: input.notes,
})
.where(eq(trials.id, input.id))