mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: enhance experiment designer action definitions, refactor trial analysis UI, and update video playback controls
This commit is contained in:
@@ -92,6 +92,12 @@ function AnalysisPageContent() {
|
||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||
eventCount: (trial as any).eventCount,
|
||||
mediaCount: (trial as any).mediaCount,
|
||||
media: trial.media?.map(m => ({
|
||||
...m,
|
||||
mediaType: m.mediaType ?? "video",
|
||||
format: m.format ?? undefined,
|
||||
contentType: m.contentType ?? undefined
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Users,
|
||||
Radio,
|
||||
Gamepad2,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
User,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -69,14 +75,13 @@ export default function DashboardPage() {
|
||||
studyId: studyFilter ?? undefined,
|
||||
});
|
||||
|
||||
const { data: scheduledTrials } = api.trials.list.useQuery({
|
||||
studyId: studyFilter ?? undefined,
|
||||
status: "scheduled",
|
||||
limit: 5,
|
||||
});
|
||||
const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery(
|
||||
{ studyId: studyFilter ?? undefined },
|
||||
{ refetchInterval: 5000 }
|
||||
);
|
||||
|
||||
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
|
||||
limit: 10,
|
||||
limit: 15,
|
||||
studyId: studyFilter ?? undefined,
|
||||
});
|
||||
|
||||
@@ -164,10 +169,10 @@ export default function DashboardPage() {
|
||||
iconColor="text-violet-500"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total Studies"
|
||||
value={userStudies.length}
|
||||
icon={FlaskConical}
|
||||
description="Active research projects"
|
||||
title="Total Interventions"
|
||||
value={stats?.totalInterventions ?? 0}
|
||||
icon={Gamepad2}
|
||||
description="Wizard manual overrides"
|
||||
iconColor="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -250,21 +255,44 @@ export default function DashboardPage() {
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-4">
|
||||
{recentActivity?.map((activity) => (
|
||||
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
|
||||
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
|
||||
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase">
|
||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||
{recentActivity?.map((activity) => {
|
||||
let eventColor = "bg-primary/30 ring-background";
|
||||
let Icon = Activity;
|
||||
if (activity.type === "trial_started") {
|
||||
eventColor = "bg-blue-500 ring-blue-100 dark:ring-blue-900";
|
||||
Icon = PlayCircle;
|
||||
} else if (activity.type === "trial_completed") {
|
||||
eventColor = "bg-green-500 ring-green-100 dark:ring-green-900";
|
||||
Icon = CheckCircle;
|
||||
} else if (activity.type === "error") {
|
||||
eventColor = "bg-red-500 ring-red-100 dark:ring-red-900";
|
||||
Icon = AlertTriangle;
|
||||
} else if (activity.type === "intervention") {
|
||||
eventColor = "bg-orange-500 ring-orange-100 dark:ring-orange-900";
|
||||
Icon = Gamepad2;
|
||||
} else if (activity.type === "annotation") {
|
||||
eventColor = "bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
|
||||
Icon = MessageSquare;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={activity.id} className="relative pl-6 pb-4 border-l last:border-0 border-muted-foreground/20">
|
||||
<span className={`absolute left-[-9px] top-0 h-4 w-4 rounded-full flex items-center justify-center ring-4 ${eventColor}`}>
|
||||
<Icon className="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
<div className="mb-0.5 text-sm font-medium leading-none">{activity.title}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase font-mono">
|
||||
{formatDistanceToNow(new Date(activity.time), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{!recentActivity?.length && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<Clock className="h-10 w-10 mb-3 opacity-20" />
|
||||
<p>No recent activity recorded.</p>
|
||||
<p className="text-sm">Start a trial to see updates here.</p>
|
||||
<p className="text-xs mt-1">Start a trial to see experiment events stream here.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -274,52 +302,58 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
{/* Scheduled Trials (Restored from previous page.tsx but styled to fit) */}
|
||||
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
|
||||
{/* Live Trials */}
|
||||
<Card id="tour-live-trials" className={`${liveTrials && liveTrials.length > 0 ? 'border-primary shadow-sm bg-primary/5' : 'border-muted/40'} col-span-4 transition-colors duration-500`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Upcoming Sessions</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Live Sessions
|
||||
{liveTrials && liveTrials.length > 0 && <span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
You have {scheduledTrials?.length ?? 0} scheduled trials coming up.
|
||||
Currently running trials in the Wizard interface
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/trials?status=scheduled">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
||||
<Link href="/trials">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!scheduledTrials?.length ? (
|
||||
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center animate-in fade-in-50">
|
||||
<Calendar className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No scheduled trials found.</p>
|
||||
{!liveTrials?.length ? (
|
||||
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed border-muted-foreground/30 text-center animate-in fade-in-50 bg-background/50">
|
||||
<Radio className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No trials are currently running.</p>
|
||||
<Button variant="link" size="sm" asChild className="mt-1">
|
||||
<Link href="/trials/new">Schedule a Trial</Link>
|
||||
<Link href="/trials">Start a Trial</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{scheduledTrials.map((trial) => (
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 bg-muted/10 hover:bg-muted/50 hover:shadow-sm transition-all duration-200">
|
||||
{liveTrials.map((trial) => (
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border border-primary/20 p-3 bg-background shadow-sm hover:shadow transition-all duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<Calendar className="h-5 w-5" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400">
|
||||
<Radio className="h-5 w-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{trial.participant.participantCode}
|
||||
<span className="ml-2 text-muted-foreground font-normal text-xs">• {trial.experiment.name}</span>
|
||||
{trial.participantCode}
|
||||
<span className="ml-2 text-muted-foreground font-normal text-xs">• {trial.experimentName}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{trial.scheduledAt ? format(trial.scheduledAt, "MMM d, h:mm a") : "Unscheduled"}
|
||||
Started {trial.startedAt ? formatDistanceToNow(new Date(trial.startedAt), { addSuffix: true }) : 'just now'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="gap-2" asChild>
|
||||
<Button size="sm" className="gap-2 bg-primary hover:bg-primary/90" asChild>
|
||||
<Link href={`/wizard/${trial.id}`}>
|
||||
<Play className="h-3.5 w-3.5" /> Start
|
||||
<Play className="h-3.5 w-3.5" /> Spectate / Jump In
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -61,15 +61,15 @@ describe("Control Flow Persistence", () => {
|
||||
// console.log("DB Rows Conditions:", JSON.stringify(dbRows[0].conditions, null, 2));
|
||||
// END DEBUG
|
||||
|
||||
expect(dbRows[0].type).toBe("conditional");
|
||||
expect((dbRows[0].conditions as any).options).toHaveLength(2);
|
||||
expect(dbRows[0]!.type).toBe("conditional");
|
||||
expect((dbRows[0]!.conditions as any).options).toHaveLength(2);
|
||||
|
||||
// Simulate Load
|
||||
const hydratedSteps = convertDatabaseToSteps(dbRows);
|
||||
|
||||
expect(hydratedSteps[0].type).toBe("conditional");
|
||||
expect((hydratedSteps[0].trigger.conditions as any).options).toHaveLength(2);
|
||||
expect((hydratedSteps[0].trigger.conditions as any).options[0].label).toBe("Yes");
|
||||
expect(hydratedSteps[0]!.type).toBe("conditional");
|
||||
expect((hydratedSteps[0]!.trigger.conditions as any).options).toHaveLength(2);
|
||||
expect((hydratedSteps[0]!.trigger.conditions as any).options[0].label).toBe("Yes");
|
||||
});
|
||||
|
||||
it("should persist loop configuration", () => {
|
||||
@@ -97,14 +97,14 @@ describe("Control Flow Persistence", () => {
|
||||
const dbRows = convertStepsToDatabase(originalSteps);
|
||||
|
||||
// Note: 'loop' type is mapped to 'conditional' in DB, but detailed conditions should survive
|
||||
expect(dbRows[0].type).toBe("conditional");
|
||||
expect((dbRows[0].conditions as any).loop.iterations).toBe(5);
|
||||
expect(dbRows[0]!.type).toBe("conditional");
|
||||
expect((dbRows[0]!.conditions as any).loop.iterations).toBe(5);
|
||||
|
||||
// Simulate Load
|
||||
const hydratedSteps = convertDatabaseToSteps(dbRows);
|
||||
|
||||
// Checking data integrity
|
||||
expect((hydratedSteps[0].trigger.conditions as any).loop).toBeDefined();
|
||||
expect((hydratedSteps[0].trigger.conditions as any).loop.iterations).toBe(5);
|
||||
expect((hydratedSteps[0]!.trigger.conditions as any).loop).toBeDefined();
|
||||
expect((hydratedSteps[0]!.trigger.conditions as any).loop.iterations).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ describe("Hashing Utilities", () => {
|
||||
id: "act-1",
|
||||
type: "log",
|
||||
name: "Log",
|
||||
category: "observation",
|
||||
parameters: { message: "A" },
|
||||
source: { kind: "core", baseActionId: "log" },
|
||||
execution: { transport: "internal" }
|
||||
@@ -87,6 +88,7 @@ describe("Hashing Utilities", () => {
|
||||
id: "act-1",
|
||||
type: "log",
|
||||
name: "Log",
|
||||
category: "observation",
|
||||
parameters: { message: "A" },
|
||||
source: { kind: "core", baseActionId: "log" },
|
||||
execution: { transport: "internal" }
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("Designer Store Integration", () => {
|
||||
|
||||
store.getState().upsertStep(step);
|
||||
expect(store.getState().steps).toHaveLength(1);
|
||||
expect(store.getState().steps[0].id).toBe("step-1");
|
||||
expect(store.getState().steps[0]!.id).toBe("step-1");
|
||||
});
|
||||
|
||||
it("should update an existing step", () => {
|
||||
@@ -55,7 +55,7 @@ describe("Designer Store Integration", () => {
|
||||
store.getState().upsertStep(updatedStep);
|
||||
|
||||
expect(store.getState().steps).toHaveLength(1);
|
||||
expect(store.getState().steps[0].name).toBe("Updated Step");
|
||||
expect(store.getState().steps[0]!.name).toBe("Updated Step");
|
||||
});
|
||||
|
||||
it("should remove a step", () => {
|
||||
@@ -100,12 +100,12 @@ describe("Designer Store Integration", () => {
|
||||
store.getState().reorderStep(0, 1);
|
||||
|
||||
const steps = store.getState().steps;
|
||||
expect(steps[0].id).toBe("step-2");
|
||||
expect(steps[1].id).toBe("step-1");
|
||||
expect(steps[0]!.id).toBe("step-2");
|
||||
expect(steps[1]!.id).toBe("step-1");
|
||||
|
||||
// Orders should be updated
|
||||
expect(steps[0].order).toBe(0);
|
||||
expect(steps[1].order).toBe(1);
|
||||
expect(steps[0]!.order).toBe(0);
|
||||
expect(steps[1]!.order).toBe(1);
|
||||
});
|
||||
|
||||
it("should upsert an action into a step", () => {
|
||||
@@ -124,6 +124,7 @@ describe("Designer Store Integration", () => {
|
||||
id: "act-1",
|
||||
type: "log",
|
||||
name: "Log",
|
||||
category: "observation",
|
||||
parameters: {},
|
||||
source: { kind: "core", baseActionId: "log" },
|
||||
execution: { transport: "internal" }
|
||||
@@ -132,7 +133,7 @@ describe("Designer Store Integration", () => {
|
||||
store.getState().upsertAction("step-1", action);
|
||||
|
||||
const storedStep = store.getState().steps[0];
|
||||
expect(storedStep.actions).toHaveLength(1);
|
||||
expect(storedStep.actions[0].id).toBe("act-1");
|
||||
expect(storedStep!.actions).toHaveLength(1);
|
||||
expect(storedStep!.actions[0]!.id).toBe("act-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,13 @@ const mockActionDef: ActionDefinition = {
|
||||
id: "core.log",
|
||||
name: "Log Info",
|
||||
type: "log",
|
||||
category: "utility",
|
||||
description: "Logs information",
|
||||
category: "observation",
|
||||
icon: "lucide-info",
|
||||
color: "blue",
|
||||
parameters: [
|
||||
{ id: "message", name: "Message", type: "text", required: true },
|
||||
{ id: "level", name: "Level", type: "select", options: ["info", "warn", "error"], default: "info" }
|
||||
{ id: "level", name: "Level", type: "select", options: ["info", "warn", "error"], value: "info" }
|
||||
],
|
||||
source: { kind: "core", baseActionId: "log" }
|
||||
};
|
||||
@@ -33,7 +36,7 @@ describe("Experiment Validators", () => {
|
||||
it("should fail if experiment has no steps", () => {
|
||||
const result = validateExperimentDesign([], { steps: [], actionDefinitions: [] });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0].message).toContain("at least one step");
|
||||
expect(result.issues[0]!.message).toContain("at least one step");
|
||||
});
|
||||
|
||||
it("should fail if step name is empty", () => {
|
||||
@@ -55,7 +58,7 @@ describe("Experiment Validators", () => {
|
||||
id: "act-1",
|
||||
type: "log",
|
||||
name: "Log",
|
||||
order: 0,
|
||||
category: "observation",
|
||||
parameters: {}, // Missing 'message'
|
||||
source: { kind: "core", baseActionId: "log" },
|
||||
execution: { transport: "internal" }
|
||||
@@ -75,7 +78,7 @@ describe("Experiment Validators", () => {
|
||||
id: "act-1",
|
||||
type: "log",
|
||||
name: "Log",
|
||||
order: 0,
|
||||
category: "observation",
|
||||
parameters: { message: "Hello" },
|
||||
source: { kind: "core", baseActionId: "log" },
|
||||
execution: { transport: "internal" }
|
||||
@@ -104,7 +107,7 @@ describe("Experiment Validators", () => {
|
||||
id: "act-1",
|
||||
type: "math",
|
||||
name: "Math",
|
||||
order: 0,
|
||||
category: "observation",
|
||||
parameters: { val: 15 }, // Too high
|
||||
source: { kind: "core", baseActionId: "math" },
|
||||
execution: { transport: "internal" }
|
||||
@@ -116,7 +119,7 @@ describe("Experiment Validators", () => {
|
||||
actionDefinitions: [rangeActionDef]
|
||||
});
|
||||
|
||||
expect(issues[0].message).toContain("must be at most 10");
|
||||
expect(issues[0]!.message).toContain("must be at most 10");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
studies,
|
||||
studyMembers,
|
||||
trials,
|
||||
trialEvents,
|
||||
users,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
@@ -39,39 +40,105 @@ export const dashboardRouter = createTRPCRouter({
|
||||
|
||||
// Build where conditions
|
||||
const whereConditions = input.studyId
|
||||
? eq(activityLogs.studyId, input.studyId)
|
||||
: inArray(activityLogs.studyId, studyIds);
|
||||
? and(
|
||||
eq(experiments.studyId, input.studyId),
|
||||
inArray(
|
||||
trialEvents.eventType,
|
||||
['trial_started', 'trial_completed', 'intervention', 'error', 'annotation']
|
||||
)
|
||||
)
|
||||
: and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
inArray(
|
||||
trialEvents.eventType,
|
||||
['trial_started', 'trial_completed', 'intervention', 'error', 'annotation']
|
||||
)
|
||||
);
|
||||
|
||||
// Get recent activity logs
|
||||
// Get recent interesting trial events
|
||||
const activities = await ctx.db
|
||||
.select({
|
||||
id: activityLogs.id,
|
||||
action: activityLogs.action,
|
||||
description: activityLogs.description,
|
||||
createdAt: activityLogs.createdAt,
|
||||
id: trialEvents.id,
|
||||
type: trialEvents.eventType,
|
||||
data: trialEvents.data,
|
||||
timestamp: trialEvents.timestamp,
|
||||
trialId: trials.id,
|
||||
experimentName: experiments.name,
|
||||
participantCode: participants.participantCode,
|
||||
user: {
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
},
|
||||
study: {
|
||||
name: studies.name,
|
||||
},
|
||||
})
|
||||
.from(activityLogs)
|
||||
.innerJoin(users, eq(activityLogs.userId, users.id))
|
||||
.innerJoin(studies, eq(activityLogs.studyId, studies.id))
|
||||
.from(trialEvents)
|
||||
.innerJoin(trials, eq(trialEvents.trialId, trials.id))
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.innerJoin(participants, eq(trials.participantId, participants.id))
|
||||
.leftJoin(users, eq(trialEvents.createdBy, users.id))
|
||||
.where(whereConditions)
|
||||
.orderBy(desc(activityLogs.createdAt))
|
||||
.orderBy(desc(trialEvents.timestamp))
|
||||
.limit(input.limit);
|
||||
|
||||
return activities.map((activity) => ({
|
||||
id: activity.id,
|
||||
type: activity.action,
|
||||
title: activity.description,
|
||||
description: `${activity.study.name} - ${activity.user.name}`,
|
||||
time: activity.createdAt,
|
||||
status: "info" as const,
|
||||
}));
|
||||
return activities.map((activity) => {
|
||||
let title = activity.type.replace(/_/g, " ");
|
||||
title = title.charAt(0).toUpperCase() + title.slice(1);
|
||||
|
||||
let description = `${activity.participantCode} • ${activity.experimentName}`;
|
||||
if (activity.user?.name) {
|
||||
description += ` • by ${activity.user.name}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: activity.id,
|
||||
type: activity.type,
|
||||
title: title,
|
||||
description: description,
|
||||
time: activity.timestamp,
|
||||
status: activity.type === "error" ? "error" : activity.type === "trial_completed" ? "success" : "info" as const,
|
||||
data: activity.data,
|
||||
trialId: activity.trialId,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getLiveTrials: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get studies the user has access to
|
||||
const accessibleStudies = await ctx.db
|
||||
.select({ studyId: studyMembers.studyId })
|
||||
.from(studyMembers)
|
||||
.where(eq(studyMembers.userId, userId));
|
||||
|
||||
const studyIds = accessibleStudies.map((s) => s.studyId);
|
||||
|
||||
if (studyIds.length === 0) return [];
|
||||
|
||||
const whereConditions = input.studyId
|
||||
? and(eq(experiments.studyId, input.studyId), eq(trials.status, "in_progress"))
|
||||
: and(inArray(experiments.studyId, studyIds), eq(trials.status, "in_progress"));
|
||||
|
||||
const live = await ctx.db
|
||||
.select({
|
||||
id: trials.id,
|
||||
startedAt: trials.startedAt,
|
||||
experimentName: experiments.name,
|
||||
participantCode: participants.participantCode,
|
||||
studyName: studies.name,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.innerJoin(participants, eq(trials.participantId, participants.id))
|
||||
.innerJoin(studies, eq(experiments.studyId, studies.id))
|
||||
.where(whereConditions)
|
||||
.orderBy(desc(trials.startedAt));
|
||||
|
||||
return live;
|
||||
}),
|
||||
|
||||
getStudyProgress: protectedProcedure
|
||||
@@ -87,10 +154,10 @@ export const dashboardRouter = createTRPCRouter({
|
||||
// Build where conditions
|
||||
const whereConditions = input.studyId
|
||||
? and(
|
||||
eq(studyMembers.userId, userId),
|
||||
eq(studies.status, "active"),
|
||||
eq(studies.id, input.studyId),
|
||||
)
|
||||
eq(studyMembers.userId, userId),
|
||||
eq(studies.status, "active"),
|
||||
eq(studies.id, input.studyId),
|
||||
)
|
||||
: and(eq(studyMembers.userId, userId), eq(studies.status, "active"));
|
||||
|
||||
// Get studies the user has access to with participant counts
|
||||
@@ -116,19 +183,19 @@ export const dashboardRouter = createTRPCRouter({
|
||||
const trialCounts =
|
||||
studyIds.length > 0
|
||||
? await ctx.db
|
||||
.select({
|
||||
studyId: experiments.studyId,
|
||||
completedTrials: count(trials.id),
|
||||
})
|
||||
.from(experiments)
|
||||
.innerJoin(trials, eq(experiments.id, trials.experimentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
),
|
||||
)
|
||||
.groupBy(experiments.studyId)
|
||||
.select({
|
||||
studyId: experiments.studyId,
|
||||
completedTrials: count(trials.id),
|
||||
})
|
||||
.from(experiments)
|
||||
.innerJoin(trials, eq(experiments.id, trials.experimentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
),
|
||||
)
|
||||
.groupBy(experiments.studyId)
|
||||
: [];
|
||||
|
||||
const trialCountMap = new Map(
|
||||
@@ -144,9 +211,9 @@ export const dashboardRouter = createTRPCRouter({
|
||||
const progress =
|
||||
totalParticipants > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((completedTrials / totalParticipants) * 100),
|
||||
)
|
||||
100,
|
||||
Math.round((completedTrials / totalParticipants) * 100),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
@@ -262,6 +329,19 @@ export const dashboardRouter = createTRPCRouter({
|
||||
),
|
||||
);
|
||||
|
||||
// Get total interventions
|
||||
const [interventionsCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trialEvents)
|
||||
.innerJoin(trials, eq(trialEvents.trialId, trials.id))
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trialEvents.eventType, "intervention"),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
totalStudies: studyCount?.count ?? 0,
|
||||
totalExperiments: experimentCount?.count ?? 0,
|
||||
@@ -270,6 +350,7 @@ export const dashboardRouter = createTRPCRouter({
|
||||
activeTrials: activeTrialsCount?.count ?? 0,
|
||||
scheduledTrials: scheduledTrialsCount?.count ?? 0,
|
||||
completedToday: completedTodayCount?.count ?? 0,
|
||||
totalInterventions: interventionsCount?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -315,10 +396,10 @@ export const dashboardRouter = createTRPCRouter({
|
||||
return {
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
: null,
|
||||
systemRoles: systemRoles.map((r) => r.role),
|
||||
studyMemberships: studyMemberships.map((m) => ({
|
||||
|
||||
@@ -211,12 +211,6 @@ export class TrialExecutionEngine {
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
|
||||
// Log trial start event
|
||||
await this.logTrialEvent(trialId, "trial_started", {
|
||||
wizardId: context.wizardId,
|
||||
startTime: context.startTime.toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -983,13 +977,6 @@ export class TrialExecutionEngine {
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
|
||||
// Log completion
|
||||
await this.logTrialEvent(trialId, "trial_completed", {
|
||||
endTime: endTime.toISOString(),
|
||||
duration,
|
||||
totalSteps: this.stepDefinitions.get(trialId)?.length || 0,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.activeTrials.delete(trialId);
|
||||
this.stepDefinitions.delete(trialId);
|
||||
|
||||
Reference in New Issue
Block a user