diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index e7b2567..f2ff61a 100755 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -16,8 +16,19 @@ import { Separator } from "~/components/ui/separator"; import { PageHeader } from "~/components/ui/page-header"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { formatRole, getRoleDescription } from "~/lib/auth-client"; -import { User, Shield, Download, Trash2, ExternalLink } from "lucide-react"; +import { + User, + Shield, + Download, + Trash2, + ExternalLink, + Lock, + UserCog, + Mail, + Fingerprint +} from "lucide-react"; import { useSession } from "next-auth/react"; +import { cn } from "~/lib/utils"; interface ProfileUser { id: string; @@ -32,185 +43,141 @@ interface ProfileUser { function ProfileContent({ user }: { user: ProfileUser }) { return ( -
+
({ + label: formatRole(r.role), + variant: "secondary" as const, + })) ?? []), + ]} /> -
- {/* Profile Information */} -
- {/* Basic Information */} - - - Basic Information - - Your personal account information - - - - - - +
+ {/* Main Content (Left Column) */} +
- {/* Password Change */} - - - Password - Change your account password - - - - - + {/* Personal Information */} +
+
+ +

Personal Information

+
+ + + Contact Details + Update your public profile information + + + + + +
- {/* Account Actions */} - - - Account Actions - Manage your account settings - - -
-
-

Export Data

-

- Download all your research data and account information -

-
- -
- - - -
-
-

- Delete Account -

-

- Permanently delete your account and all associated data -

-
- -
-
-
+ {/* Security */} +
+
+ +

Security

+
+ + + Password + Ensure your account stays secure + + + + + +
- {/* Sidebar */} -
- {/* User Summary */} - - - Account Summary - - -
-
- - {(user.name ?? user.email ?? "U").charAt(0).toUpperCase()} - -
-
-

{user.name ?? "Unnamed User"}

-

{user.email}

-
-
+ {/* Sidebar (Right Column) */} +
- - -
-

User ID

-

- {user.id} -

-
- - - - {/* System Roles */} - - - - - System Roles - - Your current system permissions - - - {user.roles && user.roles.length > 0 ? ( -
- {user.roles.map((roleInfo, index: number) => ( -
-
-
- - {formatRole(roleInfo.role)} - + {/* Permissions */} +
+
+ +

Permissions

+
+ + + {user.roles && user.roles.length > 0 ? ( +
+ {user.roles.map((roleInfo, index) => ( +
+
+ {formatRole(roleInfo.role)} + + Since {new Date(roleInfo.grantedAt).toLocaleDateString()} +
-

+

{getRoleDescription(roleInfo.role)}

-

- Granted{" "} - {new Date(roleInfo.grantedAt).toLocaleDateString()} -

+ {index < (user.roles?.length || 0) - 1 && }
+ ))} +
+
+ + Role Management +
+ System roles are managed by administrators. Contact support if you need access adjustments.
- ))} - - - -
-

- Need additional permissions?{" "} - -

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

No Roles Assigned

+

Contact an admin to request access.

+
-

No Roles Assigned

-

- You don't have any system roles yet. Contact an - administrator to get access to HRIStudio features. -

-
+ + {/* Data & Privacy */} +
+
+ +

Data & Privacy

+
+ + + +
+

Export Data

+

Download a copy of your personal data.

+
- )} -
-
+ +
+

Delete Account

+

This action is irreversible.

+ +
+ + +
@@ -218,13 +185,17 @@ function ProfileContent({ user }: { user: ProfileUser }) { } export default function ProfilePage() { - const { data: session } = useSession(); + const { data: session, status } = useSession(); useBreadcrumbsEffect([ { label: "Dashboard", href: "/dashboard" }, { label: "Profile" }, ]); + if (status === "loading") { + return
Loading profile...
; + } + if (!session?.user) { redirect("/auth/signin"); } diff --git a/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/page.tsx b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/page.tsx index b71b103..441e867 100644 --- a/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/page.tsx @@ -1,12 +1,13 @@ "use client"; import { formatDistanceToNow } from "date-fns"; -import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react"; +import { Calendar, Clock, Edit, Play, Settings, Users, TestTube } from "lucide-react"; import Link from "next/link"; import { notFound } from "next/navigation"; import { useEffect, useState } from "react"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; +import { PageHeader } from "~/components/ui/page-header"; import { EntityView, EntityViewHeader, @@ -183,18 +184,19 @@ export default function ExperimentDetailPage({ return ( - +
- +
) : undefined } /> diff --git a/src/app/(dashboard)/studies/[id]/page.tsx b/src/app/(dashboard)/studies/[id]/page.tsx index 8df523c..5a95f30 100755 --- a/src/app/(dashboard)/studies/[id]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { formatDistanceToNow } from "date-fns"; -import { Plus, Settings, Shield } from "lucide-react"; +import { Plus, Settings, Shield, Building } from "lucide-react"; import Link from "next/link"; import { notFound } from "next/navigation"; import { useEffect, useState } from "react"; @@ -16,6 +16,7 @@ import { QuickActions, StatsGrid, } from "~/components/ui/entity-view"; +import { PageHeader } from "~/components/ui/page-header"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useSession } from "next-auth/react"; import { api } from "~/trpc/react"; @@ -167,17 +168,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) { return ( {/* Header */} - +
- +
} /> @@ -271,10 +273,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) { {experiment.status} diff --git a/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx b/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx index 30d66ce..0892b5d 100644 --- a/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/participants/[participantId]/page.tsx @@ -10,8 +10,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/com import { Badge } from "~/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Button } from "~/components/ui/button"; -import { Edit } from "lucide-react"; +import { Edit, Users } from "lucide-react"; import Link from "next/link"; +import { PageHeader } from "~/components/ui/page-header"; import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager"; @@ -37,14 +38,16 @@ export default async function ParticipantDetailPage({ return ( - 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 fb112f9..8e31249 100644 --- a/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx +++ b/src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx @@ -86,7 +86,7 @@ function AnalysisPageContent() { ); } - const trialData = { + const customTrialData = { ...trial, startedAt: trial.startedAt ? new Date(trial.startedAt) : null, completedAt: trial.completedAt ? new Date(trial.completedAt) : null, @@ -96,7 +96,7 @@ function AnalysisPageContent() { return ( ); diff --git a/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx b/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx index e773893..753e20a 100755 --- a/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx @@ -140,6 +140,12 @@ function TrialDetailContent() { title={`Trial: ${trial.participant.participantCode}`} description={`${trial.experiment.name} - Session ${trial.sessionNumber}`} icon={Play} + badges={[ + { + label: trial.status.replace("_", " ").toUpperCase(), + variant: getStatusBadgeVariant(trial.status), + } + ]} actions={
{trial.status === "scheduled" && ( diff --git a/src/components/trials/analysis/events-columns.tsx b/src/components/trials/analysis/events-columns.tsx index 1084df7..759e523 100644 --- a/src/components/trials/analysis/events-columns.tsx +++ b/src/components/trials/analysis/events-columns.tsx @@ -39,11 +39,15 @@ export const eventsColumns = (startTime?: Date): ColumnDef[] => [ id: "timestamp", header: "Time", accessorKey: "timestamp", + size: 90, + meta: { + style: { width: '90px', minWidth: '90px' } + }, cell: ({ row }) => { const date = new Date(row.original.timestamp); return ( -
- +
+ {formatRelativeTime(row.original.timestamp, startTime)} @@ -56,6 +60,10 @@ export const eventsColumns = (startTime?: Date): ColumnDef[] => [ { accessorKey: "eventType", header: "Event Type", + size: 160, + meta: { + style: { width: '160px', minWidth: '160px' } + }, cell: ({ row }) => { const type = row.getValue("eventType") as string; const isError = type.includes("error"); @@ -63,25 +71,33 @@ export const eventsColumns = (startTime?: Date): ColumnDef[] => [ const isRobot = type.includes("robot"); const isStep = type.includes("step"); + const isObservation = type.includes("annotation") || type.includes("note"); + const isJump = type.includes("jump"); // intervention_step_jump + const isActionComplete = type.includes("marked_complete"); + let Icon = Activity; if (isError) Icon = AlertTriangle; - else if (isIntervention) Icon = User; // Wizard/Intervention often User + else if (isIntervention || isJump) Icon = User; // Jumps are interventions else if (isRobot) Icon = Bot; else if (isStep) Icon = Flag; - else if (type.includes("note")) Icon = MessageSquare; - else if (type.includes("completed")) Icon = CheckCircle; + else if (isObservation) Icon = MessageSquare; + else if (type.includes("completed") || isActionComplete) Icon = CheckCircle; return ( - - - {type.replace(/_/g, " ")} - +
+ + + {type.replace(/_/g, " ")} + +
); }, filterFn: (row, id, value) => { @@ -93,14 +109,42 @@ export const eventsColumns = (startTime?: Date): ColumnDef[] => [ header: "Details", cell: ({ row }) => { const data = row.original.data; - if (!data || Object.keys(data).length === 0) return -; + const type = row.getValue("eventType") as string; + + // Wrapper for density and alignment + const Wrapper = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+ ); + + if (!data || Object.keys(data).length === 0) return -; + + // Smart Formatting + if (type.includes("jump")) { + return ( + + Jumped to step {data.stepName || (data.toIndex !== undefined ? data.toIndex + 1 : "?")} + (Manual) + + ); + } + if (type.includes("skipped")) { + return Skipped: {data.actionId}; + } + if (type.includes("marked_complete")) { + return Manually marked complete; + } + if (type.includes("annotation") || type.includes("note")) { + return {data.description || data.note || data.message || "No content"}; + } - // Simplistic view for now: JSON stringify but truncated? - // Or meaningful extraction based on event type. return ( - - {JSON.stringify(data).replace(/[{""}]/g, " ").trim()} - + + + {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 index f19f647..d02dc24 100644 --- a/src/components/trials/analysis/events-data-table.tsx +++ b/src/components/trials/analysis/events-data-table.tsx @@ -1,22 +1,61 @@ "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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "~/components/ui/table"; +import { Badge } from "~/components/ui/badge"; import { Input } from "~/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; +import { usePlayback } from "../playback/PlaybackContext"; +import { cn } from "~/lib/utils"; +import { + CheckCircle, + AlertTriangle, + Bot, + User, + Flag, + MessageSquare, + Activity, + Video +} from "lucide-react"; +import { type TrialEvent } from "./events-columns"; interface EventsDataTableProps { data: TrialEvent[]; startTime?: Date; } +// 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 function EventsDataTable({ data, startTime }: EventsDataTableProps) { + const { seekTo, events, currentEventIndex } = usePlayback(); 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 => { @@ -40,46 +79,39 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) { }); }, [data, eventTypeFilter, globalFilter]); - // Custom Filters UI - const filters = ( -
- -
- ); + // Active Event Logic & Auto-scroll + // Match filtered events with global playback "active event" via ID + const activeEventId = React.useMemo(() => { + if (currentEventIndex >= 0 && currentEventIndex < events.length) { + // We need to match the type of ID used in data/events + // Assuming events from context are TrialEvent compatible + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const evt = events[currentEventIndex] as any; + return evt?.id; + } + return null; + }, [events, currentEventIndex]); + + const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>({}); + + React.useEffect(() => { + if (activeEventId && rowRefs.current[activeEventId]) { + rowRefs.current[activeEventId]?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [activeEventId]); + + const handleRowClick = (event: TrialEvent) => { + if (!startTime) return; + const timeMs = new Date(event.timestamp).getTime(); + const seekSeconds = (timeMs - startTime.getTime()) / 1000; + seekTo(Math.max(0, seekSeconds)); + }; 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} + +
+
+ {filteredData.length} events
- +
+
+ + + + Time + Event Type + Details + + + + {filteredData.length === 0 ? ( + + + No results. + + + ) : ( + filteredData.map((event, index) => { + const type = event.eventType; + const data = event.data; + + // Type Logic + const isError = type.includes("error"); + const isIntervention = type.includes("intervention"); + const isRobot = type.includes("robot"); + const isStep = type.includes("step"); + const isObservation = type.includes("annotation") || type.includes("note"); + const isJump = type.includes("jump"); + const isActionComplete = type.includes("marked_complete"); + const isCamera = type.includes("camera"); + + let Icon = Activity; + if (isError) Icon = AlertTriangle; + else if (isIntervention || isJump) Icon = User; + else if (isRobot) Icon = Bot; + else if (isStep) Icon = Flag; + else if (isObservation) Icon = MessageSquare; + else if (isCamera) Icon = Video; + else if (type.includes("completed") || isActionComplete) Icon = CheckCircle; + + // Details Logic + let detailsContent; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = data as any; // Cast for easier access + + if (type.includes("jump")) { + detailsContent = ( + <>Jumped to step {d?.stepName || (d?.toIndex !== undefined ? d.toIndex + 1 : "?")} (Manual) + ); + } else if (type.includes("skipped")) { + detailsContent = Skipped: {d?.actionId}; + } else if (type.includes("marked_complete")) { + detailsContent = Manually marked complete; + } else if (type.includes("annotation") || type.includes("note")) { + detailsContent = {d?.description || d?.note || d?.message || "No content"}; + } else if (type.includes("step")) { + detailsContent = Step: {d?.stepName || d?.name || (d?.index !== undefined ? `Index ${d.index}` : "")}; + } else if (type.includes("action_executed")) { + const name = d?.actionName || d?.actionId; + const meta = d?.actionType ? `(${d.actionType})` : d?.type ? `(${d.type})` : ""; + detailsContent = Executed: {name} {meta}; + } else if (type.includes("robot") || type.includes("say") || type.includes("speech")) { + const text = d?.text || d?.message || d?.data?.text; + detailsContent = ( + + Robot: {d?.command || d?.type || "Action"} + {text && "{text}"} + + ); + } else if (type.includes("intervention")) { + detailsContent = Intervention: {d?.type || "Manual Action"}; + } else if (type === "trial_started") { + detailsContent = Trial Started; + } else if (type === "trial_completed") { + detailsContent = Trial Completed; + } else if (type === "trial_paused") { + detailsContent = Trial Paused; + } else if (isCamera) { + detailsContent = {type === "camera_started" ? "Recording Started" : type === "camera_stopped" ? "Recording Stopped" : "Camera Event"}; + } else { + // Default + if (d && Object.keys(d).length > 0) { + detailsContent = ( + + {JSON.stringify(d).replace(/[{"}]/g, " ").trim()} + + ); + } else { + detailsContent = -; + } + } + + const isActive = activeEventId === event.id; + + return ( + { + if (event.id) rowRefs.current[event.id] = el; + }} + className={cn( + "cursor-pointer h-auto border-l-2 border-transparent transition-colors", + isActive + ? "bg-muted border-l-primary" + : "hover:bg-muted/50" + )} + onClick={() => handleRowClick(event)} + > + +
+ + {formatRelativeTime(event.timestamp, startTime)} + + + {new Date(event.timestamp).toLocaleTimeString()} + +
+
+ +
+ + + {type.replace(/_/g, " ")} + +
+
+ +
+ {detailsContent} +
+
+
+ ); + }) + )} +
+
+
+
); } diff --git a/src/components/trials/playback/EventTimeline.tsx b/src/components/trials/playback/EventTimeline.tsx index 77ae556..63139c2 100644 --- a/src/components/trials/playback/EventTimeline.tsx +++ b/src/components/trials/playback/EventTimeline.tsx @@ -83,20 +83,21 @@ export function EventTimeline() { }, [effectiveDuration]); const getEventIcon = (type: string) => { - if (type.includes("intervention") || type.includes("wizard")) return ; + if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return ; if (type.includes("robot") || type.includes("action")) return ; if (type.includes("completed")) return ; if (type.includes("start")) return ; - if (type.includes("note")) return ; + if (type.includes("note") || type.includes("annotation")) return ; if (type.includes("error")) return ; return ; }; const getEventColor = (type: string) => { - if (type.includes("intervention") || type.includes("wizard")) return "bg-orange-100 text-orange-600 border-orange-200"; + if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return "bg-orange-100 text-orange-600 border-orange-200"; if (type.includes("robot") || type.includes("action")) return "bg-purple-100 text-purple-600 border-purple-200"; if (type.includes("completed")) return "bg-green-100 text-green-600 border-green-200"; if (type.includes("start")) return "bg-blue-100 text-blue-600 border-blue-200"; + if (type.includes("note") || type.includes("annotation")) return "bg-yellow-100 text-yellow-600 border-yellow-200"; if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200"; return "bg-slate-100 text-slate-600 border-slate-200"; }; @@ -132,19 +133,37 @@ export function EventTimeline() { {sortedEvents.map((event, i) => { const pct = getPercentage(new Date(event.timestamp).getTime()); + // Smart Formatting Logic + const details = (() => { + const { eventType, data } = event; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = data as any; + + if (eventType.includes("jump")) return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`; + if (eventType.includes("skipped")) return `Skipped: ${d?.actionId}`; + if (eventType.includes("marked_complete")) return "Manually marked complete"; + if (eventType.includes("annotation") || eventType.includes("note")) return d?.description || d?.note || d?.message || "Note"; + + if (!d || Object.keys(d).length === 0) return null; + return JSON.stringify(d).slice(0, 100).replace(/[{""}]/g, " ").trim(); + })(); + return (
{ e.stopPropagation(); - seekTo((new Date(event.timestamp).getTime() - startTime) / 1000); + // startTime is in ms, timestamp is Date string or obj + const timeMs = new Date(event.timestamp).getTime(); + const seekSeconds = (timeMs - startTime) / 1000; + seekTo(Math.max(0, seekSeconds)); }} >
{getEventIcon(event.eventType)} @@ -156,9 +175,9 @@ export function EventTimeline() {
{new Date(event.timestamp).toLocaleTimeString()}
- {!!event.data && ( -
- {JSON.stringify(event.data as object).slice(0, 100)} + {!!details && ( +
+ {details}
)} diff --git a/src/components/trials/playback/PlaybackPlayer.tsx b/src/components/trials/playback/PlaybackPlayer.tsx index 00e395d..73e91fd 100644 --- a/src/components/trials/playback/PlaybackPlayer.tsx +++ b/src/components/trials/playback/PlaybackPlayer.tsx @@ -85,13 +85,14 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {