From c071d336240f3deb4aa761a439282aead1de72c5 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 8 Aug 2025 00:36:41 -0400 Subject: [PATCH] docs: update participant trialCount documentation; fix participants view & experiments router diagnostics; add StepPreview placeholder and block-converter smoke test --- docs/api-routes.md | 6 +- docs/database-schema.md | 3 + .../experiments/designer/StepPreview.tsx | 195 +++++++++ .../participants/ParticipantsView.tsx | 165 ++++---- .../__tests__/block-converter.test.ts | 127 ++++++ src/server/api/routers/experiments.ts | 392 +++++++++++++++++- 6 files changed, 811 insertions(+), 77 deletions(-) create mode 100644 src/components/experiments/designer/StepPreview.tsx create mode 100644 src/lib/experiment-designer/__tests__/block-converter.test.ts diff --git a/docs/api-routes.md b/docs/api-routes.md index f7b07c1..1aa5d94 100644 --- a/docs/api-routes.md +++ b/docs/api-routes.md @@ -552,7 +552,7 @@ HRIStudio uses tRPC for type-safe API communication between client and server. A ## Participant Management Routes (`participants`) ### `participants.list` -- **Description**: List study participants +- **Description**: List participants in a study - **Type**: Query - **Input**: ```typescript @@ -563,7 +563,7 @@ HRIStudio uses tRPC for type-safe API communication between client and server. A search?: string } ``` -- **Output**: Paginated participant list +- **Output**: Paginated participant list. Each participant object in this response includes a computed `trialCount` field (number of linked trials) plus derived consent metadata; sensitive fields like `demographics` and `notes` are omitted in this list view. - **Auth Required**: Yes (Study member) ### `participants.get` @@ -586,7 +586,7 @@ HRIStudio uses tRPC for type-safe API communication between client and server. A demographics?: Record } ``` -- **Output**: Created participant object +- **Output**: Created participant object (does NOT include `notes` or `trialCount`; `trialCount` is computed and only appears in list output as an aggregate) - **Auth Required**: Yes (Study researcher) ### `participants.update` diff --git a/docs/database-schema.md b/docs/database-schema.md index a545068..217e71b 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -202,6 +202,9 @@ CREATE TABLE actions ( ```sql -- Participants in studies +-- NOTE: The application exposes a computed `trialCount` field in API list responses. +-- This value is derived at query time by counting linked trials and is NOT persisted +-- as a physical column in this table to avoid redundancy and maintain consistency. CREATE TABLE participants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), study_id UUID NOT NULL REFERENCES studies(id) ON DELETE CASCADE, diff --git a/src/components/experiments/designer/StepPreview.tsx b/src/components/experiments/designer/StepPreview.tsx new file mode 100644 index 0000000..b0f2667 --- /dev/null +++ b/src/components/experiments/designer/StepPreview.tsx @@ -0,0 +1,195 @@ +import { memo } from "react"; +import { Badge } from "~/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +/** + * Lightweight, dependency‑minimal placeholder for step preview rendering in the + * experiment designer. This was added to satisfy references expecting the file + * to exist (diagnostics previously reported it missing). + * + * Replace / extend this component when richer preview logic (block graphs, + * parameter summaries, validation states, drift indicators) is implemented. + * + * Design Goals: + * - Zero external (designer-internal) imports to avoid circular dependencies + * - Strict typing without leaking un-finalized internal step model types + * - Safe rendering even with partial or incomplete data + * - Pure presentational; no side-effects or client hooks required + */ + +export interface StepPreviewAction { + id?: string; + name: string; + description?: string | null; + type?: string | null; + pluginId?: string | null; + pluginVersion?: string | null; + category?: string | null; +} + +export interface StepPreviewProps { + id?: string; + name: string; + description?: string | null; + type?: string | null; + orderIndex?: number; + required?: boolean; + durationEstimateSeconds?: number; + actions?: StepPreviewAction[]; + conditions?: unknown; + validationIssues?: readonly string[]; + integrityHashFragment?: string | null; + /** + * When true, shows a subtle placeholder treatment (e.g. while constructing + * from a transient visual design mutation). + */ + transient?: boolean; +} + +/** + * Stateless pure component – safe to use in server or client trees. + */ +export const StepPreview = memo(function StepPreview({ + name, + description, + type, + orderIndex, + required, + durationEstimateSeconds, + actions = [], + conditions, + validationIssues, + integrityHashFragment, + transient, +}: StepPreviewProps) { + const hasIssues = (validationIssues?.length ?? 0) > 0; + + return ( + + +
+
+ + {orderIndex !== undefined && ( + + #{orderIndex + 1} + + )} + {name || "(Untitled Step)"} + + {description && ( +

+ {description} +

+ )} +
+
+ {type && ( + + {type} + + )} + {required && ( + + Required + + )} + {hasIssues && ( + + {validationIssues?.length} Issue + {validationIssues && validationIssues.length > 1 ? "s" : ""} + + )} +
+
+
+ +
+
+ {durationEstimateSeconds !== undefined && ( + ≈ {Math.max(1, Math.round(durationEstimateSeconds))}s + )} + {actions.length > 0 && ( + + {actions.length} action{actions.length > 1 ? "s" : ""} + + )} + {conditions !== undefined && conditions !== null && ( + Conditional + )} + {integrityHashFragment && ( + + hash:{integrityHashFragment.slice(0, 8)} + + )} + {transient && transient} +
+ + {/* Action summary */} + {actions.length > 0 && ( +
    + {actions.slice(0, 5).map((a, idx) => ( +
  1. + {a.name} + {a.type && ( + + {a.type} + + )} + {a.pluginId && ( + + {a.pluginId} + {a.pluginVersion && ( + @{a.pluginVersion} + )} + + )} +
  2. + ))} + {actions.length > 5 && ( +
  3. + + {actions.length - 5} more… +
  4. + )} +
+ )} + + {hasIssues && validationIssues && ( +
    + {validationIssues.slice(0, 3).map((issue, i) => ( +
  • + • {issue} +
  • + ))} + {validationIssues.length > 3 && ( +
  • + + {validationIssues.length - 3} more… +
  • + )} +
+ )} +
+
+
+ ); +}); + +export default StepPreview; diff --git a/src/components/participants/ParticipantsView.tsx b/src/components/participants/ParticipantsView.tsx index cab69f6..3b69a02 100644 --- a/src/components/participants/ParticipantsView.tsx +++ b/src/components/participants/ParticipantsView.tsx @@ -2,10 +2,20 @@ import { format, formatDistanceToNow } from "date-fns"; import { - AlertCircle, - CheckCircle, - Clock, Download, Eye, MoreHorizontal, Plus, - Search, Shield, Target, Trash2, Upload, Users, UserX + AlertCircle, + CheckCircle, + Clock, + Download, + Eye, + MoreHorizontal, + Plus, + Search, + Shield, + Target, + Trash2, + Upload, + Users, + UserX, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; @@ -14,37 +24,37 @@ import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "~/components/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "~/components/ui/select"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "~/components/ui/table"; import { Textarea } from "~/components/ui/textarea"; import { api } from "~/trpc/react"; @@ -54,13 +64,14 @@ interface Participant { participantCode: string; email: string | null; name: string | null; - demographics: any; + demographics: Record; consentGiven: boolean; consentDate: Date | null; notes: string | null; createdAt: Date; updatedAt: Date; studyId: string; + trialCount: number; _count?: { trials: number; }; @@ -78,7 +89,14 @@ export function ParticipantsView() { const [showConsentDialog, setShowConsentDialog] = useState(false); const [selectedParticipant, setSelectedParticipant] = useState(null); - const [newParticipant, setNewParticipant] = useState({ + const [newParticipant, setNewParticipant] = useState<{ + participantCode: string; + email: string; + name: string; + studyId: string; + demographics: Record; + notes: string; + }>({ participantCode: "", email: "", name: "", @@ -102,12 +120,10 @@ export function ParticipantsView() { { studyId: studyFilter === "all" - ? userStudies?.studies?.[0]?.id || "" + ? (userStudies?.studies?.[0]?.id ?? "") : studyFilter, - search: searchQuery || undefined, + search: searchQuery ?? undefined, limit: 100, - - }, { enabled: !!userStudies?.studies?.length, @@ -117,7 +133,7 @@ export function ParticipantsView() { // Mutations const createParticipantMutation = api.participants.create.useMutation({ onSuccess: () => { - refetch(); + void refetch(); setShowNewParticipantDialog(false); resetNewParticipantForm(); }, @@ -125,7 +141,7 @@ export function ParticipantsView() { const updateConsentMutation = api.participants.update.useMutation({ onSuccess: () => { - refetch(); + void refetch(); setShowConsentDialog(false); setSelectedParticipant(null); }, @@ -133,7 +149,7 @@ export function ParticipantsView() { const deleteParticipantMutation = api.participants.delete.useMutation({ onSuccess: () => { - refetch(); + void refetch(); }, }); @@ -155,31 +171,25 @@ export function ParticipantsView() { await createParticipantMutation.mutateAsync({ participantCode: newParticipant.participantCode, studyId: newParticipant.studyId, - email: newParticipant.email || undefined, - name: newParticipant.name || undefined, + email: newParticipant.email ? newParticipant.email : undefined, + name: newParticipant.name ? newParticipant.name : undefined, demographics: newParticipant.demographics, - }); } catch (_error) { console.error("Failed to create participant:", _error); } - }, [newParticipant, createParticipantMutation]); + }, [createParticipantMutation, newParticipant]); - const handleUpdateConsent = useCallback( - async (consentGiven: boolean) => { - if (!selectedParticipant) return; - - try { - await updateConsentMutation.mutateAsync({ - id: selectedParticipant.id, - - }); - } catch (_error) { - console.error("Failed to update consent:", _error); - } - }, - [selectedParticipant, updateConsentMutation], - ); + const handleUpdateConsent = useCallback(async () => { + if (!selectedParticipant) return; + try { + await updateConsentMutation.mutateAsync({ + id: selectedParticipant.id, + }); + } catch (_error) { + console.error("Failed to update consent:", _error); + } + }, [selectedParticipant, updateConsentMutation]); const handleDeleteParticipant = useCallback( async (participantId: string) => { @@ -230,13 +240,16 @@ export function ParticipantsView() { } }; - const filteredParticipants = - participantsData?.participants?.filter((participant) => { - if (consentFilter === "consented" && !participant.consentGiven) + const filteredParticipants: Participant[] = + (participantsData?.participants?.filter((participant) => { + if (consentFilter === "consented" && !participant.consentGiven) { return false; - if (consentFilter === "pending" && participant.consentGiven) return false; + } + if (consentFilter === "pending" && participant.consentGiven) { + return false; + } return true; - }) || []; + }) as Participant[] | undefined) ?? []; return (
@@ -296,7 +309,7 @@ export function ParticipantsView() { All Studies - {userStudies?.studies?.map((study: any) => ( + {userStudies?.studies?.map((study) => ( {study.name} @@ -317,7 +330,7 @@ export function ParticipantsView() { value={`${sortBy}-${sortOrder}`} onValueChange={(value) => { const [field, order] = value.split("-"); - setSortBy(field || "createdAt"); + setSortBy(field ?? "createdAt"); setSortOrder(order as "asc" | "desc"); }} > @@ -345,7 +358,7 @@ export function ParticipantsView() {

- {participantsData?.pagination?.total || 0} + {participantsData?.pagination?.total ?? 0}

Total Participants

@@ -358,7 +371,11 @@ export function ParticipantsView() {

- {filteredParticipants.filter((p) => p.consentGiven).length} + { + filteredParticipants.filter( + (p: Participant) => p.consentGiven, + ).length + }

Consented

@@ -371,7 +388,11 @@ export function ParticipantsView() {

- {filteredParticipants.filter((p) => !p.consentGiven).length} + { + filteredParticipants.filter( + (p: Participant) => !p.consentGiven, + ).length + }

Pending Consent

@@ -385,7 +406,7 @@ export function ParticipantsView() {

{filteredParticipants.reduce( - (sum, p) => sum + (p.trialCount || 0), + (sum: number, p: Participant) => sum + (p.trialCount ?? 0), 0, )}

@@ -469,11 +490,11 @@ export function ParticipantsView() {
{userStudies?.studies?.find( (s) => s.id === participant.studyId, - )?.name || "Unknown Study"} + )?.name ?? "Unknown Study"}
- {getConsentStatusBadge({...participant, demographics: null, notes: null})} + {getConsentStatusBadge(participant)} {participant.consentDate && (

{format( @@ -484,7 +505,7 @@ export function ParticipantsView() { )} - {getTrialsBadge(participant.trialCount || 0)} + {getTrialsBadge(participant.trialCount ?? 0)}

@@ -512,7 +533,7 @@ export function ParticipantsView() { { - setSelectedParticipant({...participant, demographics: null, notes: null}); + setSelectedParticipant(participant); setShowConsentDialog(true); }} > @@ -696,7 +717,7 @@ export function ParticipantsView() {