feat: Implement trial event logging, archiving, experiment soft deletion, and new analytics/event data tables.

This commit is contained in:
2026-02-10 16:14:31 -05:00
parent 0f535f6887
commit a8c868ad3f
17 changed files with 1356 additions and 567 deletions

View File

@@ -1,190 +1,15 @@
"use client";
import { useParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import {
BarChart3,
Search,
Filter,
PlayCircle,
Calendar,
Clock,
ChevronRight,
User,
LayoutGrid
} from "lucide-react";
import { Suspense, useEffect } from "react";
import { BarChart3 } from "lucide-react";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { api } from "~/trpc/react";
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { formatDistanceToNow } from "date-fns";
// -- Sub-Components --
function AnalyticsContent({
selectedTrialId,
setSelectedTrialId,
trialsList,
isLoadingList
}: {
selectedTrialId: string | null;
setSelectedTrialId: (id: string | null) => void;
trialsList: any[];
isLoadingList: boolean;
}) {
// Fetch full details of selected trial
const {
data: selectedTrial,
isLoading: isLoadingTrial,
error: trialError
} = api.trials.get.useQuery(
{ id: selectedTrialId! },
{ enabled: !!selectedTrialId }
);
// Transform trial data
const trialData = selectedTrial ? {
...selectedTrial,
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
eventCount: (selectedTrial as any).eventCount,
mediaCount: (selectedTrial as any).mediaCount,
} : null;
return (
<div className="h-[calc(100vh-140px)] flex flex-col">
{selectedTrialId ? (
isLoadingTrial ? (
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
<div className="flex flex-col items-center gap-2 animate-pulse">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-muted-foreground text-sm">Loading trial data...</span>
</div>
</div>
) : trialError ? (
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
<div className="max-w-md text-center">
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
<p className="text-sm opacity-80">{trialError.message}</p>
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
Return to Overview
</Button>
</div>
</div>
) : trialData ? (
<TrialAnalysisView trial={trialData} />
) : null
) : (
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
<StudyOverviewPlaceholder
trials={trialsList ?? []}
onSelect={(id) => setSelectedTrialId(id)}
/>
</div>
)}
</div>
);
}
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
const recentTrials = [...trials].sort((a, b) =>
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
).slice(0, 5);
return (
<div className="h-full p-8 grid place-items-center bg-muted/5">
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
{/* Left: Illustration / Prompt */}
<div className="flex flex-col justify-center space-y-4">
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
<BarChart3 className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
<CardDescription className="text-base mt-2">
Select a session from the top right to review video recordings, event logs, and metrics.
</CardDescription>
</div>
<div className="flex gap-4 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<PlayCircle className="h-4 w-4" />
Feature-rich playback
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
Synchronized timeline
</div>
</div>
</div>
{/* Right: Recent Sessions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Sessions</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[240px]">
<div className="px-4 pb-4 space-y-1">
{recentTrials.map(trial => (
<button
key={trial.id}
onClick={() => onSelect(trial.id)}
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
>
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
{trial.sessionNumber}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{trial.participant?.participantCode ?? "Unknown"}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
'bg-slate-500/10 text-slate-500 border-slate-500/20'
}`}>
{trial.status.replace('_', ' ')}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
<Calendar className="h-3 w-3" />
{new Date(trial.createdAt).toLocaleDateString()}
<span className="text-muted-foreground top-[1px] relative text-[10px]"></span>
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
</button>
))}
{recentTrials.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No sessions found.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}
// -- Main Page --
import { StudyAnalyticsDataTable } from "~/components/analytics/study-analytics-data-table";
export default function StudyAnalyticsPage() {
const params = useParams();
@@ -192,11 +17,8 @@ export default function StudyAnalyticsPage() {
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// State lifted up
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
// Fetch list of trials for the selector
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
// Fetch list of trials
const { data: trialsList, isLoading } = api.trials.list.useQuery(
{ studyId, limit: 100 },
{ enabled: !!studyId }
);
@@ -217,50 +39,30 @@ export default function StudyAnalyticsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]);
return (
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
<div className="flex-none">
<div className="space-y-6">
<PageHeader
title="Analytics"
description="Analyze trial data and replay sessions"
title="Analysis"
description="View and analyze session data across all trials"
icon={BarChart3}
actions={
<div className="flex items-center gap-2">
{/* Session Selector in Header */}
<div className="w-[300px]">
<Select
value={selectedTrialId ?? "overview"}
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
>
<SelectTrigger className="w-full h-9 text-xs">
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
<SelectValue placeholder="Select Session" />
</SelectTrigger>
<SelectContent className="max-h-[400px]" align="end">
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
Show Study Overview
</SelectItem>
{trialsList?.map((trial) => (
<SelectItem key={trial.id} value={trial.id} className="text-xs">
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
}
/>
</div>
<div className="flex-1 min-h-0 bg-transparent">
<div className="bg-transparent">
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsContent
selectedTrialId={selectedTrialId}
setSelectedTrialId={setSelectedTrialId}
trialsList={trialsList ?? []}
isLoadingList={isLoadingList}
/>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-2 animate-pulse">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-muted-foreground text-sm">Loading session data...</span>
</div>
</div>
) : (
<StudyAnalyticsDataTable data={(trialsList ?? []).map(t => ({
...t,
startedAt: t.startedAt ? new Date(t.startedAt) : null,
completedAt: t.completedAt ? new Date(t.completedAt) : null,
createdAt: new Date(t.createdAt),
}))} />
)}
</Suspense>
</div>
</div>

View File

@@ -13,6 +13,8 @@ import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react";
import Link from "next/link";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>;
}
@@ -61,6 +63,13 @@ export default async function ParticipantDetailPage({
<TabsContent value="overview">
<div className="grid gap-6 grid-cols-1">
<ParticipantConsentManager
studyId={studyId}
participantId={participantId}
consentGiven={participant.consentGiven}
consentDate={participant.consentDate}
existingConsent={participant.consents[0] ?? null}
/>
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>

View File

@@ -95,25 +95,10 @@ function AnalysisPageContent() {
};
return (
<div className="flex h-full flex-col">
<PageHeader
title="Trial Analysis"
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial Details
</Link>
</Button>
}
<TrialAnalysisView
trial={trialData}
backHref={`/studies/${studyId}/trials/${trialId}`}
/>
<div className="min-h-0 flex-1">
<TrialAnalysisView trial={trialData} />
</div>
</div>
);
}

View File

@@ -0,0 +1,319 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useState } from "react";
import {
ArrowUpDown,
MoreHorizontal,
Calendar,
Clock,
Activity,
Eye,
Video
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Badge } from "~/components/ui/badge";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
export type AnalyticsTrial = {
id: string;
sessionNumber: number;
status: string;
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
eventCount: number;
mediaCount: number;
experimentId: string;
participant: {
participantCode: string;
};
experiment: {
name: string;
studyId: string;
};
};
export const columns: ColumnDef<AnalyticsTrial>[] = [
{
accessorKey: "sessionNumber",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Session
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => <div className="font-mono text-center">#{row.getValue("sessionNumber")}</div>,
},
{
accessorKey: "participant.participantCode",
header: "Participant",
cell: ({ row }) => (
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge
variant="outline"
className={`capitalize ${status === "completed"
? "bg-green-500/10 text-green-500 border-green-500/20"
: status === "in_progress"
? "bg-blue-500/10 text-blue-500 border-blue-500/20"
: "bg-slate-500/10 text-slate-500 border-slate-500/20"
}`}
>
{status.replace("_", " ")}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Date
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"));
return (
<div className="flex flex-col">
<span className="text-sm">{date.toLocaleDateString()}</span>
<span className="text-xs text-muted-foreground">{formatDistanceToNow(date, { addSuffix: true })}</span>
</div>
)
},
},
{
accessorKey: "duration",
header: "Duration",
cell: ({ row }) => {
const duration = row.getValue("duration") as number | null;
if (!duration) return <span className="text-muted-foreground">-</span>;
const m = Math.floor(duration / 60);
const s = Math.floor(duration % 60);
return <div className="font-mono">{`${m}m ${s}s`}</div>;
},
},
{
accessorKey: "eventCount",
header: "Events",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<span>{row.getValue("eventCount")}</span>
</div>
)
},
},
{
accessorKey: "mediaCount",
header: "Media",
cell: ({ row }) => {
const count = row.getValue("mediaCount") as number;
if (count === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center gap-1">
<Video className="h-3 w-3 text-muted-foreground" />
<span>{count}</span>
</div>
)
},
},
{
id: "actions",
cell: ({ row }) => {
const trial = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}>
<Eye className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${trial.experimentId}/trials/${trial.id}`}>
View Trial Details
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
interface StudyAnalyticsDataTableProps {
data: AnalyticsTrial[];
}
export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full">
<div className="flex items-center py-4">
<Input
placeholder="Filter participants..."
value={(table.getColumn("participant.participantCode")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("participant.participantCode")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
import { ArrowUpDown, MoreHorizontal, Edit, LayoutTemplate, Trash2 } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -243,8 +243,17 @@ export const columns: ColumnDef<Experiment>[] = [
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const experiment = row.original;
cell: ({ row }) => <ExperimentActions experiment={row.original} />,
},
];
function ExperimentActions({ experiment }: { experiment: Experiment }) {
const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
utils.experiments.list.invalidate();
},
});
return (
<DropdownMenu>
@@ -256,51 +265,34 @@ export const columns: ColumnDef<Experiment>[] = [
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(experiment.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
Edit Metadata
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="mr-2 h-4 w-4" />
Designer
Design
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
<DropdownMenuItem
className="text-red-600 focus:text-red-700"
onClick={() => {
if (confirm("Are you sure you want to delete this experiment?")) {
deleteMutation.mutate({ id: experiment.id });
}
}}
>
<PlayCircle className="mr-2 h-4 w-4" />
Start Trial
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<Archive className="mr-2 h-4 w-4" />
Archive
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}
export function ExperimentsTable() {
const { selectedStudyId } = useStudyContext();

View File

@@ -27,6 +27,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
export type Experiment = {
id: string;
@@ -78,27 +79,23 @@ const statusConfig = {
};
function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
const handleDelete = async () => {
const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
toast.success("Experiment deleted successfully");
utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Failed to delete experiment: ${error.message}`);
},
});
const handleDelete = () => {
if (
window.confirm(`Are you sure you want to delete "${experiment.name}"?`)
) {
try {
// TODO: Implement delete experiment mutation
toast.success("Experiment deleted successfully");
} catch {
toast.error("Failed to delete experiment");
deleteMutation.mutate({ id: experiment.id });
}
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(experiment.id);
toast.success("Experiment ID copied to clipboard");
};
const handleStartTrial = () => {
// Navigate to new trial creation with this experiment pre-selected
window.location.href = `/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`;
};
return (
@@ -111,45 +108,20 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Metadata
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<FlaskConical className="mr-2 h-4 w-4" />
Open Designer
Design
</Link>
</DropdownMenuItem>
{experiment.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Experiment
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{experiment.status === "ready" && (
<DropdownMenuItem onClick={handleStartTrial}>
<Play className="mr-2 h-4 w-4" />
Start New Trial
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Experiment ID
</DropdownMenuItem>
{experiment.canDelete && (
<>
<DropdownMenuSeparator />
@@ -158,7 +130,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Experiment
Delete
</DropdownMenuItem>
</>
)}
@@ -315,20 +287,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
},
enableSorting: false,
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date as Date, { addSuffix: true })}
</div>
);
},
},
{
accessorKey: "updatedAt",
header: ({ column }) => (

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
import { toast } from "~/components/ui/use-toast";
import { cn } from "~/lib/utils";
import axios from "axios";
interface ConsentUploadFormProps {
studyId: string;
participantId: string;
consentFormId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function ConsentUploadForm({
studyId,
participantId,
consentFormId,
onSuccess,
onCancel,
}: ConsentUploadFormProps) {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
toast({
title: "File too large",
description: "Maximum file size is 10MB",
variant: "destructive",
});
return;
}
// Validate type
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
if (!allowedTypes.includes(selectedFile.type)) {
toast({
title: "Invalid file type",
description: "Please upload a PDF, PNG, or JPG file",
variant: "destructive",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO
await axios.put(url, file, {
headers: {
"Content-Type": file.type,
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadProgress(percentCompleted);
}
},
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast({
title: "Consent Recorded",
description: "The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast({
title: "Upload Failed",
description: error instanceof Error ? error.message : "An unexpected error occurred",
variant: "destructive",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!file ? (
<div className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-6 bg-muted/5 hover:bg-muted/10 transition-colors">
<Upload className="h-8 w-8 text-muted-foreground mb-4" />
<h3 className="font-semibold text-sm mb-1">Upload Signed Consent</h3>
<p className="text-xs text-muted-foreground mb-4 text-center">
Drag and drop or click to select<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button variant="secondary" size="sm" onClick={() => document.getElementById("consent-file-upload")?.click()}>
Select File
</Button>
</div>
) : (
<div className="border rounded-lg p-4 bg-muted/5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-primary/10 rounded flex items-center justify-center">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium line-clamp-1 break-all">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setFile(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="space-y-2 mb-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onCancel} disabled={isUploading}>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { ConsentUploadForm } from "./ConsentUploadForm";
import { FileText, Download, CheckCircle, AlertCircle, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
interface ParticipantConsentManagerProps {
studyId: string;
participantId: string;
consentGiven: boolean;
consentDate: Date | null;
existingConsent: {
id: string;
storagePath: string | null;
signedAt: Date;
consentForm: {
title: string;
version: number;
};
} | null;
}
export function ParticipantConsentManager({
studyId,
participantId,
consentGiven,
consentDate,
existingConsent,
}: ParticipantConsentManagerProps) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
// Fetch active consent forms to know which form to sign/upload against
const { data: consentForms } = api.participants.getConsentForms.useQuery({ studyId });
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
// Helper to get download URL
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
{ storagePath: existingConsent?.storagePath ?? "" },
{ enabled: false }
);
const handleDownload = async () => {
if (!existingConsent?.storagePath) return;
try {
const result = await fetchDownloadUrl();
if (result.data?.url) {
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleSuccess = () => {
setIsOpen(false);
utils.participants.get.invalidate({ id: participantId });
toast.success("Success", { description: "Consent recorded successfully" });
};
return (
<div className="rounded-lg border bg-card text-card-foreground shadow-sm">
<div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex flex-col space-y-1.5">
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
<FileText className="h-5 w-5" />
Consent Status
</h3>
<p className="text-sm text-muted-foreground">
Manage participant consent and forms.
</p>
</div>
<Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
</div>
{existingConsent && (
<p className="text-xs text-muted-foreground">
Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" variant={consentGiven ? "secondary" : "default"}>
<Upload className="mr-2 h-4 w-4" />
{consentGiven ? "Update Consent" : "Record Consent"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle>
<DialogDescription>
Upload the signed PDF or image of the consent form for this participant.
{activeForm && (
<span className="block mt-1 font-medium text-foreground">
Active Form: {activeForm.title} (v{activeForm.version})
</span>
)}
</DialogDescription>
</DialogHeader>
{activeForm ? (
<ConsentUploadForm
studyId={studyId}
participantId={participantId}
consentFormId={activeForm.id}
onSuccess={handleSuccess}
onCancel={() => setIsOpen(false)}
/>
) : (
<div className="py-4 text-center text-muted-foreground">
No active consent form found for this study. Please create one first.
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
import { ArrowUpDown, MoreHorizontal, Edit, Trash2 } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -148,30 +148,7 @@ export const columns: ColumnDef<Participant>[] = [
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{
id: "actions",
enableHiding: false,
@@ -195,23 +172,12 @@ export const columns: ColumnDef<Participant>[] = [
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(participant.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit participant
</Link >
</DropdownMenuItem >
<DropdownMenuItem disabled>
<Mail className="mr-2 h-4 w-4" />
Send consent
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />

View File

@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import * as React from "react";
import { format, formatDistanceToNow } from "date-fns";
@@ -331,33 +331,7 @@ export const columns: ColumnDef<Trial>[] = [
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
if (!date)
return <span className="text-muted-foreground text-sm">Unknown</span>;
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{
id: "actions",
enableHiding: false,
@@ -393,19 +367,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(trial.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
{trial.status === "scheduled" && (
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
@@ -431,11 +392,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{(trial.status === "scheduled" || trial.status === "failed") && (
<DropdownMenuItem className="text-red-600">
<Ban className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,107 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
// Define the shape of our data (matching schema)
export interface TrialEvent {
id: string;
trialId: string;
eventType: string;
timestamp: Date | string;
data: any;
createdBy: string | null;
}
// 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 const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{
id: "timestamp",
header: "Time",
accessorKey: "timestamp",
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col">
<span className="font-mono font-medium">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{date.toLocaleTimeString()}
</span>
</div>
);
},
},
{
accessorKey: "eventType",
header: "Event Type",
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention) Icon = User; // Wizard/Intervention often User
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (type.includes("note")) Icon = MessageSquare;
else if (type.includes("completed")) Icon = CheckCircle;
return (
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
isIntervention && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: "data",
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
if (!data || Object.keys(data).length === 0) return <span className="text-muted-foreground text-xs">-</span>;
// Simplistic view for now: JSON stringify but truncated?
// Or meaningful extraction based on event type.
return (
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border block max-w-[400px] truncate">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
);
},
},
];

View File

@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { DataTable } from "~/components/ui/data-table";
import { type TrialEvent, eventsColumns } from "./events-columns";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Input } from "~/components/ui/input";
interface EventsDataTableProps {
data: TrialEvent[];
startTime?: Date;
}
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>("");
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
// Enhanced filtering logic
const filteredData = React.useMemo(() => {
return data.filter(event => {
// Type filter
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
return false;
}
// Global text search (checks type and data)
if (globalFilter) {
const searchLower = globalFilter.toLowerCase();
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
// Safe JSON stringify check
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
const dataMatch = dataString.includes(searchLower);
return typeMatch || dataMatch;
}
return true;
});
}, [data, eventTypeFilter, globalFilter]);
// Custom Filters UI
const filters = (
<div className="flex items-center gap-2">
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
</SelectContent>
</Select>
</div>
);
return (
<div className="space-y-4">
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering.
Since we implemented custom external filtering for "type" dropdown and "global" search,
we pass the filtered data directly.
However, the shared DataTable component has a `searchKey` prop that drives an internal Input.
If we want to use OUR custom search input (to search JSON data), we should probably NOT use
DataTable's internal search or pass a custom filter.
The shared DataTable's `searchKey` only filters a specific column string value.
Since "data" is an object, we can't easily use the built-in single-column search.
So we'll implement our own search input and pass `filters={filters}` which creates
additional dropdowns, but we might want to REPLACE the standard search input.
Looking at `DataTable` implementation:
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`,
no input is rendered, and we can put ours in `filters`.
*/}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
{filters}
</div>
</div>
<DataTable
columns={columns}
data={filteredData}
// No searchKey, we handle it externally
isLoading={false}
/>
</div>
);
}

View File

@@ -2,17 +2,21 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import { PlaybackProvider } from "../playback/PlaybackContext";
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
import { EventTimeline } from "../playback/EventTimeline";
import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { EventsDataTable } from "../analysis/events-data-table";
interface TrialAnalysisViewProps {
trial: {
@@ -27,9 +31,10 @@ interface TrialAnalysisViewProps {
mediaCount?: number;
media?: { url: string; contentType: string }[];
};
backHref: string;
}
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
// Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id,
@@ -39,139 +44,153 @@ export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
const videoUrl = videoMedia?.url;
// Metrics
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
const errorCount = events.filter(e => e.eventType.includes("error")).length;
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden">
<div className="flex h-full flex-col gap-4 p-4 text-sm">
{/* Header Context */}
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14">
<div className="flex items-center justify-between pb-2 border-b">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild className="-ml-2">
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex flex-col">
<h1 className="text-base font-semibold leading-none">
<h1 className="text-lg font-semibold leading-none tracking-tight">
{trial.experiment.name}
</h1>
<p className="text-xs text-muted-foreground mt-1">
{trial.participant.participantCode} Session {trial.id.slice(0, 4)}...
</p>
<div className="flex items-center gap-2 text-muted-foreground mt-1">
<span className="font-mono">{trial.participant.participantCode}</span>
<span></span>
<span>Session {trial.id.slice(0, 4)}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-muted-foreground bg-muted/30 px-3 py-1 rounded-full border">
<Clock className="h-3.5 w-3.5" />
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
<span className="text-xs font-mono">
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
</span>
</div>
{trial.duration && (
<Badge variant="secondary" className="text-[10px] font-mono">
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
</Badge>
</div>
</div>
{/* Metrics Header */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card className="bg-gradient-to-br from-blue-50 to-transparent dark:from-blue-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
<Clock className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? (
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span>
) : (
"--:--"
)}
</div>
<p className="text-xs text-muted-foreground">Total session time</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-50 to-transparent dark:from-purple-950/20">
<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 className="bg-gradient-to-br from-orange-50 to-transparent dark:from-orange-950/20">
<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 className="bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
<Activity className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn(
"inline-block h-2 w-2 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
</div>
</CardContent>
</Card>
</div>
{/* Main Resizable Workspace */}
<div className="flex-1 min-h-0">
<ResizablePanelGroup direction="horizontal">
{/* LEFT: Video & Timeline */}
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
{/* Main Workspace: Vertical Layout */}
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
<ResizablePanelGroup direction="vertical">
{/* Top: Video Player */}
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative">
{/* TOP: Video & Timeline */}
<ResizablePanel defaultSize={50} minSize={30} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40">
<div className="relative flex-1 min-h-0 flex items-center justify-center">
{videoUrl ? (
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
) : (
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
<p className="text-sm">No recording available.</p>
<div className="flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
<div className="bg-muted rounded-full p-4 mb-4">
<VideoOff className="h-8 w-8 opacity-50" />
</div>
<h3 className="font-semibold text-lg">No playback media available</h3>
<p className="text-sm max-w-sm mt-2">
There is no video recording associated with this trial session.
</p>
</div>
)}
</ResizablePanel>
<ResizableHandle withHandle />
{/* Bottom: Timeline Track */}
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
<Info className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
</div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0 p-2 overflow-hidden">
{/* Timeline Control */}
<div className="shrink-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4">
<EventTimeline />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle className="bg-border/50" />
{/* RIGHT: Logs & Metrics */}
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
{/* Metrics Strip */}
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{events.filter(e => e.eventType.includes("intervention")).length}
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
{/* BOTTOM: Events Table */}
<ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">Event Log</h3>
</div>
</CardContent>
</Card>
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{trial.status === 'completed' ? 'PASS' : 'INC'}
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
</div>
</CardContent>
</Card>
</div>
{/* Log Title */}
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
<span className="text-xs font-semibold flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-primary" />
Event Log
</span>
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
</div>
{/* Scrollable Event List */}
<div className="flex-1 min-h-0 relative bg-background/50">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{events.map((event, i) => (
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
{event.eventType.replace(/_/g, " ")}
</span>
</div>
{!!event.data && (
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
</div>
)}
</div>
</div>
))}
{events.length === 0 && (
<div className="p-8 text-center text-xs text-muted-foreground italic">
No events found in log.
</div>
)}
<ScrollArea className="flex-1">
<div className="p-4">
<EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
@@ -187,3 +206,4 @@ function formatTime(ms: number) {
const s = Math.floor(totalSeconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}

View File

@@ -406,6 +406,32 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
const pauseTrialMutation = api.trials.pause.useMutation({
onSuccess: () => {
toast.success("Trial paused");
// Optionally update local state if needed, though status might not change on backend strictly to "paused"
// depending on enum. But we logged the event.
},
onError: (error) => {
toast.error("Failed to pause trial", { description: error.message });
},
});
const archiveTrialMutation = api.trials.archive.useMutation({
onSuccess: () => {
console.log("Trial archived successfully");
},
onError: (error) => {
console.error("Failed to archive trial", error);
},
});
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => {
// toast.success("Event logged"); // Too noisy
},
});
// Action handlers
const handleStartTrial = async () => {
console.log(
@@ -443,8 +469,11 @@ export const WizardInterface = React.memo(function WizardInterface({
};
const handlePauseTrial = async () => {
// TODO: Implement pause functionality
console.log("Pause trial");
try {
await pauseTrialMutation.mutateAsync({ id: trial.id });
} catch (error) {
console.error("Failed to pause trial:", error);
}
};
const handleNextStep = (targetIndex?: number) => {
@@ -498,6 +527,19 @@ export const WizardInterface = React.memo(function WizardInterface({
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) {
// Log step change
logEventMutation.mutate({
trialId: trial.id,
type: "step_changed",
data: {
fromIndex: currentStepIndex,
toIndex: nextIndex,
fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name,
}
});
setCurrentStepIndex(nextIndex);
} else {
handleCompleteTrial();
@@ -507,6 +549,8 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleCompleteTrial = async () => {
try {
await completeTrialMutation.mutateAsync({ id: trial.id });
// Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id });
} catch (error) {
console.error("Failed to complete trial:", error);
}
@@ -543,10 +587,7 @@ export const WizardInterface = React.memo(function WizardInterface({
});
};
// Mutation for events (Acknowledge)
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => toast.success("Event logged"),
});
// Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({

View File

@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { randomUUID } from "crypto";
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm";
import { and, asc, count, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
@@ -87,7 +87,10 @@ export const experimentsRouter = createTRPCRouter({
// Check study access
await checkStudyAccess(ctx.db, userId, studyId);
const conditions = [eq(experiments.studyId, studyId)];
const conditions = [
eq(experiments.studyId, studyId),
isNull(experiments.deletedAt),
];
if (status) {
conditions.push(eq(experiments.status, status));
}
@@ -224,7 +227,10 @@ export const experimentsRouter = createTRPCRouter({
}
// Build where conditions
const conditions = [inArray(experiments.studyId, studyIds)];
const conditions = [
inArray(experiments.studyId, studyIds),
isNull(experiments.deletedAt),
];
if (status) {
conditions.push(eq(experiments.status, status));

View File

@@ -7,6 +7,7 @@ import type { db } from "~/server/db";
import {
activityLogs, consentForms, participantConsents, participants, studyMembers, trials
} from "~/server/db/schema";
import { getUploadUrl, validateFile } from "~/lib/storage/minio";
// Helper function to check study access
async function checkStudyAccess(
@@ -415,6 +416,42 @@ export const participantsRouter = createTRPCRouter({
return { success: true };
}),
getConsentUploadUrl: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
participantId: z.string().uuid(),
filename: z.string(),
contentType: z.string(),
size: z.number().max(10 * 1024 * 1024), // 10MB limit
})
)
.mutation(async ({ ctx, input }) => {
const { studyId, participantId, filename, contentType, size } = input;
const userId = ctx.session.user.id;
// Check study access with researcher permission
await checkStudyAccess(ctx.db, userId, studyId, ["owner", "researcher", "wizard"]);
// Validate file type
const allowedTypes = ["pdf", "png", "jpg", "jpeg"];
const validation = validateFile(filename, size, allowedTypes);
if (!validation.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: validation.error,
});
}
// Generate key: studies/{studyId}/participants/{participantId}/consent/{timestamp}-{filename}
const key = `studies/${studyId}/participants/${participantId}/consent/${Date.now()}-${filename.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
// Generate presigned URL
const url = await getUploadUrl(key, contentType);
return { url, key };
}),
recordConsent: protectedProcedure
.input(
z.object({
@@ -422,10 +459,11 @@ export const participantsRouter = createTRPCRouter({
consentFormId: z.string().uuid(),
signatureData: z.string().optional(),
ipAddress: z.string().optional(),
storagePath: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { participantId, consentFormId, signatureData, ipAddress } = input;
const { participantId, consentFormId, signatureData, ipAddress, storagePath } = input;
const userId = ctx.session.user.id;
// Get participant to check study access
@@ -489,6 +527,7 @@ export const participantsRouter = createTRPCRouter({
consentFormId,
signatureData,
ipAddress,
storagePath,
})
.returning();

View File

@@ -34,6 +34,7 @@ import { s3Client } from "~/server/storage";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env";
import { uploadFile } from "~/lib/storage/minio";
// Helper function to check if user has access to trial
async function checkTrialAccess(
@@ -542,6 +543,14 @@ export const trialsRouter = createTRPCRouter({
});
}
// Log trial start event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_started",
timestamp: new Date(),
data: { userId },
});
return trial[0];
}),
@@ -625,9 +634,136 @@ export const trialsRouter = createTRPCRouter({
});
}
// Log trial abort event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_aborted",
timestamp: new Date(),
data: { userId, reason: input.reason },
});
return trial[0];
}),
pause: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkTrialAccess(db, userId, input.id, [
"owner",
"researcher",
"wizard",
]);
// Log trial paused event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_paused",
timestamp: new Date(),
data: { userId },
});
return { success: true };
}),
archive: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
const trial = await checkTrialAccess(db, userId, input.id, [
"owner",
"researcher",
"wizard",
]);
// 1. Fetch full trial data
const trialData = await db.query.trials.findFirst({
where: eq(trials.id, input.id),
with: {
experiment: true,
participant: true,
wizard: true,
},
});
if (!trialData) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Trial data not found",
});
}
// 2. Fetch all events
const events = await db
.select()
.from(trialEvents)
.where(eq(trialEvents.trialId, input.id))
.orderBy(asc(trialEvents.timestamp));
// 3. Fetch all interventions
const interventions = await db
.select()
.from(wizardInterventions)
.where(eq(wizardInterventions.trialId, input.id))
.orderBy(asc(wizardInterventions.timestamp));
// 4. Construct Archive Object
const archiveObject = {
trial: trialData,
events,
interventions,
archivedAt: new Date().toISOString(),
archivedBy: userId,
};
// 5. Upload to MinIO
const filename = `archive-${input.id}-${Date.now()}.json`;
const key = `trials/${input.id}/${filename}`;
try {
const uploadResult = await uploadFile({
key,
body: JSON.stringify(archiveObject, null, 2),
contentType: "application/json",
});
// 6. Update Trial Metadata with Archive URL/Key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const currentMetadata = (trialData.metadata as any) || {};
await db
.update(trials)
.set({
metadata: {
...currentMetadata,
archiveKey: uploadResult.key,
archiveUrl: uploadResult.url,
archivedAt: new Date(),
},
})
.where(eq(trials.id, input.id));
return { success: true, url: uploadResult.url };
} catch (error) {
console.error("Failed to archive trial:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to upload archive to storage",
});
}
}),
logEvent: protectedProcedure
.input(
z.object({