feat: enhance experiment designer action definitions, refactor trial analysis UI, and update video playback controls

This commit is contained in:
2026-03-01 19:00:23 -05:00
parent 60d4fae72c
commit 61af467cc8
22 changed files with 591 additions and 269 deletions

View File

@@ -14,6 +14,7 @@ import {
Home,
LogOut,
MoreHorizontal,
PlayCircle,
Puzzle,
Settings,
TestTube,
@@ -23,6 +24,7 @@ import {
} from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
import { useTour } from "~/components/onboarding/TourProvider";
import {
DropdownMenu,
@@ -118,7 +120,13 @@ const helpItems = [
{
title: "Help Center",
url: "/help",
icon: BookOpen, // Make sure to import this from lucide-react
icon: BookOpen,
},
{
title: "Interactive Tour",
url: "#tour",
icon: PlayCircle,
action: "tour",
},
];
@@ -138,6 +146,8 @@ export function AppSidebar({
const { selectedStudyId, userStudies, selectStudy, refreshStudyData, isLoadingUserStudies } =
useStudyManagement();
const { startTour } = useTour();
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
const hasAutoSelected = useRef(false);
@@ -566,7 +576,15 @@ export function AppSidebar({
{helpItems.map((item) => {
const isActive = pathname.startsWith(item.url);
const menuButton = (
const menuButton = item.action === "tour" ? (
<SidebarMenuButton
onClick={() => startTour("full_platform")}
isActive={false}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />

View File

@@ -115,6 +115,7 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
id="tour-analytics-filter"
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
@@ -141,7 +142,7 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
</div>
</div>
<div className="rounded-md border bg-background">
<div id="tour-analytics-table" className="rounded-md border bg-background">
<div>
<Table className="w-full">
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">

View File

@@ -43,16 +43,13 @@ export function EventTimeline() {
const startTime = useMemo(() => {
if (contextStartTime) return new Date(contextStartTime).getTime();
if (sortedEvents.length > 0) return new Date(sortedEvents[0]!.timestamp).getTime();
return 0;
}, [contextStartTime, sortedEvents]);
}, [contextStartTime]);
const effectiveDuration = useMemo(() => {
if (duration > 0) return duration * 1000;
if (sortedEvents.length === 0) return 60000; // 1 min default
const end = new Date(sortedEvents[sortedEvents.length - 1]!.timestamp).getTime();
return Math.max(end - startTime, 1000);
}, [duration, sortedEvents, startTime]);
return 60000; // 1 min default
}, [duration]);
// Dimensions
const containerRef = useRef<HTMLDivElement>(null);

View File

@@ -15,6 +15,7 @@ interface PlaybackContextType {
isPlaying: boolean;
playbackRate: number;
startTime?: Date;
endTime?: Date;
// Actions
play: () => void;
@@ -44,11 +45,23 @@ interface PlaybackProviderProps {
children: React.ReactNode;
events?: TrialEvent[];
startTime?: Date;
endTime?: Date;
}
export function PlaybackProvider({ children, events = [], startTime }: PlaybackProviderProps) {
export function PlaybackProvider({ children, events = [], startTime, endTime }: PlaybackProviderProps) {
const trialDuration = React.useMemo(() => {
if (startTime && endTime) return (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000;
return 0;
}, [startTime, endTime]);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [duration, setDuration] = useState(trialDuration);
useEffect(() => {
if (trialDuration > 0 && duration === 0) {
setDuration(trialDuration);
}
}, [trialDuration, duration]);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1);
@@ -105,6 +118,8 @@ export function PlaybackProvider({ children, events = [], startTime }: PlaybackP
setCurrentTime,
events,
currentEventIndex,
startTime,
endTime,
};
return (

View File

@@ -70,7 +70,6 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
setIsBuffering(false);
}
};
@@ -85,7 +84,6 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
<video
ref={videoRef}
src={src}
controls
muted={muted}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}

View File

@@ -192,34 +192,34 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
<div className="flex flex-col xl:flex-row gap-3 shrink-0">
<Card id="tour-trial-metrics" className="shadow-sm flex-1">
<CardContent className="p-0 h-full">
<div className="flex flex-row divide-x h-full">
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<Clock className="h-3.5 w-3.5 text-blue-500" /> Duration
<div className="grid grid-cols-2 grid-rows-2 h-full divide-x divide-y">
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Clock className="h-4 w-4 text-blue-500" /> Duration
</p>
<p className="text-base font-bold">
<p className="text-2xl font-bold">
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
</p>
</div>
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<Bot className="h-3.5 w-3.5 text-purple-500" /> Robot Actions
<div className="flex flex-col p-4 md:p-6 justify-center border-t-0">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
</p>
<p className="text-base font-bold">{robotActionCount}</p>
<p className="text-2xl font-bold">{robotActionCount}</p>
</div>
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<AlertTriangle className="h-3.5 w-3.5 text-orange-500" /> Interventions
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<AlertTriangle className="h-4 w-4 text-orange-500" /> Interventions
</p>
<p className="text-base font-bold">{interventionCount}</p>
<p className="text-2xl 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
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Activity className="h-4 w-4 text-green-500" /> Completeness
</p>
<div className="flex items-center gap-1.5 text-base font-bold">
<div className="flex items-center gap-2 text-2xl font-bold">
<span className={cn(
"inline-block h-2 w-2 rounded-full",
"inline-block h-3 w-3 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status === 'completed' ? '100%' : 'Incomplete'}
@@ -244,7 +244,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background flex flex-col">
{/* FIXED TIMELINE: Always visible at top */}
<div className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-1">
<div id="tour-trial-timeline" className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-1">
<EventTimeline />
</div>

View File

@@ -1149,7 +1149,7 @@ export const WizardInterface = React.memo(function WizardInterface({
<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">
<TabsContent value="camera_obs" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
<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>
@@ -1164,7 +1164,7 @@ export const WizardInterface = React.memo(function WizardInterface({
</div>
</TabsContent>
<TabsContent value="robot" className="flex-1 m-0 h-full overflow-hidden">
<TabsContent value="robot" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}

View File

@@ -4,21 +4,12 @@ import React, { useCallback, useRef, useState } from "react";
import Webcam from "react-webcam";
import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner";
import { api } from "~/trpc/react";
export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
const [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [uploading, setUploading] = useState(false);
@@ -35,19 +26,11 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
const saveRecordingMutation = api.storage.saveRecording.useMutation();
const logEventMutation = api.trials.logEvent.useMutation();
const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
},
[setDevices],
);
const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => {
setIsMounted(true);
navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]);
}, []);
const handleEnableCamera = () => {
setIsCameraEnabled(true);
@@ -87,6 +70,10 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
console.log("Already recording, skipping start");
return;
}
setIsRecording(true);
chunksRef.current = [];
@@ -125,7 +112,7 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
};
const handleStopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
if (mediaRecorderRef.current && isRecording && mediaRecorderRef.current.state === "recording") {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (trialId) {
@@ -197,32 +184,10 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Camera className="h-4 w-4" />
Webcam Feed
</h2>
<div className="flex items-center justify-end border-b px-2 py-1 bg-muted/10 h-10 shrink-0">
{!readOnly && (
<div className="flex items-center gap-2">
{devices.length > 0 && isMounted && (
<Select
value={deviceId ?? undefined}
onValueChange={setDeviceId}
disabled={!isCameraEnabled || isRecording}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="Select Camera" />
</SelectTrigger>
<SelectContent>
{devices.map((device, key) => (
<SelectItem key={key} value={device.deviceId} className="text-xs">
{device.label || `Camera ${key + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isCameraEnabled && (
!isRecording ? (
@@ -284,7 +249,6 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
audio={false}
width="100%"
height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }}
onUserMedia={handleUserMedia}
onUserMediaError={(err) => setError(String(err))}
className="object-contain w-full h-full"
@@ -334,6 +298,6 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
</div>
)}
</div>
</div>
</div >
);
}

View File

@@ -82,10 +82,6 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
<div className="flex h-full flex-col p-2">
{/* Robot Controls - Scrollable */}
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">
<Bot className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Robot Control</span>
</div>
<ScrollArea className="flex-1">
<div className="space-y-4 p-3">
{/* Robot Status */}

View File

@@ -47,6 +47,14 @@ export function WizardObservationPane({
const [tags, setTags] = useState<string[]>([]);
const [currentTag, setCurrentTag] = useState("");
const placeholders: Record<string, string> = {
observation: "Type your observation here...",
participant_behavior: "Describe the participant's behavior...",
system_issue: "Describe the system issue...",
success: "Describe the success...",
failure: "Describe the failure...",
};
const handleSubmit = async () => {
if (!note.trim()) return;
@@ -72,10 +80,10 @@ export function WizardObservationPane({
return (
<div className="flex h-full flex-col bg-background">
<div className="flex-1 flex flex-col p-4 m-0">
<div className="flex-1 flex flex-col p-4 m-0 overflow-hidden">
<div className="flex flex-1 flex-col gap-2">
<Textarea
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
placeholder={readOnly ? "Session is read-only" : (placeholders[category] || "Type your observation here...")}
className="flex-1 resize-none font-mono text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -83,60 +91,66 @@ export function WizardObservationPane({
disabled={readOnly}
/>
<div className="flex items-center gap-2">
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="observation">Observation</SelectItem>
<SelectItem value="participant_behavior">Behavior</SelectItem>
<SelectItem value="system_issue">System Issue</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-col gap-2 shrink-0">
{/* Top Line: Category & Tags */}
<div className="flex items-center gap-2 w-full">
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
<SelectTrigger className="w-[140px] h-8 text-xs shrink-0">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="observation">Observation</SelectItem>
<SelectItem value="participant_behavior">Behavior</SelectItem>
<SelectItem value="system_issue">System Issue</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
<Tag className={`h-3 w-3 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
<input
type="text"
placeholder={readOnly ? "" : "Add tags..."}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
onBlur={addTag}
disabled={readOnly}
/>
<div className="flex flex-1 min-w-[80px] items-center gap-2 rounded-md border px-2 h-8">
<Tag className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
<input
type="text"
placeholder={readOnly ? "" : "Add tags..."}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed w-full min-w-0"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
onBlur={addTag}
disabled={readOnly}
/>
</div>
</div>
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8 shrink-0"
>
<Send className="mr-2 h-3 w-3" />
Add Note
</Button>
{onFlagIntervention && (
{/* Bottom Line: Actions */}
<div className="flex items-center justify-end gap-2 w-full">
{onFlagIntervention && (
<Button
size="sm"
variant="outline"
onClick={() => onFlagIntervention()}
disabled={readOnly}
className="h-8 shrink-0 flex-1 sm:flex-none 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>
)}
<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"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8 shrink-0 flex-1 sm:flex-none"
>
<AlertTriangle className="mr-2 h-3 w-3" />
Intervention
<Send className="mr-2 h-3 w-3" />
Save Note
</Button>
)}
</div>
</div>
{tags.length > 0 && (