From 29ce71e8318ea4a0089e9f4104b3b6d5bcf3140f Mon Sep 17 00:00:00 2001 From: averel10 Date: Fri, 3 Apr 2026 08:38:14 +0200 Subject: [PATCH] feat: added admin participant detail page --- src/app/actions/calibration-scoring.ts | 40 ++- src/app/actions/participants.ts | 231 ++++++++++++++++++ src/app/admin/experiments/[id]/page.tsx | 8 +- .../[id]/participants/[userId]/page.tsx | 101 ++++++++ .../experiments/[id]/participants/page.tsx | 83 +++++++ src/components/ParticipantDetailView.tsx | 183 ++++++++++++++ src/components/ParticipantsList.tsx | 83 +++++++ 7 files changed, 722 insertions(+), 7 deletions(-) create mode 100644 src/app/actions/participants.ts create mode 100644 src/app/admin/experiments/[id]/participants/[userId]/page.tsx create mode 100644 src/app/admin/experiments/[id]/participants/page.tsx create mode 100644 src/components/ParticipantDetailView.tsx create mode 100644 src/components/ParticipantsList.tsx diff --git a/src/app/actions/calibration-scoring.ts b/src/app/actions/calibration-scoring.ts index b309f3e..17abf84 100644 --- a/src/app/actions/calibration-scoring.ts +++ b/src/app/actions/calibration-scoring.ts @@ -5,41 +5,69 @@ import { experiment_calibration } from '@/lib/model/experiment_calibration'; import { experiment } from '@/lib/model/experiment'; import { participant } from '@/lib/model/participant'; import { eq, and } from 'drizzle-orm'; -import { auth } from '@/lib/auth'; +import { auth, requireAdmin } from '@/lib/auth'; import { headers } from 'next/headers'; /** - * Calculates dialect performance scores based on the participant's calibration answers. + * Calculates dialect performance scores based on calibration answers. * Score = (correctness × confidence) averaged per dialect * Range: -1 to 1, where: * 1 = perfect identification with full confidence * 0 = neutral (correct but unsure, or incorrect and unsure) * -1 = completely wrong with full confidence (penalizes false confidence) + * + * @param experimentId - The experiment ID + * @param userId - Optional user ID. If provided, requires admin privileges. If not provided, uses current user. */ export async function getDialectScoresFromCalibration( - experimentId: number + experimentId: number, + userId?: string ): Promise> { const session = await auth.api.getSession({ headers: await headers() }); if (!session) throw new Error('Nicht angemeldet'); - // Fetch calibration items and participant's answers + // Determine which user to fetch scores for + let targetUserId = userId; + if (targetUserId) { + // Admin access required for fetching other user's scores + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + } else { + // Use current user if no userId provided + targetUserId = session.user.id; + } + + // Fetch calibration items const calibrationItems = await db .select() .from(experiment_calibration) .where(eq(experiment_calibration.experimentId, experimentId)); + // Fetch participant record const participantRecord = await db .select() .from(participant) .where( and( eq(participant.experimentId, experimentId), - eq(participant.userId, session.user.id) + eq(participant.userId, targetUserId) ) ) .limit(1); - // Calculate dialect scores based on calibration performance + // Calculate dialect scores + return calculateDialectScores(calibrationItems, participantRecord); +} + +/** + * Helper function to calculate dialect scores from calibration items and answers + */ +function calculateDialectScores( + calibrationItems: typeof experiment_calibration.$inferSelect[], + participantRecord: (typeof participant.$inferSelect)[] +): Record { const dialectScores: Record = {}; if (participantRecord.length > 0 && participantRecord[0].calibrationAnswers) { diff --git a/src/app/actions/participants.ts b/src/app/actions/participants.ts new file mode 100644 index 0000000..2203141 --- /dev/null +++ b/src/app/actions/participants.ts @@ -0,0 +1,231 @@ +'use server'; + +import db from '@/lib/db'; +import { participant } from '@/lib/model/participant'; +import { annotation } from '@/lib/model/annotation'; +import { experiment } from '@/lib/model/experiment'; +import { dataset_entry } from '@/lib/model/dataset_entry'; +import { and, eq, count } from 'drizzle-orm'; +import { requireAdmin } from '@/lib/auth'; +import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring'; + +export interface ParticipantListItem { + id: number; + experimentId: number; + userId: string; + completedOnboarding: boolean; + completedCalibration: boolean; + annotationCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface ParticipantDetail extends ParticipantListItem { + onboardingAnswers: any; + calibrationAnswers: any; + annotations: Array<{ + id: number; + datasetEntryId: number; + externalId: string; + fileName: string; + datasetId: number; + rating: number; + dialectLabel: string; + createdAt: Date; + }>; +} + +/** + * Get all participants for an experiment as a list + */ +export async function getParticipantsList(experimentId: number): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1); + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Get all participants for this experiment + const participants = await db + .select() + .from(participant) + .where(eq(participant.experimentId, experimentId)); + + // Build list with annotation counts + const participantsList: ParticipantListItem[] = []; + + for (const p of participants) { + // Count annotations for this participant + const annotationCounts = await db + .select({ count: count().as('count') }) + .from(annotation) + .where( + and( + eq(annotation.experimentId, experimentId), + eq(annotation.userId, p.userId) + ) + ); + + const annotationCount = annotationCounts[0]?.count || 0; + + // Use the proper calibration validation function + const completedCalibration = await isParticipantCalibrationDone(experimentId, p); + + participantsList.push({ + id: p.id, + experimentId: p.experimentId!, + userId: p.userId, + completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined, + completedCalibration, + annotationCount, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + }); + } + + // Sort by descending creation date + participantsList.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return participantsList; + } catch (error) { + console.error('Error fetching participants list:', error); + throw error; + } +} + +/** + * Get detailed information about a specific participant + */ +export async function getParticipantDetail( + experimentId: number, + userId: string +): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1); + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Get participant + const participants = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, userId) + ) + ) + .limit(1); + + if (!participants || participants.length === 0) { + throw new Error('Participant not found'); + } + + const p = participants[0]; + + // Get all annotations for this participant + const participantAnnotations = await db + .select({ + id: annotation.id, + datasetEntryId: annotation.datasetEntryId, + externalId: dataset_entry.externalId, + fileName: dataset_entry.fileName, + datasetId: dataset_entry.datasetId, + rating: annotation.rating, + dialectLabel: annotation.dialectLabel, + createdAt: annotation.createdAt, + }) + .from(annotation) + .leftJoin(dataset_entry, eq(annotation.datasetEntryId, dataset_entry.id)) + .where( + and( + eq(annotation.experimentId, experimentId), + eq(annotation.userId, userId) + ) + ); + + // Use the proper calibration validation function + const completedCalibration = await isParticipantCalibrationDone(experimentId, p); + + return { + id: p.id, + experimentId: p.experimentId!, + userId: p.userId, + completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined, + completedCalibration, + onboardingAnswers: p.onboardingAnswers || {}, + calibrationAnswers: p.calibrationAnswers || {}, + annotationCount: participantAnnotations.length, + annotations: participantAnnotations.map(a => ({ + id: a.id, + datasetEntryId: a.datasetEntryId, + externalId: a.externalId || 'Unknown', fileName: a.fileName || 'Unknown', + datasetId: a.datasetId || 0, rating: a.rating, + dialectLabel: a.dialectLabel, + createdAt: a.createdAt, + })), + createdAt: p.createdAt, + updatedAt: p.updatedAt, + }; + } catch (error) { + console.error('Error fetching participant detail:', error); + throw error; + } +} + +/** + * Get calibration scores for a participant + */ +export async function getParticipantCalibrationScores( + experimentId: number, + userId: string +): Promise> { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1); + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Verify participant exists + const participants = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, userId) + ) + ) + .limit(1); + + if (!participants || participants.length === 0) { + throw new Error('Participant not found'); + } + + // Get calibration scores using the existing server action + const scores = await getDialectScoresFromCalibration(experimentId, userId); + return scores; + } catch (error) { + console.error('Error fetching participant calibration scores:', error); + throw error; + } +} diff --git a/src/app/admin/experiments/[id]/page.tsx b/src/app/admin/experiments/[id]/page.tsx index 52146f1..13a6e4d 100644 --- a/src/app/admin/experiments/[id]/page.tsx +++ b/src/app/admin/experiments/[id]/page.tsx @@ -68,7 +68,13 @@ export default async function ExperimentPage({ params }: ExperimentPageProps) { -
+
+ + View Participants + diff --git a/src/app/admin/experiments/[id]/participants/[userId]/page.tsx b/src/app/admin/experiments/[id]/participants/[userId]/page.tsx new file mode 100644 index 0000000..612bf5b --- /dev/null +++ b/src/app/admin/experiments/[id]/participants/[userId]/page.tsx @@ -0,0 +1,101 @@ +import Link from 'next/link'; +import { getParticipantDetail } from '@/app/actions/participants'; +import { requireAdmin } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import db from '@/lib/db'; +import { experiment } from '@/lib/model/experiment'; +import { eq } from 'drizzle-orm'; +import ParticipantDetailView from '@/components/ParticipantDetailView'; + +interface ParticipantDetailPageProps { + params: { + id: string; + userId: string; + }; +} + +export default async function ParticipantPage({ params }: ParticipantDetailPageProps) { + const result = await requireAdmin(); + + if (!result.authenticated) { + redirect("/user/sign-in"); + } + + if (!result.admin) { + redirect("/"); + } + + const { id, userId } = await params; + const experimentId = parseInt(id, 10); + + // Verify experiment exists + const experiments = await db + .select() + .from(experiment) + .where(eq(experiment.id, experimentId)); + + if (experiments.length === 0) { + return ( +
+
+ + ← Back to Experiments + +
+

Experiment not found

+
+
+
+ ); + } + + const exp = experiments[0]; + + // Get participant details + let participant; + try { + participant = await getParticipantDetail(experimentId, userId); + } catch (error) { + return ( +
+
+ + ← Back to Participants + +
+

Participant not found

+
+
+
+ ); + } + + return ( +
+
+
+ + ← Back to Participants + +
+
+

{exp.name}

+

Participant Data

+
+
+
+ + +
+
+ ); +} diff --git a/src/app/admin/experiments/[id]/participants/page.tsx b/src/app/admin/experiments/[id]/participants/page.tsx new file mode 100644 index 0000000..ab603f7 --- /dev/null +++ b/src/app/admin/experiments/[id]/participants/page.tsx @@ -0,0 +1,83 @@ +import Link from 'next/link'; +import { getParticipantsList } from '@/app/actions/participants'; +import { requireAdmin } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import db from '@/lib/db'; +import { experiment } from '@/lib/model/experiment'; +import { eq } from 'drizzle-orm'; +import ParticipantsList from '@/components/ParticipantsList'; + +interface ParticipantsPageProps { + params: { + id: string; + }; +} + +export default async function ParticipantsPage({ params }: ParticipantsPageProps) { + const result = await requireAdmin(); + + if (!result.authenticated) { + redirect("/user/sign-in"); + } + + if (!result.admin) { + redirect("/"); + } + + const { id } = await params; + const experimentId = parseInt(id, 10); + + // Verify experiment exists + const experiments = await db + .select() + .from(experiment) + .where(eq(experiment.id, experimentId)); + + if (experiments.length === 0) { + return ( +
+
+ + ← Back to Experiments + +
+

Experiment not found

+
+
+
+ ); + } + + const exp = experiments[0]; + + // Get participants + const participants = await getParticipantsList(experimentId); + + return ( +
+
+
+
+ + ← Back to Experiment + +

Participants

+

{exp.name}

+
+
+
{participants.length}
+
Total Participants
+
+
+ + +
+
+ ); +} diff --git a/src/components/ParticipantDetailView.tsx b/src/components/ParticipantDetailView.tsx new file mode 100644 index 0000000..3fd8b45 --- /dev/null +++ b/src/components/ParticipantDetailView.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { ParticipantDetail, getParticipantCalibrationScores } from '@/app/actions/participants'; +import { useEffect, useState } from 'react'; +import CalibrationScoresDisplay from './CalibrationScoresDisplay'; +import AudioPlayer from './AudioPlayer'; + +interface ParticipantDetailViewProps { + participant: ParticipantDetail; + experimentId: number; +} + +interface CalibrationScores { + [dialectLabel: string]: number; +} + +export default function ParticipantDetailView({ participant, experimentId }: ParticipantDetailViewProps) { + const [selectedAnnotation, setSelectedAnnotation] = useState<(typeof participant.annotations)[0] | null>( + participant.annotations.length > 0 ? participant.annotations[0] : null + ); + const [calibrationScores, setCalibrationScores] = useState(null); + const [scoresLoading, setScoresLoading] = useState(false); + const [scoresError, setScoresError] = useState(null); + + useEffect(() => { + if (participant.completedCalibration) { + const fetchScores = async () => { + setScoresLoading(true); + setScoresError(null); + try { + const scores = await getParticipantCalibrationScores(experimentId, participant.userId); + setCalibrationScores(scores); + } catch (err) { + setScoresError(err instanceof Error ? err.message : 'Failed to load calibration scores'); + } finally { + setScoresLoading(false); + } + }; + fetchScores(); + } + }, [experimentId, participant.userId, participant.completedCalibration]); + + return ( +
+ {/* Header Card */} +
+

{participant.userId}

+
+
+
Status
+
Joined
+
+ {new Date(participant.createdAt).toLocaleDateString()} +
+
+
+
Onboarding
+
+ {participant.completedOnboarding ? '✓ Completed' : '○ Pending'} +
+
+
+
Calibration
+
+ {participant.completedCalibration ? '✓ Completed' : '○ Pending'} +
+
+
+
Annotations
+
{participant.annotationCount}
+
+
+
+ + {/* Onboarding Data */} + {participant.completedOnboarding && ( +
+

Onboarding Data

+
+ {JSON.stringify(participant.onboardingAnswers, null, 2)} +
+
+ )} + + {/* Calibration Data */} + {participant.completedCalibration && ( +
+

Calibration Data

+
+ {JSON.stringify(participant.calibrationAnswers, null, 2)} +
+
+ )} + + {/* Calibration Scores */} + {participant.completedCalibration && ( +
+

Calibration Scores

+ {scoresLoading &&
Loading scores...
} + {scoresError &&
Error: {scoresError}
} + {calibrationScores && Object.keys(calibrationScores).length > 0 ? ( + + ) : ( +
No calibration scores available
+ )} +
+ )} + + {/* Annotations */} + {participant.annotationCount > 0 && ( +
+ {/* Annotations List */} +
+

Annotations ({participant.annotationCount})

+
+ {participant.annotations.map((annotation) => ( + + ))} +
+
+ + {/* Selected Annotation Details */} + {selectedAnnotation && ( +
+

Details

+
+
+
External ID
+
{selectedAnnotation.externalId}
+
+
+
Rating
+
{selectedAnnotation.rating}
+
+
+
Dialect
+
+ {selectedAnnotation.dialectLabel} +
+
+
+
Timestamp
+
{new Date(selectedAnnotation.createdAt).toLocaleString()}
+
+
+
Audio
+ +
+
+
+ )} +
+ )} + + {participant.annotationCount === 0 && ( +
+

No annotations yet

+
+ )} +
+ ); +} diff --git a/src/components/ParticipantsList.tsx b/src/components/ParticipantsList.tsx new file mode 100644 index 0000000..9dab0cd --- /dev/null +++ b/src/components/ParticipantsList.tsx @@ -0,0 +1,83 @@ +'use client'; + +import Link from 'next/link'; +import { ParticipantListItem } from '@/app/actions/participants'; + +interface ParticipantsListProps { + experimentId: number; + participants: ParticipantListItem[]; +} + +export default function ParticipantsList({ experimentId, participants }: ParticipantsListProps) { + if (participants.length === 0) { + return ( +
+

No participants yet

+
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {participants.map((participant) => ( + + + + + + + + + ))} + +
User IDOnboardingCalibrationAnnotationsJoinedAction
{participant.userId} + + {participant.completedOnboarding ? '✓ Done' : '○ Pending'} + + + + {participant.completedCalibration ? '✓ Done' : '○ Pending'} + + + + {participant.annotationCount} + + + {new Date(participant.createdAt).toLocaleDateString()} + + + View + +
+
+
+ ); +}