From a8c868ad3f411bd2b121deff2414e00490bb292e Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 10 Feb 2026 16:14:31 -0500 Subject: [PATCH] feat: Implement trial event logging, archiving, experiment soft deletion, and new analytics/event data tables. --- .../studies/[id]/analytics/page.tsx | 252 ++------------ .../participants/[participantId]/page.tsx | 9 + .../[id]/trials/[trialId]/analysis/page.tsx | 23 +- .../analytics/study-analytics-data-table.tsx | 319 ++++++++++++++++++ .../experiments/ExperimentsTable.tsx | 106 +++--- .../experiments/experiments-columns.tsx | 81 ++--- .../participants/ConsentUploadForm.tsx | 190 +++++++++++ .../ParticipantConsentManager.tsx | 161 +++++++++ .../participants/ParticipantsTable.tsx | 38 +-- src/components/trials/TrialsTable.tsx | 46 +-- .../trials/analysis/events-columns.tsx | 107 ++++++ .../trials/analysis/events-data-table.tsx | 101 ++++++ .../trials/views/TrialAnalysisView.tsx | 246 +++++++------- .../trials/wizard/WizardInterface.tsx | 53 ++- src/server/api/routers/experiments.ts | 12 +- src/server/api/routers/participants.ts | 43 ++- src/server/api/routers/trials.ts | 136 ++++++++ 17 files changed, 1356 insertions(+), 567 deletions(-) create mode 100644 src/components/analytics/study-analytics-data-table.tsx create mode 100644 src/components/participants/ConsentUploadForm.tsx create mode 100644 src/components/participants/ParticipantConsentManager.tsx create mode 100644 src/components/trials/analysis/events-columns.tsx create mode 100644 src/components/trials/analysis/events-data-table.tsx diff --git a/src/app/(dashboard)/studies/[id]/analytics/page.tsx b/src/app/(dashboard)/studies/[id]/analytics/page.tsx index 970cb5e..a6fd6b7 100755 --- a/src/app/(dashboard)/studies/[id]/analytics/page.tsx +++ b/src/app/(dashboard)/studies/[id]/analytics/page.tsx @@ -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 ( -
- {selectedTrialId ? ( - isLoadingTrial ? ( -
-
-
- Loading trial data... -
-
- ) : trialError ? ( -
-
-

Error Loading Trial

-

{trialError.message}

- -
-
- ) : trialData ? ( - - ) : null - ) : ( -
- setSelectedTrialId(id)} - /> -
- )} -
- ); -} - -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 ( -
-
- {/* Left: Illustration / Prompt */} -
-
- -
-
-

Analytics & Playback

- - Select a session from the top right to review video recordings, event logs, and metrics. - -
-
-
- - Feature-rich playback -
-
- - Synchronized timeline -
-
-
- - {/* Right: Recent Sessions */} - - - Recent Sessions - - - -
- {recentTrials.map(trial => ( - - ))} - {recentTrials.length === 0 && ( -
- No sessions found. -
- )} -
-
-
-
-
-
- ) -} - -// -- 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(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 ( -
-
- - {/* Session Selector in Header */} -
- +
+ + +
+ Loading analytics...
}> + {isLoading ? ( +
+
+
+ Loading session data...
- } - /> -
- -
- Loading analytics...
}> - + ) : ( + ({ + ...t, + startedAt: t.startedAt ? new Date(t.startedAt) : null, + completedAt: t.completedAt ? new Date(t.completedAt) : null, + createdAt: new Date(t.createdAt), + }))} /> + )}
diff --git a/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx b/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx index 8be144a..30d66ce 100644 --- a/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx @@ -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({
+
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 ( + + ); + }, + 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 ( + + ); + }, + 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 ( +
+
+ ) + }, + }, + { + id: "actions", + cell: ({ row }) => { + const trial = row.original; + return ( + + + + + + 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. +
+
+ + +
+
+
+ ); +} 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 ( - - - - - - 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 ( + + + + + + 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 +

+ + +
+ ) : ( +
+
+
+
+ +
+
+

{file.name}

+

+ {(file.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ {!isUploading && ( + + )} +
+ + {isUploading && ( +
+
+ Uploading... + {uploadProgress}% +
+ +
+ )} + +
+ + +
+
+ )} +
+ ); +} 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 && ( + + )} + + + + + + + + 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 ( - - ); - }, - 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 ( - - ); - }, - 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 = ( +
+ +
+ ); + + 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({