Enhance HRIStudio with immersive experiment designer and comprehensive documentation updates

- Introduced a new immersive experiment designer using React Flow, providing a professional-grade visual flow editor for creating experiments.
- Added detailed documentation for the flow designer connections and ordering system, emphasizing its advantages and implementation details.
- Updated existing documentation to reflect the latest features and improvements, including a streamlined README and quick reference guide.
- Consolidated participant type definitions into a new file for better organization and clarity.

Features:
- Enhanced user experience with a node-based interface for experiment design.
- Comprehensive documentation supporting new features and development practices.

Breaking Changes: None - existing functionality remains intact.
This commit is contained in:
2025-08-05 00:48:36 -04:00
parent 433c1c4517
commit b1684a0c69
44 changed files with 4654 additions and 5310 deletions

View File

@@ -1,52 +1,50 @@
"use client"
"use client";
import * as React from "react"
import {
BarChart3,
TrendingUp,
TrendingDown,
Activity,
import {
Activity,
BarChart3,
Calendar,
Download,
Filter,
Download
} from "lucide-react"
TrendingDown,
TrendingUp,
} from "lucide-react";
import { Button } from "~/components/ui/button"
import { StudyGuard } from "~/components/dashboard/study-guard";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Badge } from "~/components/ui/badge"
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import { StudyGuard } from "~/components/dashboard/study-guard"
} from "~/components/ui/select";
// Mock chart component - replace with actual charting library
function MockChart({ title, data }: { title: string; data: number[] }) {
const maxValue = Math.max(...data)
const maxValue = Math.max(...data);
return (
<div className="space-y-2">
<h4 className="text-sm font-medium">{title}</h4>
<div className="flex items-end space-x-1 h-32">
<div className="flex h-32 items-end space-x-1">
{data.map((value, index) => (
<div
<div
key={index}
className="bg-primary rounded-t flex-1 min-h-[4px]"
className="bg-primary min-h-[4px] flex-1 rounded-t"
style={{ height: `${(value / maxValue) * 100}%` }}
/>
))}
</div>
</div>
)
);
}
function AnalyticsOverview() {
@@ -62,7 +60,7 @@ function AnalyticsOverview() {
{
title: "Avg Trial Duration",
value: "24.5m",
change: "-3%",
change: "-3%",
trend: "down",
description: "vs last month",
icon: Calendar,
@@ -71,7 +69,7 @@ function AnalyticsOverview() {
title: "Completion Rate",
value: "94.2%",
change: "+2.1%",
trend: "up",
trend: "up",
description: "vs last month",
icon: TrendingUp,
},
@@ -80,29 +78,33 @@ function AnalyticsOverview() {
value: "87.3%",
change: "+5.4%",
trend: "up",
description: "vs last month",
description: "vs last month",
icon: BarChart3,
},
]
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => (
<Card key={metric.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{metric.title}</CardTitle>
<metric.icon className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">
{metric.title}
</CardTitle>
<metric.icon className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metric.value}</div>
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
<span className={`flex items-center ${
metric.trend === "up" ? "text-green-600" : "text-red-600"
}`}>
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
<span
className={`flex items-center ${
metric.trend === "up" ? "text-green-600" : "text-red-600"
}`}
>
{metric.trend === "up" ? (
<TrendingUp className="h-3 w-3 mr-1" />
<TrendingUp className="mr-1 h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3 mr-1" />
<TrendingDown className="mr-1 h-3 w-3" />
)}
{metric.change}
</span>
@@ -112,13 +114,13 @@ function AnalyticsOverview() {
</Card>
))}
</div>
)
);
}
function ChartsSection() {
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44]
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28]
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94]
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
return (
<div className="grid gap-4 lg:grid-cols-3">
@@ -152,20 +154,22 @@ function ChartsSection() {
</CardContent>
</Card>
</div>
)
);
}
function RecentInsights() {
const insights = [
{
title: "Peak Performance Hours",
description: "Participants show 23% better performance during 10-11 AM trials",
description:
"Participants show 23% better performance during 10-11 AM trials",
type: "trend",
severity: "info",
},
{
title: "Attention Span Decline",
description: "Average attention span has decreased by 8% over the last month",
description:
"Average attention span has decreased by 8% over the last month",
type: "alert",
severity: "warning",
},
@@ -178,23 +182,23 @@ function RecentInsights() {
{
title: "Equipment Utilization",
description: "Robot interaction trials are at 85% capacity utilization",
type: "info",
type: "info",
severity: "info",
},
]
];
const getSeverityColor = (severity: string) => {
switch (severity) {
case "success":
return "bg-green-50 text-green-700 border-green-200"
return "bg-green-50 text-green-700 border-green-200";
case "warning":
return "bg-yellow-50 text-yellow-700 border-yellow-200"
return "bg-yellow-50 text-yellow-700 border-yellow-200";
case "info":
return "bg-blue-50 text-blue-700 border-blue-200"
return "bg-blue-50 text-blue-700 border-blue-200";
default:
return "bg-gray-50 text-gray-700 border-gray-200"
return "bg-gray-50 text-gray-700 border-gray-200";
}
}
};
return (
<Card>
@@ -207,18 +211,18 @@ function RecentInsights() {
<CardContent>
<div className="space-y-4">
{insights.map((insight, index) => (
<div
<div
key={index}
className={`p-4 rounded-lg border ${getSeverityColor(insight.severity)}`}
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
>
<h4 className="font-medium mb-1">{insight.title}</h4>
<h4 className="mb-1 font-medium">{insight.title}</h4>
<p className="text-sm">{insight.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
)
);
}
function AnalyticsContent() {
@@ -292,7 +296,7 @@ function AnalyticsContent() {
</Card>
</div>
</div>
)
);
}
export default function AnalyticsPage() {
@@ -301,4 +305,4 @@ export default function AnalyticsPage() {
<AnalyticsContent />
</StudyGuard>
);
}
}

View File

@@ -20,15 +20,36 @@ export default async function ExperimentDesignerPage({
}
return (
<ExperimentDesignerClient
experiment={{
...experiment,
description: experiment.description ?? "",
}}
/>
<div className="fixed inset-0 z-50">
<ExperimentDesignerClient
experiment={{
...experiment,
description: experiment.description ?? "",
}}
/>
</div>
);
} catch (error) {
console.error("Error loading experiment:", error);
notFound();
}
}
export async function generateMetadata({
params,
}: ExperimentDesignerPageProps) {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.id });
return {
title: `${experiment?.name} - Flow Designer | HRIStudio`,
description: `Design experiment protocol for ${experiment?.name} using visual flow editor`,
};
} catch {
return {
title: "Experiment Flow Designer | HRIStudio",
description: "Immersive visual experiment protocol designer",
};
}
}

View File

@@ -1,6 +1,15 @@
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle, ArrowLeft, Calendar, Edit, FileText, Mail, Play, Shield, Trash2, Users
AlertCircle,
ArrowLeft,
Calendar,
Edit,
FileText,
Mail,
Play,
Shield,
Trash2,
Users,
} from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -8,11 +17,11 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { auth } from "~/server/auth";
import { api } from "~/trpc/server";
@@ -42,7 +51,7 @@ export default async function ParticipantDetailPage({
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
const canDelete = ["administrator", "researcher"].includes(userRole);
// canDelete removed - not used in component
// Get participant's trials
const trials = await api.trials.list({
@@ -70,7 +79,7 @@ export default async function ParticipantDetailPage({
</div>
<div>
<h1 className="text-foreground text-3xl font-bold">
{participant.name || participant.participantCode}
{participant.name ?? participant.participantCode}
</h1>
<p className="text-muted-foreground text-lg">
{participant.name
@@ -151,58 +160,59 @@ export default async function ParticipantDetailPage({
</h4>
<p className="text-sm">
<Link
href={`/studies/${(participant.study as any)?.id}`}
href={`/studies/${participant.study?.id}`}
className="text-primary hover:underline"
>
{(participant.study as any)?.name}
{participant.study?.name}
</Link>
</p>
</div>
</div>
{participant.demographics &&
typeof participant.demographics === "object" &&
Object.keys(participant.demographics).length > 0 ? (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Demographics
</h4>
<div className="grid gap-4 md:grid-cols-2">
{(participant.demographics as Record<string, any>)
?.age && (
<div>
<span className="text-sm font-medium">Age:</span>{" "}
<span className="text-sm">
{String(
(
participant.demographics as Record<
string,
any
>
).age,
)}
</span>
</div>
)}
{(participant.demographics as Record<string, any>)
?.gender && (
<div>
<span className="text-sm font-medium">Gender:</span>{" "}
<span className="text-sm">
{String(
(
participant.demographics as Record<
string,
any
>
).gender,
)}
</span>
</div>
)}
</div>
typeof participant.demographics === "object" &&
participant.demographics !== null &&
Object.keys(participant.demographics).length > 0 ? (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Demographics
</h4>
<div className="grid gap-4 md:grid-cols-2">
{(() => {
const demo = participant.demographics as Record<
string,
unknown
>;
return (
<>
{demo.age && (
<div>
<span className="text-sm font-medium">
Age:
</span>{" "}
<span className="text-sm">
{typeof demo.age === "number"
? demo.age.toString()
: String(demo.age)}
</span>
</div>
)}
{demo.gender && (
<div>
<span className="text-sm font-medium">
Gender:
</span>{" "}
<span className="text-sm">
{String(demo.gender)}
</span>
</div>
)}
</>
);
})()}
</div>
) : null}
</div>
) : null}
{/* Notes */}
{participant.notes && (
@@ -228,7 +238,9 @@ export default async function ParticipantDetailPage({
</CardTitle>
{canEdit && (
<Button size="sm" asChild>
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
Schedule Trial
</Link>
</Button>
@@ -270,13 +282,10 @@ export default async function ParticipantDetailPage({
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{(trial as any).scheduledAt
? formatDistanceToNow(
(trial as any).scheduledAt,
{
addSuffix: true,
},
)
{trial.createdAt
? formatDistanceToNow(new Date(trial.createdAt), {
addSuffix: true,
})
: "Not scheduled"}
</span>
{trial.duration && (
@@ -293,11 +302,13 @@ export default async function ParticipantDetailPage({
<Play className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 font-medium">No Trials Yet</h3>
<p className="text-muted-foreground mb-4 text-sm">
This participant hasn't been assigned to any trials.
This participant hasn&apos;t been assigned to any trials.
</p>
{canEdit && (
<Button asChild>
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
Schedule First Trial
</Link>
</Button>
@@ -399,7 +410,9 @@ export default async function ParticipantDetailPage({
className="w-full justify-start"
asChild
>
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
<Play className="mr-2 h-4 w-4" />
Schedule Trial
</Link>
@@ -427,7 +440,7 @@ export default async function ParticipantDetailPage({
</div>
</div>
);
} catch (_error) {
} catch {
return notFound();
}
}

View File

@@ -252,14 +252,14 @@ export default async function StudyDetailPage({
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
<span className="text-sm font-medium text-blue-600">
{(member.user.name || member.user.email)
{(member.user.name ?? member.user.email)
.charAt(0)
.toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{member.user.name || member.user.email}
{member.user.name ?? member.user.email}
</p>
<p className="text-xs text-slate-500 capitalize">
{member.role}

View File

@@ -2,13 +2,13 @@
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import { ManagementPageLayout } from "~/components/ui/page-layout";
import { ParticipantsTable } from "~/components/participants/ParticipantsTable";
import { ManagementPageLayout } from "~/components/ui/page-layout";
import { useActiveStudy } from "~/hooks/useActiveStudy";
export default function StudyParticipantsPage() {
const params = useParams();
const studyId = params.id as string;
const studyId = typeof params.id === "string" ? params.id : "";
const { setActiveStudy, activeStudy } = useActiveStudy();
// Set the active study if it doesn't match the current route
@@ -25,7 +25,7 @@ export default function StudyParticipantsPage() {
breadcrumb={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: activeStudy?.title || "Study", href: `/studies/${studyId}` },
{ label: activeStudy?.title ?? "Study", href: `/studies/${studyId}` },
{ label: "Participants" },
]}
createButton={{

View File

@@ -2,13 +2,13 @@
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import { ManagementPageLayout } from "~/components/ui/page-layout";
import { TrialsTable } from "~/components/trials/TrialsTable";
import { ManagementPageLayout } from "~/components/ui/page-layout";
import { useActiveStudy } from "~/hooks/useActiveStudy";
export default function StudyTrialsPage() {
const params = useParams();
const studyId = params.id as string;
const studyId = typeof params.id === "string" ? params.id : "";
const { setActiveStudy, activeStudy } = useActiveStudy();
// Set the active study if it doesn't match the current route
@@ -25,7 +25,7 @@ export default function StudyTrialsPage() {
breadcrumb={[
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: activeStudy?.title || "Study", href: `/studies/${studyId}` },
{ label: activeStudy?.title ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials" },
]}
createButton={{

View File

@@ -2,8 +2,10 @@ import { eq } from "drizzle-orm";
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import {
generateFileKey,
getMimeType, uploadFile, validateFile
generateFileKey,
getMimeType,
uploadFile,
validateFile,
} from "~/lib/storage/minio";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
@@ -38,7 +40,7 @@ export async function POST(request: NextRequest) {
// Validate input
const validationResult = uploadSchema.safeParse({
trialId: trialId || undefined,
trialId: trialId ?? undefined,
category,
filename: file.name,
contentType: file.type,
@@ -111,7 +113,7 @@ export async function POST(request: NextRequest) {
const mediaCapture = await db
.insert(mediaCaptures)
.values({
trialId: validatedTrialId!, // Non-null assertion since it's validated above
trialId: validatedTrialId!, // Non-null assertion since it's validated above
format: file.type || getMimeType(file.name),
fileSize: file.size,
storagePath: fileKey,
@@ -161,7 +163,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const filename = searchParams.get("filename");
const contentType = searchParams.get("contentType");
const category = searchParams.get("category") || "document";
const category = searchParams.get("category") ?? "document";
const trialId = searchParams.get("trialId");
if (!filename) {
@@ -188,12 +190,12 @@ export async function GET(request: NextRequest) {
category,
filename,
session.user.id,
trialId || undefined,
trialId ?? undefined,
);
// Generate presigned URL for upload
const { getUploadUrl } = await import("~/lib/storage/minio");
const uploadUrl = await getUploadUrl(fileKey, contentType || undefined);
const uploadUrl = await getUploadUrl(fileKey, contentType ?? undefined);
return NextResponse.json({
success: true,
@@ -242,9 +244,7 @@ function validateFileByCategory(
return validateFile(file.name, file.size, types, maxSize);
}
function getCaptureType(
category: string,
): "video" | "audio" | "image" {
function getCaptureType(category: string): "video" | "audio" | "image" {
switch (category) {
case "video":
return "video";

View File

@@ -1,19 +1,12 @@
import { eq } from "drizzle-orm";
import { type NextRequest } from "next/server";
import { type WebSocketServer } from "ws";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
import { trialEvents, trials } from "~/server/db/schema";
// Store active WebSocket connections
const connections = new Map<string, Set<any>>();
const userConnections = new Map<
string,
{ userId: string; trialId: string; role: string }
>();
// Create WebSocket server instance
const wss: WebSocketServer | null = null;
// Store active WebSocket connections (for external WebSocket server)
// These would be used by a separate WebSocket implementation
// const connections = new Map<string, Set<WebSocket>>();
// const userConnections = new Map<
// string,
// { userId: string; trialId: string; role: string }
// >();
export const runtime = "nodejs";
@@ -48,347 +41,3 @@ export async function GET(request: NextRequest) {
},
);
}
// WebSocket connection handler (for external WebSocket server)
export async function handleWebSocketConnection(ws: any, request: any) {
try {
const url = new URL(request.url, `http://${request.headers.host}`);
const trialId = url.searchParams.get("trialId");
const token = url.searchParams.get("token");
if (!trialId || !token) {
ws.close(1008, "Missing required parameters");
return;
}
// Verify authentication
const session = await auth();
if (!session?.user) {
ws.close(1008, "Unauthorized");
return;
}
// Verify trial access
const trial = await db
.select()
.from(trials)
.where(eq(trials.id, trialId))
.limit(1);
if (!trial.length) {
ws.close(1008, "Trial not found");
return;
}
const userRole = session.user.roles?.[0]?.role;
if (
!userRole ||
!["administrator", "researcher", "wizard", "observer"].includes(userRole)
) {
ws.close(1008, "Insufficient permissions");
return;
}
const connectionId = crypto.randomUUID();
const userId = session.user.id;
// Store connection info
userConnections.set(connectionId, {
userId,
trialId,
role: userRole,
});
// Add to trial connections
if (!connections.has(trialId)) {
connections.set(trialId, new Set());
}
connections.get(trialId)!.add(ws);
// Send initial connection confirmation
ws.send(
JSON.stringify({
type: "connection_established",
data: {
connectionId,
trialId,
timestamp: new Date().toISOString(),
},
}),
);
// Send current trial status
await sendTrialStatus(ws, trialId);
ws.on("message", async (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
await handleWebSocketMessage(ws, connectionId, message);
} catch (error) {
console.error("Error handling WebSocket message:", error);
ws.send(
JSON.stringify({
type: "error",
data: {
message: "Invalid message format",
timestamp: new Date().toISOString(),
},
}),
);
}
});
ws.on("close", () => {
console.log(`WebSocket disconnected: ${connectionId}`);
// Clean up connections
const connectionInfo = userConnections.get(connectionId);
if (connectionInfo) {
const trialConnections = connections.get(connectionInfo.trialId);
if (trialConnections) {
trialConnections.delete(ws);
if (trialConnections.size === 0) {
connections.delete(connectionInfo.trialId);
}
}
userConnections.delete(connectionId);
}
});
ws.on("error", (error: Error) => {
console.error(`WebSocket error for ${connectionId}:`, error);
});
console.log(`WebSocket connected: ${connectionId} for trial ${trialId}`);
} catch (error) {
console.error("WebSocket setup error:", error);
ws.close(1011, "Internal server error");
}
}
async function handleWebSocketMessage(
ws: any,
connectionId: string,
message: any,
) {
const connectionInfo = userConnections.get(connectionId);
if (!connectionInfo) {
return;
}
const { userId, trialId, role } = connectionInfo;
switch (message.type) {
case "trial_action":
if (["wizard", "researcher", "administrator"].includes(role)) {
await handleTrialAction(trialId, userId, message.data);
broadcastToTrial(trialId, {
type: "trial_action_executed",
data: {
action: message.data,
executedBy: userId,
timestamp: new Date().toISOString(),
},
});
}
break;
case "step_transition":
if (["wizard", "researcher", "administrator"].includes(role)) {
await handleStepTransition(trialId, userId, message.data);
broadcastToTrial(trialId, {
type: "step_changed",
data: {
...message.data,
changedBy: userId,
timestamp: new Date().toISOString(),
},
});
}
break;
case "wizard_intervention":
if (["wizard", "researcher", "administrator"].includes(role)) {
await logTrialEvent(
trialId,
"wizard_intervention",
message.data,
userId,
);
broadcastToTrial(trialId, {
type: "intervention_logged",
data: {
...message.data,
interventionBy: userId,
timestamp: new Date().toISOString(),
},
});
}
break;
case "request_trial_status":
await sendTrialStatus(ws, trialId);
break;
case "heartbeat":
ws.send(
JSON.stringify({
type: "heartbeat_response",
data: {
timestamp: new Date().toISOString(),
},
}),
);
break;
default:
ws.send(
JSON.stringify({
type: "error",
data: {
message: `Unknown message type: ${message.type}`,
timestamp: new Date().toISOString(),
},
}),
);
}
}
async function handleTrialAction(
trialId: string,
userId: string,
actionData: any,
) {
try {
// Log the action as a trial event
await logTrialEvent(trialId, "wizard_action", actionData, userId);
// Update trial status if needed
if (actionData.actionType === "start_trial") {
await db
.update(trials)
.set({
status: "in_progress",
startedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(trials.id, trialId));
} else if (actionData.actionType === "complete_trial") {
await db
.update(trials)
.set({
status: "completed",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(trials.id, trialId));
} else if (actionData.actionType === "abort_trial") {
await db
.update(trials)
.set({
status: "aborted",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(trials.id, trialId));
}
} catch (error) {
console.error("Error handling trial action:", error);
throw error;
}
}
async function handleStepTransition(
trialId: string,
userId: string,
stepData: any,
) {
try {
await logTrialEvent(trialId, "step_transition", stepData, userId);
} catch (error) {
console.error("Error handling step transition:", error);
throw error;
}
}
async function logTrialEvent(
trialId: string,
eventType: string,
data: any,
userId: string,
) {
try {
await db.insert(trialEvents).values({
trialId,
eventType: eventType as "trial_start" | "trial_end" | "step_start" | "step_end" | "wizard_intervention" | "error" | "custom",
data,
createdBy: userId,
timestamp: new Date(),
});
} catch (error) {
console.error("Error logging trial event:", error);
throw error;
}
}
async function sendTrialStatus(ws: any, trialId: string) {
try {
const trial = await db
.select()
.from(trials)
.where(eq(trials.id, trialId))
.limit(1);
if (trial.length > 0) {
ws.send(
JSON.stringify({
type: "trial_status",
data: {
trial: trial[0],
timestamp: new Date().toISOString(),
},
}),
);
}
} catch (error) {
console.error("Error sending trial status:", error);
}
}
function broadcastToTrial(trialId: string, message: any) {
const trialConnections = connections.get(trialId);
if (trialConnections) {
const messageStr = JSON.stringify(message);
for (const ws of trialConnections) {
if (ws.readyState === 1) {
// WebSocket.OPEN
ws.send(messageStr);
}
}
}
}
// Utility function to broadcast trial updates
export function broadcastTrialUpdate(
trialId: string,
updateType: string,
data: any,
) {
broadcastToTrial(trialId, {
type: updateType,
data: {
...data,
timestamp: new Date().toISOString(),
},
});
}
// Cleanup orphaned connections
setInterval(() => {
for (const [connectionId, info] of userConnections.entries()) {
const trialConnections = connections.get(info.trialId);
if (!trialConnections || trialConnections.size === 0) {
userConnections.delete(connectionId);
}
}
}, 30000);