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 { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { formatRole, getRoleDescription } from "~/lib/auth-client"; 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 { useSession } from "next-auth/react";
import { cn } from "~/lib/utils";
interface ProfileUser { interface ProfileUser {
id: string; id: string;
@@ -32,23 +43,34 @@ interface ProfileUser {
function ProfileContent({ user }: { user: ProfileUser }) { function ProfileContent({ user }: { user: ProfileUser }) {
return ( return (
<div className="space-y-6"> <div className="space-y-8 animate-in fade-in duration-500">
<PageHeader <PageHeader
title="Profile" title={user.name ?? "User"}
description="Manage your account settings and preferences" description={user.email}
icon={User} 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"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Profile Information */} {/* Main Content (Left Column) */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-8 lg:col-span-2">
{/* Basic Information */}
<Card className="hover:shadow-md transition-shadow duration-200"> {/* 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> <CardHeader>
<CardTitle>Basic Information</CardTitle> <CardTitle className="text-base">Contact Details</CardTitle>
<CardDescription> <CardDescription>Update your public profile information</CardDescription>
Your personal account information
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ProfileEditForm <ProfileEditForm
@@ -61,156 +83,101 @@ function ProfileContent({ user }: { user: ProfileUser }) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</section>
{/* Password Change */} {/* Security */}
<Card className="hover:shadow-md transition-shadow duration-200"> <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> <CardHeader>
<CardTitle>Password</CardTitle> <CardTitle className="text-base">Password</CardTitle>
<CardDescription>Change your account password</CardDescription> <CardDescription>Ensure your account stays secure</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<PasswordChangeForm /> <PasswordChangeForm />
</CardContent> </CardContent>
</Card> </Card>
</section>
</div>
{/* Account Actions */} {/* Sidebar (Right Column) */}
<div className="space-y-8">
{/* 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> <Card>
<CardHeader> <CardContent className="pt-6">
<CardTitle>Account Actions</CardTitle> {user.roles && user.roles.length > 0 ? (
<CardDescription>Manage your account settings</CardDescription> <div className="space-y-4">
</CardHeader> {user.roles.map((roleInfo, index) => (
<CardContent className="space-y-4"> <div key={index} className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <span className="font-medium text-sm">{formatRole(roleInfo.role)}</span>
<h4 className="text-sm font-medium">Export Data</h4> <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
<p className="text-muted-foreground text-sm"> Since {new Date(roleInfo.grantedAt).toLocaleDateString()}
Download all your research data and account information
</p>
</div>
<Button variant="outline" disabled>
<Download className="mr-2 h-4 w-4" />
Export Data
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<h4 className="text-destructive text-sm font-medium">
Delete Account
</h4>
<p className="text-muted-foreground text-sm">
Permanently delete your account and all associated data
</p>
</div>
<Button variant="destructive" disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* User Summary */}
<Card 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> </span>
</div> </div>
<div> <p className="text-xs text-muted-foreground leading-relaxed">
<p className="font-medium">{user.name ?? "Unnamed User"}</p>
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
</div>
<Separator />
<div>
<p className="mb-2 text-sm font-medium">User ID</p>
<p className="text-muted-foreground bg-muted rounded p-2 font-mono text-xs break-all">
{user.id}
</p>
</div>
</CardContent>
</Card>
{/* System Roles */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
System Roles
</CardTitle>
<CardDescription>Your current system permissions</CardDescription>
</CardHeader>
<CardContent>
{user.roles && user.roles.length > 0 ? (
<div className="space-y-3">
{user.roles.map((roleInfo, index: number) => (
<div
key={index}
className="flex items-start justify-between"
>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge variant="secondary">
{formatRole(roleInfo.role)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{getRoleDescription(roleInfo.role)} {getRoleDescription(roleInfo.role)}
</p> </p>
<p className="text-muted-foreground/80 mt-1 text-xs"> {index < (user.roles?.length || 0) - 1 && <Separator className="my-2" />}
Granted{" "}
{new Date(roleInfo.grantedAt).toLocaleDateString()}
</p>
</div>
</div> </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">
<Separator /> <div className="flex items-center gap-2 mb-1 text-primary font-medium">
<Shield className="h-3 w-3" />
<div className="text-center"> <span>Role Management</span>
<p className="text-muted-foreground text-xs"> </div>
Need additional permissions?{" "} System roles are managed by administrators. Contact support if you need access adjustments.
<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> </div>
) : ( ) : (
<div className="py-6 text-center"> <div className="text-center py-4">
<div className="bg-muted mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg"> <p className="text-sm font-medium">No Roles Assigned</p>
<Shield className="text-muted-foreground h-6 w-6" /> <p className="text-xs text-muted-foreground mt-1">Contact an admin to request access.</p>
</div> <Button size="sm" variant="outline" className="mt-3 w-full">Request Access</Button>
<p className="mb-1 text-sm font-medium">No Roles Assigned</p>
<p className="text-muted-foreground text-xs">
You don&apos;t have any system roles yet. Contact an
administrator to get access to HRIStudio features.
</p>
<Button size="sm" variant="outline">
Request Access
</Button>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </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>
<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> </div>
</div> </div>
@@ -218,13 +185,17 @@ function ProfileContent({ user }: { user: ProfileUser }) {
} }
export default function ProfilePage() { export default function ProfilePage() {
const { data: session } = useSession(); const { data: session, status } = useSession();
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
{ label: "Profile" }, { label: "Profile" },
]); ]);
if (status === "loading") {
return <div className="p-8 text-muted-foreground animate-pulse">Loading profile...</div>;
}
if (!session?.user) { if (!session?.user) {
redirect("/auth/signin"); redirect("/auth/signin");
} }

View File

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

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; 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 Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -16,6 +16,7 @@ import {
QuickActions, QuickActions,
StatsGrid, StatsGrid,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -167,17 +168,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
return ( return (
<EntityView> <EntityView>
{/* Header */} {/* Header */}
<EntityViewHeader <PageHeader
title={study.name} title={study.name}
subtitle={study.description ?? undefined} description={study.description ?? undefined}
icon="Building" icon={Building}
status={{ badges={[
{
label: statusInfo?.label ?? "Unknown", label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary", variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "FileText", }
}} ]}
actions={ actions={
<> <div className="flex items-center gap-2">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}> <Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@@ -190,7 +192,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
New Experiment New Experiment
</Link> </Link>
</Button> </Button>
</> </div>
} }
/> />

View File

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

View File

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

View File

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

View File

@@ -39,11 +39,15 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
id: "timestamp", id: "timestamp",
header: "Time", header: "Time",
accessorKey: "timestamp", accessorKey: "timestamp",
size: 90,
meta: {
style: { width: '90px', minWidth: '90px' }
},
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.timestamp); const date = new Date(row.original.timestamp);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col py-0.5">
<span className="font-mono font-medium"> <span className="font-mono font-medium text-xs">
{formatRelativeTime(row.original.timestamp, startTime)} {formatRelativeTime(row.original.timestamp, startTime)}
</span> </span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block"> <span className="text-[10px] text-muted-foreground hidden group-hover:block">
@@ -56,6 +60,10 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{ {
accessorKey: "eventType", accessorKey: "eventType",
header: "Event Type", header: "Event Type",
size: 160,
meta: {
style: { width: '160px', minWidth: '160px' }
},
cell: ({ row }) => { cell: ({ row }) => {
const type = row.getValue("eventType") as string; const type = row.getValue("eventType") as string;
const isError = type.includes("error"); const isError = type.includes("error");
@@ -63,25 +71,33 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
const isRobot = type.includes("robot"); const isRobot = type.includes("robot");
const isStep = type.includes("step"); 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; let Icon = Activity;
if (isError) Icon = AlertTriangle; 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 (isRobot) Icon = Bot;
else if (isStep) Icon = Flag; else if (isStep) Icon = Flag;
else if (type.includes("note")) Icon = MessageSquare; else if (isObservation) Icon = MessageSquare;
else if (type.includes("completed")) Icon = CheckCircle; else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
return ( return (
<div className="flex items-center py-0.5">
<Badge variant="outline" className={cn( <Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5", "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", 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", (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", 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" 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" /> <Icon className="h-3 w-3" />
{type.replace(/_/g, " ")} {type.replace(/_/g, " ")}
</Badge> </Badge>
</div>
); );
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
@@ -93,14 +109,42 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
header: "Details", header: "Details",
cell: ({ row }) => { cell: ({ row }) => {
const data = row.original.data; 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;
// Simplistic view for now: JSON stringify but truncated? // Wrapper for density and alignment
// Or meaningful extraction based on event type. 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 ( 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"> <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>;
}
return (
<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()} {JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code> </code>
</Wrapper>
); );
}, },
}, },

View File

@@ -1,22 +1,61 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { DataTable } from "~/components/ui/data-table"; import {
import { type TrialEvent, eventsColumns } from "./events-columns"; Table,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input"; 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 { interface EventsDataTableProps {
data: TrialEvent[]; data: TrialEvent[];
startTime?: Date; 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) { export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const { seekTo, events, currentEventIndex } = usePlayback();
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all"); const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>(""); const [globalFilter, setGlobalFilter] = React.useState<string>("");
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
// Enhanced filtering logic // Enhanced filtering logic
const filteredData = React.useMemo(() => { const filteredData = React.useMemo(() => {
return data.filter(event => { return data.filter(event => {
@@ -40,9 +79,47 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
}); });
}, [data, eventTypeFilter, globalFilter]); }, [data, eventTypeFilter, globalFilter]);
// Custom Filters UI // Active Event Logic & Auto-scroll
const filters = ( // Match filtered events with global playback "active event" via ID
<div className="flex items-center gap-2"> 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">
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}> <Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]"> <SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" /> <SelectValue placeholder="All Events" />
@@ -55,49 +132,164 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
<SelectItem value="robot">Robot Actions</SelectItem> <SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem> <SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem> <SelectItem value="error">Errors</SelectItem>
<SelectItem value="annotation">Notes</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="text-xs text-muted-foreground mr-2">
{filteredData.length} events
</div>
</div>
<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 ( return (
<div className="space-y-4"> <TableRow
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering. key={event.id || index}
Since we implemented custom external filtering for "type" dropdown and "global" search, ref={(el) => {
we pass the filtered data directly. if (event.id) rowRefs.current[event.id] = el;
}}
However, the shared DataTable component has a `searchKey` prop that drives an internal Input. className={cn(
If we want to use OUR custom search input (to search JSON data), we should probably NOT use "cursor-pointer h-auto border-l-2 border-transparent transition-colors",
DataTable's internal search or pass a custom filter. isActive
? "bg-muted border-l-primary"
The shared DataTable's `searchKey` only filters a specific column string value. : "hover:bg-muted/50"
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 onClick={() => handleRowClick(event)}
additional dropdowns, but we might want to REPLACE the standard search input. >
<TableCell className="py-1 align-top w-[100px]">
Looking at `DataTable` implementation: <div className="flex flex-col">
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`, <span className="font-mono font-medium text-xs">
no input is rendered, and we can put ours in `filters`. {formatRelativeTime(event.timestamp, startTime)}
*/} </span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
<div className="flex items-center justify-between"> {new Date(event.timestamp).toLocaleTimeString()}
<div className="flex flex-1 items-center space-x-2"> </span>
<Input </div>
placeholder="Search event data..." </TableCell>
value={globalFilter} <TableCell className="py-1 align-top w-[180px]">
onChange={(e) => setGlobalFilter(e.target.value)} <div className="flex items-center">
className="h-8 w-[150px] lg:w-[250px]" <Badge variant="outline" className={cn(
/> "capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
{filters} 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> </div>
<DataTable
columns={columns}
data={filteredData}
// No searchKey, we handle it externally
isLoading={false}
/>
</div> </div>
); );
} }

View File

@@ -83,20 +83,21 @@ export function EventTimeline() {
}, [effectiveDuration]); }, [effectiveDuration]);
const getEventIcon = (type: string) => { 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("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("completed")) return <CheckCircle className="h-4 w-4" />;
if (type.includes("start")) return <Flag 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" />; if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
return <Activity className="h-4 w-4" />; return <Activity className="h-4 w-4" />;
}; };
const getEventColor = (type: string) => { 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("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("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("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"; if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
return "bg-slate-100 text-slate-600 border-slate-200"; return "bg-slate-100 text-slate-600 border-slate-200";
}; };
@@ -132,19 +133,37 @@ export function EventTimeline() {
{sortedEvents.map((event, i) => { {sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime()); 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 ( return (
<Tooltip key={i}> <Tooltip key={i}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <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}%` }} style={{ left: `${pct}%` }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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( <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) getEventColor(event.eventType)
)}> )}>
{getEventIcon(event.eventType)} {getEventIcon(event.eventType)}
@@ -156,9 +175,9 @@ export function EventTimeline() {
<div className="text-[10px] font-mono opacity-70 mb-1"> <div className="text-[10px] font-mono opacity-70 mb-1">
{new Date(event.timestamp).toLocaleTimeString()} {new Date(event.timestamp).toLocaleTimeString()}
</div> </div>
{!!event.data && ( {!!details && (
<div className="bg-muted/50 p-1 rounded font-mono text-[9px] max-w-[200px] break-all"> <div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
{JSON.stringify(event.data as object).slice(0, 100)} {details}
</div> </div>
)} )}
</TooltipContent> </TooltipContent>

View File

@@ -85,13 +85,14 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
<video <video
ref={videoRef} ref={videoRef}
src={src} src={src}
controls
muted={muted}
className="w-full h-full object-contain" className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onWaiting={handleWaiting} onWaiting={handleWaiting}
onPlaying={handlePlaying} onPlaying={handlePlaying}
onEnded={handleEnded} onEnded={handleEnded}
onClick={togglePlay}
/> />
{/* Overlay Controls (Visible on Hover/Pause) */} {/* 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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import Link from "next/link"; import Link from "next/link";
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react"; import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -31,7 +32,7 @@ interface TrialAnalysisViewProps {
participant: { participantCode: string }; participant: { participantCode: string };
eventCount?: number; eventCount?: number;
mediaCount?: number; mediaCount?: number;
media?: { url: string; contentType: string }[]; media?: { url: string; mediaType: string; format?: string; contentType?: string }[];
}; };
backHref: string; backHref: string;
} }
@@ -41,6 +42,8 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
const { data: events = [] } = api.trials.getEvents.useQuery({ const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id, trialId: trial.id,
limit: 1000 limit: 1000
}, {
refetchInterval: 5000
}); });
// Auto-print effect // 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; const videoUrl = videoMedia?.url;
// Metrics // Metrics
@@ -64,7 +67,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
return ( return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}> <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 */} {/* Header Context */}
<PageHeader <PageHeader
title={trial.experiment.name} title={trial.experiment.name}
@@ -185,65 +188,56 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
} }
/> />
{/* Metrics Header */} {/* Top Section: Metrics & Optional Video Grid */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4" id="tour-trial-metrics"> <div className="flex flex-col xl:flex-row gap-3 shrink-0">
<Card> <Card id="tour-trial-metrics" className="shadow-sm flex-1">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0"> <CardContent className="p-0 h-full">
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle> <div className="flex flex-row divide-x h-full">
<Clock className="h-4 w-4 text-blue-500" /> <div className="flex-1 flex flex-col p-3 px-4 justify-center">
</CardHeader> <p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<CardContent> <Clock className="h-3.5 w-3.5 text-blue-500" /> Duration
<div className="text-2xl font-bold"> </p>
{trial.duration ? ( <p className="text-base font-bold">
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> {trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
) : ( </p>
"--:--"
)}
</div> </div>
<p className="text-xs text-muted-foreground">Total session time</p> <div className="flex-1 flex flex-col p-3 px-4 justify-center">
</CardContent> <p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
</Card> <Bot className="h-3.5 w-3.5 text-purple-500" /> Robot Actions
</p>
<Card> <p className="text-base font-bold">{robotActionCount}</p>
<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>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <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( <span className={cn(
"inline-block h-2 w-2 rounded-full", "inline-block h-2 w-2 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500" trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} /> )} />
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)} {trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </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> </div>
{/* Main Workspace: Vertical Layout */} {/* Main Workspace: Vertical Layout */}
@@ -254,51 +248,89 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
<EventTimeline /> <EventTimeline />
</div> </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 */} {/* 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-1 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"> <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"> <div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" /> <TabsList className="h-8">
<h3 className="font-semibold text-sm">Event Log</h3> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="Filter events..." placeholder="Filter..."
className="h-8 w-[200px]" className="h-7 w-[150px] text-xs"
disabled disabled
style={{ display: 'none' }} 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> </div>
<div className="flex-1 min-h-0">
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="p-4"> <div className="p-0">
<EventsDataTable <EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))} data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined} startTime={trial.startedAt ?? undefined}
/> />
</div> </div>
</ScrollArea> </ScrollArea>
</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>
</ResizablePanel>
</ResizablePanelGroup>
</div> </div>
</div> </div>
</PlaybackProvider> </PlaybackProvider>

View File

@@ -10,6 +10,7 @@ import {
Play, Play,
Target, Target,
Users, Users,
SkipForward
} from "lucide-react"; } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -30,6 +31,8 @@ interface TrialProgressProps {
parameters?: Record<string, unknown>; parameters?: Record<string, unknown>;
}>; }>;
currentStepIndex: number; currentStepIndex: number;
completedSteps: Set<number>;
skippedSteps: Set<number>;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed"; trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
} }
@@ -71,6 +74,8 @@ const stepTypeConfig = {
export function TrialProgress({ export function TrialProgress({
steps, steps,
currentStepIndex, currentStepIndex,
completedSteps,
skippedSteps,
trialStatus, trialStatus,
}: TrialProgressProps) { }: TrialProgressProps) {
if (!steps || steps.length === 0) { if (!steps || steps.length === 0) {
@@ -93,7 +98,7 @@ export function TrialProgress({
? 0 ? 0
: ((currentStepIndex + 1) / steps.length) * 100; : ((currentStepIndex + 1) / steps.length) * 100;
const completedSteps = const completedCount =
trialStatus === "completed" trialStatus === "completed"
? steps.length ? steps.length
: trialStatus === "aborted" || trialStatus === "failed" : trialStatus === "aborted" || trialStatus === "failed"
@@ -102,12 +107,19 @@ export function TrialProgress({
const getStepStatus = (index: number) => { const getStepStatus = (index: number) => {
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted"; if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
if (trialStatus === "completed" || index < currentStepIndex) if (trialStatus === "completed") return "completed";
return "completed";
if (skippedSteps.has(index)) return "skipped";
if (completedSteps.has(index)) return "completed";
if (index === currentStepIndex && trialStatus === "in_progress") if (index === currentStepIndex && trialStatus === "in_progress")
return "active"; return "active";
if (index === currentStepIndex && trialStatus === "scheduled") if (index === currentStepIndex && trialStatus === "scheduled")
return "pending"; return "pending";
// Default fallback if jumping around without explicitly adding to sets
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
return "upcoming"; return "upcoming";
}; };
@@ -145,6 +157,14 @@ export function TrialProgress({
borderColor: "border-red-300", borderColor: "border-red-300",
textColor: "text-red-800", 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 default: // upcoming
return { return {
icon: Circle, icon: Circle,
@@ -171,7 +191,7 @@ export function TrialProgress({
</CardTitle> </CardTitle>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{completedSteps}/{steps.length} steps {completedCount}/{steps.length} steps
</Badge> </Badge>
{totalDuration > 0 && ( {totalDuration > 0 && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
@@ -191,8 +211,7 @@ export function TrialProgress({
</div> </div>
<Progress <Progress
value={progress} value={progress}
className={`h-2 ${ className={`h-2 ${trialStatus === "completed"
trialStatus === "completed"
? "bg-green-100" ? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed" : trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100" ? "bg-red-100"
@@ -236,8 +255,7 @@ export function TrialProgress({
{/* Connection Line */} {/* Connection Line */}
{index < steps.length - 1 && ( {index < steps.length - 1 && (
<div <div
className={`absolute top-12 left-6 h-6 w-0.5 ${ className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
getStepStatus(index + 1) === "completed" ||
(getStepStatus(index + 1) === "active" && (getStepStatus(index + 1) === "active" &&
status === "completed") status === "completed")
? "bg-green-300" ? "bg-green-300"
@@ -248,8 +266,7 @@ export function TrialProgress({
{/* Step Card */} {/* Step Card */}
<div <div
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${ className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200` ? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed" : status === "completed"
? `${statusConfig.bgColor} ${statusConfig.borderColor}` ? `${statusConfig.bgColor} ${statusConfig.borderColor}`
@@ -261,8 +278,7 @@ export function TrialProgress({
{/* Step Number & Status */} {/* Step Number & Status */}
<div className="flex-shrink-0 space-y-1"> <div className="flex-shrink-0 space-y-1">
<div <div
className={`flex h-8 w-12 items-center justify-center rounded-lg ${ className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
status === "active"
? statusConfig.bgColor ? statusConfig.bgColor
: status === "completed" : status === "completed"
? "bg-green-100" ? "bg-green-100"
@@ -272,8 +288,7 @@ export function TrialProgress({
}`} }`}
> >
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${status === "active"
status === "active"
? statusConfig.textColor ? statusConfig.textColor
: status === "completed" : status === "completed"
? "text-green-700" ? "text-green-700"
@@ -297,8 +312,7 @@ export function TrialProgress({
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h5 <h5
className={`truncate font-medium ${ className={`truncate font-medium ${status === "active"
status === "active"
? "text-slate-900" ? "text-slate-900"
: status === "completed" : status === "completed"
? "text-green-900" ? "text-green-900"
@@ -352,6 +366,12 @@ export function TrialProgress({
<span>Completed</span> <span>Completed</span>
</div> </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> </div>
</div> </div>
@@ -365,7 +385,7 @@ export function TrialProgress({
<div className="grid grid-cols-3 gap-4 text-center"> <div className="grid grid-cols-3 gap-4 text-center">
<div> <div>
<div className="text-2xl font-bold text-green-600"> <div className="text-2xl font-bold text-green-600">
{completedSteps} {completedCount}
</div> </div>
<div className="text-xs text-slate-600">Completed</div> <div className="text-xs text-slate-600">Completed</div>
</div> </div>
@@ -378,7 +398,7 @@ export function TrialProgress({
<div> <div>
<div className="text-2xl font-bold text-slate-600"> <div className="text-2xl font-bold text-slate-600">
{steps.length - {steps.length -
completedSteps - completedCount -
(trialStatus === "in_progress" ? 1 : 0)} (trialStatus === "in_progress" ? 1 : 0)}
</div> </div>
<div className="text-xs text-slate-600">Remaining</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 { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel"; import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel"; import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane"; import { WizardObservationPane } from "./panels/WizardObservationPane";
import { WebcamPanel } from "./panels/WebcamPanel";
import { TrialStatusBar } from "./panels/TrialStatusBar"; import { TrialStatusBar } from "./panels/TrialStatusBar";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos"; import { useWizardRos } from "~/hooks/useWizardRos";
@@ -121,20 +121,22 @@ export const WizardInterface = React.memo(function WizardInterface({
const [completedActionsCount, setCompletedActionsCount] = useState(0); const [completedActionsCount, setCompletedActionsCount] = useState(0);
// Collapse state for panels // Collapse state for panels
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = 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 // Reset completed actions when step changes
useEffect(() => { useEffect(() => {
setCompletedActionsCount(0); setCompletedActionsCount(0);
}, [currentStepIndex]); }, [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 // Track the last response value from wizard_wait_for_response for branching
const [lastResponse, setLastResponse] = useState<string | null>(null); const [lastResponse, setLastResponse] = useState<string | null>(null);
const [isPaused, setIsPaused] = useState(false);
const utils = api.useUtils();
// Get experiment steps from API // Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery( const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -492,16 +494,27 @@ export const WizardInterface = React.memo(function WizardInterface({
const handlePauseTrial = async () => { const handlePauseTrial = async () => {
try { try {
await pauseTrialMutation.mutateAsync({ id: trial.id }); await pauseTrialMutation.mutateAsync({ id: trial.id });
logEventMutation.mutate({ setIsPaused(true);
trialId: trial.id, toast.info("Trial paused");
type: "trial_paused",
data: { timestamp: new Date() }
});
} catch (error) { } catch (error) {
console.error("Failed to pause trial:", 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) => { const handleNextStep = (targetIndex?: number) => {
// If explicit target provided (from branching choice), use it // If explicit target provided (from branching choice), use it
if (typeof targetIndex === 'number') { 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); setCurrentStepIndex(targetIndex);
setCompletedActionsCount(0); setCompletedActionsCount(0);
return; return;
@@ -590,6 +621,13 @@ export const WizardInterface = React.memo(function WizardInterface({
// Default: Linear progression // Default: Linear progression
const nextIndex = currentStepIndex + 1; const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) { if (nextIndex < steps.length) {
// Mark current step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
// Log step change // Log step change
logEventMutation.mutate({ logEventMutation.mutate({
trialId: trial.id, trialId: trial.id,
@@ -600,6 +638,7 @@ export const WizardInterface = React.memo(function WizardInterface({
fromStepId: currentStep?.id, fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id, toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name, 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 () => { const handleCompleteTrial = async () => {
try { try {
// Mark final step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
await completeTrialMutation.mutateAsync({ id: trial.id }); 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 // Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id }); archiveTrialMutation.mutate({ id: trial.id });
// Immediately navigate to analysis
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
} catch (error) { } catch (error) {
console.error("Failed to complete trial:", 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") category: String(parameters?.category || "quick_note")
}); });
} else { } 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({ await logEventMutation.mutateAsync({
trialId: trial.id, trialId: trial.id,
type: "action_executed", type: "action_executed",
data: { data: {
actionId, actionId,
actionName,
actionType,
parameters parameters
} }
}); });
@@ -734,6 +858,22 @@ export const WizardInterface = React.memo(function WizardInterface({
) => { ) => {
try { try {
setIsExecutingAction(true); 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 // Try direct WebSocket execution first for better performance
if (rosConnected) { if (rosConnected) {
try { try {
@@ -778,18 +918,12 @@ export const WizardInterface = React.memo(function WizardInterface({
} }
} }
} else { } else {
// Use tRPC execution if WebSocket not connected // Not connected - show error and don't try to execute
await executeRobotActionMutation.mutateAsync({ const errorMsg = "Robot not connected. Cannot execute action.";
trialId: trial.id, toast.error(errorMsg);
pluginName, console.warn(errorMsg);
actionId, // Throw to stop execution flow
parameters, throw new Error(errorMsg);
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} }
} catch (error) { } catch (error) {
console.error("Failed to execute robot action:", error); console.error("Failed to execute robot action:", error);
@@ -825,7 +959,7 @@ export const WizardInterface = React.memo(function WizardInterface({
// Generic skip logging // Generic skip logging
await logEventMutation.mutateAsync({ await logEventMutation.mutateAsync({
trialId: trial.id, trialId: trial.id,
type: "action_skipped", type: "intervention_action_skipped",
data: { data: {
actionId, actionId,
parameters parameters
@@ -842,9 +976,17 @@ export const WizardInterface = React.memo(function WizardInterface({
toast.error("Failed to skip action"); 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 ( return (
@@ -869,13 +1011,13 @@ export const WizardInterface = React.memo(function WizardInterface({
{trial.status === "in_progress" && ( {trial.status === "in_progress" && (
<> <>
<Button <Button
variant="outline" variant={isPaused ? "default" : "outline"}
size="sm" size="sm"
onClick={handlePauseTrial} onClick={isPaused ? handleResumeTrial : handlePauseTrial}
className="gap-2" className="gap-2"
> >
<Pause className="h-4 w-4" /> {isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
Pause {isPaused ? "Resume" : "Pause"}
</Button> </Button>
<Button <Button
@@ -922,61 +1064,12 @@ export const WizardInterface = React.memo(function WizardInterface({
className="flex-none px-2 pb-2" className="flex-none px-2 pb-2"
/> />
{/* Main Grid - 2 rows */} {/* Main Grid - Single Row */}
<div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2"> <div className="flex-1 min-h-0 flex 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>
)}
{/* Center - Tabbed Workspace */}
{/* Center - Execution Workspace */} {/* Center - Execution Workspace */}
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm"> <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 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"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">Trial Execution</span> <span className="text-sm font-medium">Trial Execution</span>
{currentStep && ( {currentStep && (
@@ -998,21 +1091,23 @@ export const WizardInterface = React.memo(function WizardInterface({
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={() => setRightCollapsed(false)} onClick={() => setRightCollapsed(false)}
title="Open Robot Status" title="Open Status & Tools"
> >
<PanelRightOpen className="h-4 w-4" /> <PanelRightOpen className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10"> <div className="flex-1 overflow-auto bg-muted/10 pb-0">
<div id="tour-wizard-timeline" className="h-full"> <div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel <WizardExecutionPanel
trial={trial} trial={trial}
currentStep={currentStep} currentStep={currentStep}
steps={steps} steps={steps}
currentStepIndex={currentStepIndex} currentStepIndex={currentStepIndex}
completedStepIndices={completedSteps}
trialEvents={trialEvents} trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)} isPaused={isPaused}
onStepSelect={handleStepSelect}
onExecuteAction={handleExecuteAction} onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction} onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab} activeTab={executionPanelTab}
@@ -1025,16 +1120,19 @@ export const WizardInterface = React.memo(function WizardInterface({
onCompleteTrial={handleCompleteTrial} onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'} readOnly={trial.status === 'completed' || _userRole === 'observer'}
rosConnected={rosConnected} rosConnected={rosConnected}
onLogEvent={handleLogEvent}
/> />
</div> </div>
</div> </div>
</div> </div>
{/* Right Sidebar - Robot Status (Collapsible) */} {/* Right Sidebar - Tools Tabs (Collapsible) */}
{!rightCollapsed && ( <div className={cn(
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80"> "flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-[350px] lg:w-[400px]",
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30"> rightCollapsed && "hidden"
<span className="text-sm font-medium">Robot Control & Status</span> )}>
<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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -1044,8 +1142,29 @@ export const WizardInterface = React.memo(function WizardInterface({
<PanelRightClose className="h-4 w-4" /> <PanelRightClose className="h-4 w-4" />
</Button> </Button>
</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"> <div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div id="tour-wizard-robot-status" className="h-full"> <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 <WizardMonitoringPanel
rosConnected={rosConnected} rosConnected={rosConnected}
rosConnecting={rosConnecting} rosConnecting={rosConnecting}
@@ -1058,54 +1177,15 @@ export const WizardInterface = React.memo(function WizardInterface({
onExecuteRobotAction={handleExecuteRobotAction} onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId} studyId={trial.experiment.studyId}
trialId={trial.id} trialId={trial.id}
trialStatus={trial.status}
readOnly={trial.status === 'completed' || _userRole === 'observer'} readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
</TabsContent>
</Tabs>
</div> </div>
</div> </div>
</div> </div>
)}
</div> </div>
{/* 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" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setObsCollapsed(true)}
>
<ChevronDown 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}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
/>
</div>
</div>
)}
{
obsCollapsed && (
<Button
variant="outline"
size="sm"
onClick={() => setObsCollapsed(false)}
className="w-full flex-none"
>
<ChevronUp className="h-4 w-4 mr-2" />
Show Observations
</Button>
)
}
</div >
</div >
); );
}); });

View File

@@ -16,7 +16,7 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "~/trpc/react"; 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 [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]); const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isCameraEnabled, setIsCameraEnabled] = useState(false); const [isCameraEnabled, setIsCameraEnabled] = useState(false);
@@ -31,6 +31,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
// TRPC mutation for presigned URL // TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation(); 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( const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => { (mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== "")); setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
@@ -38,7 +42,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
[setDevices], [setDevices],
); );
const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => { React.useEffect(() => {
setIsMounted(true);
navigator.mediaDevices.enumerateDevices().then(handleDevices); navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]); }, [handleDevices]);
@@ -54,6 +61,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
setIsCameraEnabled(false); 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 = () => { const handleStartRecording = () => {
if (!webcamRef.current?.stream) return; if (!webcamRef.current?.stream) return;
@@ -78,6 +109,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
recorder.start(); recorder.start();
mediaRecorderRef.current = recorder; mediaRecorderRef.current = recorder;
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_started",
data: { action: "recording_started" }
});
}
toast.success("Recording started"); toast.success("Recording started");
} catch (e) { } catch (e) {
console.error("Failed to start recorder:", e); console.error("Failed to start recorder:", e);
@@ -90,6 +128,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
if (mediaRecorderRef.current && isRecording) { if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop(); mediaRecorderRef.current.stop();
setIsRecording(false); 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) { 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"); toast.success("Recording uploaded successfully");
@@ -137,7 +205,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
{!readOnly && ( {!readOnly && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devices.length > 0 && ( {devices.length > 0 && isMounted && (
<Select <Select
value={deviceId ?? undefined} value={deviceId ?? undefined}
onValueChange={setDeviceId} onValueChange={setDeviceId}
@@ -217,6 +285,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
width="100%" width="100%"
height="100%" height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }} videoConstraints={{ deviceId: deviceId ?? undefined }}
onUserMedia={handleUserMedia}
onUserMediaError={(err) => setError(String(err))} onUserMediaError={(err) => setError(String(err))}
className="object-contain w-full h-full" className="object-contain w-full h-full"
/> />

View File

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

View File

@@ -113,7 +113,11 @@ interface WizardExecutionPanelProps {
completedActionsCount: number; completedActionsCount: number;
onActionCompleted: () => void; onActionCompleted: () => void;
readOnly?: boolean; readOnly?: boolean;
isPaused?: boolean;
rosConnected?: boolean; rosConnected?: boolean;
completedStepIndices?: Set<number>;
skippedStepIndices?: Set<number>;
onLogEvent?: (type: string, data?: any) => void;
} }
export function WizardExecutionPanel({ export function WizardExecutionPanel({
@@ -134,12 +138,17 @@ export function WizardExecutionPanel({
completedActionsCount, completedActionsCount,
onActionCompleted, onActionCompleted,
readOnly = false, readOnly = false,
isPaused = false,
rosConnected, rosConnected,
completedStepIndices = new Set(),
skippedStepIndices = new Set(),
onLogEvent,
}: WizardExecutionPanelProps) { }: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render // Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0); // const [completedCount, setCompletedCount] = React.useState(0);
const activeActionIndex = completedActionsCount; const isStepCompleted = completedStepIndices.has(currentStepIndex);
const activeActionIndex = isStepCompleted ? 9999 : completedActionsCount;
// Auto-scroll to active action // Auto-scroll to active action
const activeActionRef = React.useRef<HTMLDivElement>(null); const activeActionRef = React.useRef<HTMLDivElement>(null);
@@ -210,13 +219,29 @@ export function WizardExecutionPanel({
// Active trial state // Active trial state
return ( 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 */} {/* Horizontal Step Progress Bar */}
<div className="flex-none border-b bg-muted/30 p-3"> <div className="flex-none border-b bg-muted/30 p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-2"> <div className="flex items-center gap-2 overflow-x-auto pb-2">
{steps.map((step, idx) => { {steps.map((step, idx) => {
const isCurrent = idx === currentStepIndex; 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; const isUpcoming = idx > currentStepIndex;
return ( return (
@@ -233,6 +258,8 @@ export function WizardExecutionPanel({
? "border-primary bg-primary/10 shadow-sm" ? "border-primary bg-primary/10 shadow-sm"
: isCompleted : isCompleted
? "border-primary/30 bg-primary/5 hover:bg-primary/10" ? "border-primary/30 bg-primary/5 hover:bg-primary/10"
: isSkipped
? "border-muted-foreground/30 bg-muted/20 border-dashed"
: "border-muted-foreground/20 bg-background hover:bg-muted/50" : "border-muted-foreground/20 bg-background hover:bg-muted/50"
} }
${readOnly ? "cursor-default" : "cursor-pointer"} ${readOnly ? "cursor-default" : "cursor-pointer"}
@@ -244,6 +271,8 @@ export function WizardExecutionPanel({
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
${isCompleted ${isCompleted
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: isSkipped
? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
: isCurrent : isCurrent
? "bg-primary text-primary-foreground ring-2 ring-primary/20" ? "bg-primary text-primary-foreground ring-2 ring-primary/20"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
@@ -348,6 +377,7 @@ export function WizardExecutionPanel({
readOnly={readOnly} readOnly={readOnly}
isExecuting={isExecuting} isExecuting={isExecuting}
isRobotConnected={rosConnected} isRobotConnected={rosConnected}
onLogEvent={onLogEvent}
/> />
</div> </div>
); );

View File

@@ -14,7 +14,6 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { WebcamPanel } from "./WebcamPanel";
import { RobotActionsPanel } from "../RobotActionsPanel"; import { RobotActionsPanel } from "../RobotActionsPanel";
interface WizardMonitoringPanelProps { interface WizardMonitoringPanelProps {
@@ -44,6 +43,7 @@ interface WizardMonitoringPanelProps {
) => Promise<void>; ) => Promise<void>;
studyId?: string; studyId?: string;
trialId?: string; trialId?: string;
trialStatus?: string;
readOnly?: boolean; readOnly?: boolean;
} }
@@ -59,6 +59,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
onExecuteRobotAction, onExecuteRobotAction,
studyId, studyId,
trialId, trialId,
trialStatus,
readOnly = false, readOnly = false,
}: WizardMonitoringPanelProps) { }: WizardMonitoringPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true); const [autonomousLife, setAutonomousLife] = React.useState(true);
@@ -78,12 +79,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
} }
}, [onSetAutonomousLife]); }, [onSetAutonomousLife]);
return ( return (
<div className="flex h-full flex-col gap-2 p-2"> <div className="flex h-full flex-col 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>
{/* Robot Controls - Scrollable */} {/* Robot Controls - Scrollable */}
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col"> <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"> <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, category?: string,
tags?: string[], tags?: string[],
) => Promise<void>; ) => Promise<void>;
onFlagIntervention?: () => Promise<void> | void;
isSubmitting?: boolean; isSubmitting?: boolean;
readOnly?: boolean; readOnly?: boolean;
@@ -36,6 +37,7 @@ interface WizardObservationPaneProps {
export function WizardObservationPane({ export function WizardObservationPane({
onAddAnnotation, onAddAnnotation,
onFlagIntervention,
isSubmitting = false, isSubmitting = false,
trialEvents = [], trialEvents = [],
readOnly = false, readOnly = false,
@@ -118,11 +120,23 @@ export function WizardObservationPane({
size="sm" size="sm"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly} disabled={isSubmitting || !note.trim() || readOnly}
className="h-8" className="h-8 shrink-0"
> >
<Send className="mr-2 h-3 w-3" /> <Send className="mr-2 h-3 w-3" />
Add Note Add Note
</Button> </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> </div>
{tags.length > 0 && ( {tags.length > 0 && (

View File

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

View File

@@ -300,7 +300,12 @@ export const trialsRouter = createTRPCRouter({
return { return {
...m, ...m,
url, // Add the signed URL to the response 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", "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 const [trial] = await db
.update(trials) .update(trials)
.set({ .set({
status: "completed", status: "completed",
completedAt: new Date(), completedAt: new Date(),
duration: durationSeconds,
notes: input.notes, notes: input.notes,
}) })
.where(eq(trials.id, input.id)) .where(eq(trials.id, input.id))