diff --git a/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx b/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx
index 4ed1da9..fb112f9 100644
--- a/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx
+++ b/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx
@@ -95,25 +95,10 @@ function AnalysisPageContent() {
};
return (
-
-
-
-
- Back to Trial Details
-
-
- }
- />
-
-
-
-
-
+
);
}
diff --git a/src/components/analytics/study-analytics-data-table.tsx b/src/components/analytics/study-analytics-data-table.tsx
new file mode 100644
index 0000000..7fb95d3
--- /dev/null
+++ b/src/components/analytics/study-analytics-data-table.tsx
@@ -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
[] = [
+ {
+ accessorKey: "sessionNumber",
+ header: ({ column }) => {
+ return (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Session
+
+
+ );
+ },
+ cell: ({ row }) => #{row.getValue("sessionNumber")}
,
+ },
+ {
+ accessorKey: "participant.participantCode",
+ header: "Participant",
+ cell: ({ row }) => (
+ {row.original.participant?.participantCode ?? "Unknown"}
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+ return (
+
+ {status.replace("_", " ")}
+
+ );
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => {
+ return (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Date
+
+
+ );
+ },
+ cell: ({ row }) => {
+ const date = new Date(row.getValue("createdAt"));
+ return (
+
+ {date.toLocaleDateString()}
+ {formatDistanceToNow(date, { addSuffix: true })}
+
+ )
+ },
+ },
+ {
+ accessorKey: "duration",
+ header: "Duration",
+ cell: ({ row }) => {
+ const duration = row.getValue("duration") as number | null;
+ if (!duration) return - ;
+ const m = Math.floor(duration / 60);
+ const s = Math.floor(duration % 60);
+ return {`${m}m ${s}s`}
;
+ },
+ },
+ {
+ accessorKey: "eventCount",
+ header: "Events",
+ cell: ({ row }) => {
+ return (
+
+
+
{row.getValue("eventCount")}
+
+ )
+ },
+ },
+ {
+ accessorKey: "mediaCount",
+ header: "Media",
+ cell: ({ row }) => {
+ const count = row.getValue("mediaCount") as number;
+ if (count === 0) return - ;
+ return (
+
+
+ {count}
+
+ )
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ const trial = row.original;
+ return (
+
+
+
+ Open menu
+
+
+
+
+ Actions
+
+
+
+ View Analysis
+
+
+
+
+ View Trial Details
+
+
+
+
+ );
+ },
+ },
+];
+
+interface StudyAnalyticsDataTableProps {
+ data: AnalyticsTrial[];
+}
+
+export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) {
+ const [sorting, setSorting] = useState([]);
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [columnVisibility, setColumnVisibility] = useState({});
+ 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 (
+
+
+
+ table.getColumn("participant.participantCode")?.setFilterValue(event.target.value)
+ }
+ className="max-w-sm"
+ />
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Previous
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Next
+
+
+
+
+ );
+}
diff --git a/src/components/experiments/ExperimentsTable.tsx b/src/components/experiments/ExperimentsTable.tsx
index 60c02af..2389157 100755
--- a/src/components/experiments/ExperimentsTable.tsx
+++ b/src/components/experiments/ExperimentsTable.tsx
@@ -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,65 +243,57 @@ export const columns: ColumnDef[] = [
{
id: "actions",
enableHiding: false,
- cell: ({ row }) => {
- const experiment = row.original;
-
- return (
-
-
-
- Open menu
-
-
-
-
- Actions
- navigator.clipboard.writeText(experiment.id)}
- >
-
- Copy ID
-
-
-
-
-
- Details
-
-
-
-
-
- Edit
-
-
-
-
-
- Designer
-
-
-
-
-
-
- Start Trial
-
-
-
-
-
- Archive
-
-
-
- );
- },
+ cell: ({ row }) => ,
},
];
+function ExperimentActions({ experiment }: { experiment: Experiment }) {
+ const utils = api.useUtils();
+ const deleteMutation = api.experiments.delete.useMutation({
+ onSuccess: () => {
+ utils.experiments.list.invalidate();
+ },
+ });
+
+ return (
+
+
+
+ Open menu
+
+
+
+
+ Actions
+
+
+
+ Edit Metadata
+
+
+
+
+
+ Design
+
+
+
+ {
+ if (confirm("Are you sure you want to delete this experiment?")) {
+ deleteMutation.mutate({ id: experiment.id });
+ }
+ }}
+ >
+
+ Delete
+
+
+
+ );
+}
+
export function ExperimentsTable() {
const { selectedStudyId } = useStudyContext();
diff --git a/src/components/experiments/experiments-columns.tsx b/src/components/experiments/experiments-columns.tsx
index 89d858f..b895e8b 100755
--- a/src/components/experiments/experiments-columns.tsx
+++ b/src/components/experiments/experiments-columns.tsx
@@ -27,6 +27,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
+import { api } from "~/trpc/react";
export type Experiment = {
id: string;
@@ -78,29 +79,25 @@ 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 }) {
Actions
-
-
-
-
- View Details
+
+
+ Edit Metadata
- Open Designer
+ Design
- {experiment.canEdit && (
-
-
-
- Edit Experiment
-
-
- )}
-
-
-
- {experiment.status === "ready" && (
-
-
- Start New Trial
-
- )}
-
-
-
- Copy Experiment ID
-
-
{experiment.canDelete && (
<>
@@ -158,7 +130,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
className="text-red-600 focus:text-red-600"
>
- Delete Experiment
+ Delete
>
)}
@@ -315,20 +287,7 @@ export const experimentsColumns: ColumnDef[] = [
},
enableSorting: false,
},
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const date = row.getValue("createdAt");
- return (
-
- {formatDistanceToNow(date as Date, { addSuffix: true })}
-
- );
- },
- },
+
{
accessorKey: "updatedAt",
header: ({ column }) => (
diff --git a/src/components/participants/ConsentUploadForm.tsx b/src/components/participants/ConsentUploadForm.tsx
new file mode 100644
index 0000000..9c4c8cc
--- /dev/null
+++ b/src/components/participants/ConsentUploadForm.tsx
@@ -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(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) => {
+ 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 (
+
+ {!file ? (
+
+
+
Upload Signed Consent
+
+ Drag and drop or click to select
+ PDF, PNG, JPG up to 10MB
+
+
+
document.getElementById("consent-file-upload")?.click()}>
+ Select File
+
+
+ ) : (
+
+
+
+
+
+
+
+
{file.name}
+
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+
+
+
+ {!isUploading && (
+
setFile(null)}>
+
+
+ )}
+
+
+ {isUploading && (
+
+
+ Uploading...
+ {uploadProgress}%
+
+
+
+ )}
+
+
+
+ Cancel
+
+
+ {isUploading ? (
+ <>
+
+ Uploading
+ >
+ ) : (
+ <>
+
+ Upload & Record
+ >
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/participants/ParticipantConsentManager.tsx b/src/components/participants/ParticipantConsentManager.tsx
new file mode 100644
index 0000000..34d2cb8
--- /dev/null
+++ b/src/components/participants/ParticipantConsentManager.tsx
@@ -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 (
+
+
+
+
+
+ Consent Status
+
+
+ Manage participant consent and forms.
+
+
+
+ {consentGiven ? "Consent Given" : "Not Recorded"}
+
+
+
+
+
+ {consentGiven ? (
+ <>
+
+
+ Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
+
+ {existingConsent && (
+
+ Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
+
+ )}
+ >
+ ) : (
+
+
+ No consent recorded for this participant.
+
+ )}
+
+
+ {consentGiven && existingConsent?.storagePath && (
+
+
+ Download PDF
+
+ )}
+
+
+
+
+
+ {consentGiven ? "Update Consent" : "Record Consent"}
+
+
+
+
+ Upload Signed Consent Form
+
+ Upload the signed PDF or image of the consent form for this participant.
+ {activeForm && (
+
+ Active Form: {activeForm.title} (v{activeForm.version})
+
+ )}
+
+
+ {activeForm ? (
+ setIsOpen(false)}
+ />
+ ) : (
+
+ No active consent form found for this study. Please create one first.
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/participants/ParticipantsTable.tsx b/src/components/participants/ParticipantsTable.tsx
index 5d91954..973b213 100755
--- a/src/components/participants/ParticipantsTable.tsx
+++ b/src/components/participants/ParticipantsTable.tsx
@@ -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[] = [
);
},
},
- {
- accessorKey: "createdAt",
- header: ({ column }) => {
- return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Created
-
-
- );
- },
- cell: ({ row }) => {
- const date = row.getValue("createdAt");
- return (
-
- {formatDistanceToNow(new Date(date as string | number | Date), {
- addSuffix: true,
- })}
-
- );
- },
- },
+
{
id: "actions",
enableHiding: false,
@@ -195,23 +172,12 @@ export const columns: ColumnDef[] = [
Actions
- navigator.clipboard.writeText(participant.id)}
- >
-
- Copy ID
-
-
Edit participant
-
-
- Send consent
-
diff --git a/src/components/trials/TrialsTable.tsx b/src/components/trials/TrialsTable.tsx
index fe0bbbf..5a3729b 100755
--- a/src/components/trials/TrialsTable.tsx
+++ b/src/components/trials/TrialsTable.tsx
@@ -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[] = [
);
},
},
- {
- accessorKey: "createdAt",
- header: ({ column }) => {
- return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Created
-
-
- );
- },
- cell: ({ row }) => {
- const date = row.getValue("createdAt");
- if (!date)
- return Unknown ;
- return (
-
- {formatDistanceToNow(new Date(date as string | number | Date), {
- addSuffix: true,
- })}
-
- );
- },
- },
{
id: "actions",
enableHiding: false,
@@ -393,19 +367,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
Actions
- navigator.clipboard.writeText(trial.id)}
- >
-
- Copy ID
-
-
-
-
-
- Details
-
-
{trial.status === "scheduled" && (
@@ -431,11 +392,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
)}
- duplicateMutation.mutate({ id: trial.id })}>
-
- Duplicate
-
-
{(trial.status === "scheduled" || trial.status === "failed") && (
diff --git a/src/components/trials/analysis/events-columns.tsx b/src/components/trials/analysis/events-columns.tsx
new file mode 100644
index 0000000..1084df7
--- /dev/null
+++ b/src/components/trials/analysis/events-columns.tsx
@@ -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[] => [
+ {
+ id: "timestamp",
+ header: "Time",
+ accessorKey: "timestamp",
+ cell: ({ row }) => {
+ const date = new Date(row.original.timestamp);
+ return (
+
+
+ {formatRelativeTime(row.original.timestamp, startTime)}
+
+
+ {date.toLocaleTimeString()}
+
+
+ );
+ },
+ },
+ {
+ 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 (
+
+
+ {type.replace(/_/g, " ")}
+
+ );
+ },
+ 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 - ;
+
+ // Simplistic view for now: JSON stringify but truncated?
+ // Or meaningful extraction based on event type.
+ return (
+
+ {JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
+
+ );
+ },
+ },
+];
diff --git a/src/components/trials/analysis/events-data-table.tsx b/src/components/trials/analysis/events-data-table.tsx
new file mode 100644
index 0000000..8790612
--- /dev/null
+++ b/src/components/trials/analysis/events-data-table.tsx
@@ -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("all");
+ const [globalFilter, setGlobalFilter] = React.useState("");
+
+ 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 = (
+
+
+
+
+
+
+ All Events
+ Interventions
+ Robot Actions
+ Step Changes
+ Errors
+
+
+
+ );
+
+ return (
+
+ {/* 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 `
` if `searchKey` is provided. If we don't provide `searchKey`,
+ no input is rendered, and we can put ours in `filters`.
+ */}
+
+
+
+ setGlobalFilter(e.target.value)}
+ className="h-8 w-[150px] lg:w-[250px]"
+ />
+ {filters}
+
+
+
+
+
+ );
+}
diff --git a/src/components/trials/views/TrialAnalysisView.tsx b/src/components/trials/views/TrialAnalysisView.tsx
index 05678a3..633e0be 100644
--- a/src/components/trials/views/TrialAnalysisView.tsx
+++ b/src/components/trials/views/TrialAnalysisView.tsx
@@ -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 (
-
+
{/* Header Context */}
-
+
+
+
+
+
+
-
+
{trial.experiment.name}
-
- {trial.participant.participantCode} • Session {trial.id.slice(0, 4)}...
-
-
-
-
-
-
-
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
+
+ {trial.participant.participantCode}
+ •
+ Session {trial.id.slice(0, 4)}
- {trial.duration && (
-
- {Math.floor(trial.duration / 60)}m {trial.duration % 60}s
-
- )}
+
+
+
+
+
+
+ {trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
+
- {/* Main Resizable Workspace */}
-
-
+ {/* Metrics Header */}
+
+
+
+ Duration
+
+
+
+
+ {trial.duration ? (
+ {Math.floor(trial.duration / 60)}m {trial.duration % 60}s
+ ) : (
+ "--:--"
+ )}
+
+ Total session time
+
+
- {/* LEFT: Video & Timeline */}
-
-
- {/* Top: Video Player */}
-
- {videoUrl ? (
-
- ) : (
-
-
-
No recording available.
-
- )}
-
+
+
+ Robot Actions
+
+
+
+ {robotActionCount}
+ Executed autonomous behaviors
+
+
-
+
+
+ Interventions
+
+
+
+ {interventionCount}
+ Manual wizard overrides
+
+
- {/* Bottom: Timeline Track */}
-
-
-
-
Timeline Track
+
+
+ Completeness
+
+
+
+
+ {trial.status === 'completed' ? '100%' : 'Incomplete'}
+
+
+
+ {trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
+
+
+
+
+
+ {/* Main Workspace: Vertical Layout */}
+
+
+
+ {/* TOP: Video & Timeline */}
+
+
+ {videoUrl ? (
+
-
-
-
+ ) : (
+
+
+
+
No playback media available
+
+ There is no video recording associated with this trial session.
+
-
-
+ )}
+
+
+ {/* Timeline Control */}
+
+
+
-
+
- {/* RIGHT: Logs & Metrics */}
-
- {/* Metrics Strip */}
-
-
-
- Interventions
-
- {events.filter(e => e.eventType.includes("intervention")).length}
-
-
-
-
-
-
- Status
-
- {trial.status === 'completed' ? 'PASS' : 'INC'}
-
-
-
-
-
-
- {/* Log Title */}
-
-
-
- Event Log
-
- {events.length} Events
-
-
- {/* Scrollable Event List */}
-
-
-
- {events.map((event, i) => (
-
-
- {formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
-
-
-
-
- {event.eventType.replace(/_/g, " ")}
-
-
- {!!event.data && (
-
- {JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
-
- )}
-
-
- ))}
- {events.length === 0 && (
-
- No events found in log.
-
- )}
-
-
+ {/* BOTTOM: Events Table */}
+
+
+
+
+
Event Log
+
+
{events.length} Events
+
+
+ ({ ...e, timestamp: new Date(e.timestamp) }))}
+ startTime={trial.startedAt ?? undefined}
+ />
+
+
@@ -187,3 +206,4 @@ function formatTime(ms: number) {
const s = Math.floor(totalSeconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
+
diff --git a/src/components/trials/wizard/WizardInterface.tsx b/src/components/trials/wizard/WizardInterface.tsx
index fbb01f7..3351721 100755
--- a/src/components/trials/wizard/WizardInterface.tsx
+++ b/src/components/trials/wizard/WizardInterface.tsx
@@ -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({
diff --git a/src/server/api/routers/experiments.ts b/src/server/api/routers/experiments.ts
index 6cdf7c2..cc031f6 100755
--- a/src/server/api/routers/experiments.ts
+++ b/src/server/api/routers/experiments.ts
@@ -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));
diff --git a/src/server/api/routers/participants.ts b/src/server/api/routers/participants.ts
index e8a884c..bd8929c 100755
--- a/src/server/api/routers/participants.ts
+++ b/src/server/api/routers/participants.ts
@@ -5,8 +5,9 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import type { db } from "~/server/db";
import {
- activityLogs, consentForms, participantConsents, participants, studyMembers, trials
+ 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();
diff --git a/src/server/api/routers/trials.ts b/src/server/api/routers/trials.ts
index 6a5ea13..1febe08 100755
--- a/src/server/api/routers/trials.ts
+++ b/src/server/api/routers/trials.ts
@@ -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({