From 9a54d91a4fa03578f946142e6836c70024348ed9 Mon Sep 17 00:00:00 2001 From: averel10 Date: Fri, 27 Mar 2026 07:28:35 +0100 Subject: [PATCH] participant-calibration flow implemented --- src/app/actions/annotations.ts | 59 +++++ src/app/actions/calibration.ts | 62 +++++ src/app/annotate/[experimentId]/page.tsx | 12 +- .../CalibrationViews/CalibrationEntryView.tsx | 165 ++++++++++++ .../CalibrationViews/CalibrationPageView.tsx | 241 ++++++++++++++++++ .../CalibrationViews/CalibrationPhase.tsx | 60 +++++ src/lib/dialects.ts | 10 + 7 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 src/components/CalibrationViews/CalibrationEntryView.tsx create mode 100644 src/components/CalibrationViews/CalibrationPageView.tsx create mode 100644 src/components/CalibrationViews/CalibrationPhase.tsx diff --git a/src/app/actions/annotations.ts b/src/app/actions/annotations.ts index b10abdf..76a6c2b 100644 --- a/src/app/actions/annotations.ts +++ b/src/app/actions/annotations.ts @@ -9,6 +9,8 @@ import { auth } from '@/lib/auth'; import { headers } from 'next/headers'; import type { DatasetEntryForAnnotation } from '@/lib/dialects'; import { experiment } from '@/lib/model/experiment'; +import { experiment_calibration } from '@/lib/model/experiment_calibration'; +import { participant } from '@/lib/model/participant'; /** * Returns unannotated entries for the given dataset and authenticated user. @@ -133,3 +135,60 @@ export async function getAnnotationProgress( return { total: allEntries.length, done: annotated.length }; } +/** + * Checks if calibration is required and completed for the current user in an experiment. + * Returns true if calibration is completed or not required, false if calibration is pending. + */ +export async function isCalibrationDone(experimentId: number): Promise { + console.log('Checking calibration status for experimentId:', experimentId); + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + // Get calibration items for this experiment + const calibrationItems = await db + .select() + .from(experiment_calibration) + .where(eq(experiment_calibration.experimentId, experimentId)); + + // If no calibration items are defined, calibration is not required + if (calibrationItems.length === 0) { + return true; + } + + // Check if participant exists for this user and experiment + const participantRecord = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, session.user.id) + ) + ) + .limit(1); + + // If no participant record exists, calibration is not done + if (participantRecord.length === 0) { + return false; + } + + // Check if calibration answers are filled + const calibrationAnswers = participantRecord[0].calibrationAnswers as Record | null; + + if (!calibrationAnswers) { + return false; + } + + // Verify that all calibration items have complete answers + for (const item of calibrationItems) { + const answer = calibrationAnswers[item.id]; + + // Check if answer exists and has both required fields + if (!answer || !answer.dialectLabel || !answer.confidence) { + return false; + } + } + + return true; +} + diff --git a/src/app/actions/calibration.ts b/src/app/actions/calibration.ts index 0604a49..3ae50aa 100644 --- a/src/app/actions/calibration.ts +++ b/src/app/actions/calibration.ts @@ -2,9 +2,12 @@ import db from '@/lib/db'; import { experiment_calibration } from '@/lib/model/experiment_calibration'; +import { participant } from '@/lib/model/participant'; import { eq, and } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; import { requireAdmin } from '@/lib/auth'; +import { auth } from '@/lib/auth'; +import { headers } from 'next/headers'; import { unlink } from 'fs/promises'; import { join } from 'path'; @@ -22,6 +25,33 @@ export async function getCalibrationEntries(experimentId: number) { } } +export async function getCalibrationAnswers(experimentId: number) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + const participantRecord = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, session.user.id) + ) + ) + .limit(1); + + if (participantRecord.length === 0) { + return { success: true, data: null }; + } + + return { success: true, data: participantRecord[0].calibrationAnswers }; + } catch (error) { + console.error('Error fetching calibration answers:', error); + throw new Error('Failed to fetch calibration answers'); + } +} + export async function deleteCalibrationEntry(calibrationId: number, experimentId: number) { const result = await requireAdmin(); if (!result.authenticated || !result.admin) { @@ -133,3 +163,35 @@ export async function deleteAllCalibrationEntries(experimentId: number) { throw new Error('Failed to delete all calibration entries'); } } + +/** + * Saves calibration answers for the current user in an experiment. + * Creates or updates a participant record with the calibration answers as JSON. + */ +export async function saveCalibrationAnswers( + experimentId: number, + answers: Record +): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + try { + // Upsert participant record with calibration answers + await db + .insert(participant) + .values({ + experimentId, + userId: session.user.id, + calibrationAnswers: answers, + }) + .onConflictDoUpdate({ + target: [participant.experimentId, participant.userId], + set: { + calibrationAnswers: answers, + }, + }); + } catch (error) { + console.error('Error saving calibration answers:', error); + throw new Error('Failed to save calibration answers'); + } +} diff --git a/src/app/annotate/[experimentId]/page.tsx b/src/app/annotate/[experimentId]/page.tsx index c3b3c9c..79a162d 100644 --- a/src/app/annotate/[experimentId]/page.tsx +++ b/src/app/annotate/[experimentId]/page.tsx @@ -2,9 +2,10 @@ import { redirect, notFound } from 'next/navigation'; import { headers } from 'next/headers'; import Link from 'next/link'; import { auth } from '@/lib/auth'; -import { getAnnotationEntries } from '@/app/actions/annotations'; +import { getAnnotationEntries, isCalibrationDone } from '@/app/actions/annotations'; import { getExperimentById } from '@/app/actions/experiment'; import AnnotationPageView from '@/components/AnnotationViews/AnnotationPageView'; +import CalibrationPhase from '@/components/CalibrationViews/CalibrationPhase'; interface Props { params: Promise<{ experimentId: string}>; @@ -16,8 +17,7 @@ export default async function AnnotatePage({ params }: Props) { const { experimentId: experimentIdStr } = await params; const experimentId = parseInt(experimentIdStr, 10); - const prototype = await getExperimentById(experimentId).then(exp => exp?.annotationTool || null); - + if (isNaN(experimentId)) { return (
@@ -29,7 +29,13 @@ export default async function AnnotatePage({ params }: Props) { ); } + // Check if calibration is required and not yet done + const calibrationDone = await isCalibrationDone(experimentId); + if (!calibrationDone) { + return ; + } + const prototype = await getExperimentById(experimentId).then(exp => exp?.annotationTool || null); const entries = await getAnnotationEntries(experimentId); if (entries.length === 0) { diff --git a/src/components/CalibrationViews/CalibrationEntryView.tsx b/src/components/CalibrationViews/CalibrationEntryView.tsx new file mode 100644 index 0000000..0f97b07 --- /dev/null +++ b/src/components/CalibrationViews/CalibrationEntryView.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import WaveformPlayer from '../WaveformPlayer'; +import { DIALECT_LABELS, DIALECT_LABELS_WITHOUT_DE } from '@/lib/dialects'; +import { ExperimentCalibration } from '@/lib/model/experiment_calibration'; + +interface CalibrationEntryViewProps { + entry: ExperimentCalibration; + onDialectSelect: (dialectLabel: string) => Promise; + onConfidenceSelect: (confidence: number) => Promise; + isSaving: boolean; + existingAnswer?: { dialectLabel: string; confidence: number } | null; +} + +const CONFIDENCE_OPTIONS = [ + { value: 1, label: 'Sehr unsicher' }, + { value: 2, label: 'Eher unsicher' }, + { value: 3, label: 'Neutral' }, + { value: 4, label: 'Eher sicher' }, + { value: 5, label: 'Sehr sicher' }, +]; + +export default function CalibrationEntryView({ + entry, + onDialectSelect, + onConfidenceSelect, + isSaving, + existingAnswer, +}: CalibrationEntryViewProps) { + const [selectedDialect, setSelectedDialect] = useState(existingAnswer?.dialectLabel || null); + const [selectedConfidence, setSelectedConfidence] = useState(existingAnswer?.confidence || null); + const [fullyPlayed, setFullyPlayed] = useState(existingAnswer ? true : false); + + const audioSrc = entry.file; + + const handleDialectChange = async (dialect: string) => { + setSelectedDialect(dialect); + await onDialectSelect(dialect); + }; + + const handleConfidenceChange = async (confidence: number) => { + setSelectedConfidence(confidence); + await onConfidenceSelect(confidence); + }; + + useEffect(() => { + // Reset state when entry changes + setSelectedDialect(existingAnswer?.dialectLabel || null); + setSelectedConfidence(existingAnswer?.confidence || null); + setFullyPlayed(existingAnswer ? true : false); + }, [entry, existingAnswer]); + + return ( +
+ {/* Sample header */} +
+ + Kalibrierungssample - {entry.order} + + {fullyPlayed && ( + + ✓ Gehört + + )} +
+ + {/* Waveform player */} + setFullyPlayed(true)} + /> + + {/* Must-listen hint */} + {!fullyPlayed && ( +

+ Bitte das Sample vollständig anhören, bevor Sie eine Bewertung abgeben. +

+ )} + + {/* Dialect question */} +
+
+ Welcher Dialekt ist es? +
+
+ {Object.entries(DIALECT_LABELS_WITHOUT_DE).map(([dialectKey, dialectName]) => { + const selected = selectedDialect === dialectKey; + const disabled = !fullyPlayed || isSaving; + return ( + + ); + })} +
+
+ + {/* Confidence question */} +
+
+ Wie sicher sind Sie? +
+
+ {CONFIDENCE_OPTIONS.map(({ value, label }) => { + const selected = selectedConfidence === value; + const disabled = !fullyPlayed || isSaving; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/CalibrationViews/CalibrationPageView.tsx b/src/components/CalibrationViews/CalibrationPageView.tsx new file mode 100644 index 0000000..b9c5e7a --- /dev/null +++ b/src/components/CalibrationViews/CalibrationPageView.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useState, useTransition, useMemo, useEffect } from 'react'; +import Link from 'next/link'; +import { saveCalibrationAnswers, getCalibrationAnswers } from '@/app/actions/calibration'; +import { ExperimentCalibration } from '@/lib/model/experiment_calibration'; +import CalibrationEntryView from './CalibrationEntryView'; + +interface CalibrationPageViewProps { + entries: ExperimentCalibration[]; + experimentId: number; +} + +interface CalibrationAnswers { + [key: number]: { + calibrationItemId: number; + dialectLabel: string; + confidence: number; + }; +} + +export default function CalibrationPageView({ + entries, + experimentId, +}: CalibrationPageViewProps) { + const [isPending, startTransition] = useTransition(); + const [answers, setAnswers] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [currentIndex, setCurrentIndex] = useState(0); + + // Load existing answers on component mount + useEffect(() => { + const loadExistingAnswers = async () => { + try { + const result = await getCalibrationAnswers(experimentId); + if (result.success && result.data) { + setAnswers(result.data as CalibrationAnswers); + + // Find the first unanswered entry + const firstUnanswered = entries.findIndex((e) => { + const answer = (result.data as CalibrationAnswers)[e.id]; + return !answer || !answer.dialectLabel || !answer.confidence; + }); + + if (firstUnanswered !== -1) { + setCurrentIndex(firstUnanswered); + } + } + } catch (error) { + console.error('Error loading calibration answers:', error); + } finally { + setIsLoading(false); + } + }; + + loadExistingAnswers(); + }, [experimentId, entries]); + + const currentEntry = entries[currentIndex]; + + // Count only complete answers (both dialect and confidence filled) + const completeAnswers = Object.values(answers).filter( + (answer) => answer.dialectLabel && answer.confidence > 0 + ); + const isComplete = entries.length > 0 && completeAnswers.length === entries.length; + const progressPct = Math.round((completeAnswers.length / entries.length) * 100); + + // Check if current entry has complete answers + const currentEntryComplete = currentEntry && + answers[currentEntry.id]?.dialectLabel && + answers[currentEntry.id]?.confidence > 0; + + const handlePrevious = () => { + if (currentIndex > 0) { + setCurrentIndex((i) => i - 1); + } + }; + + const handleNext = () => { + if (currentIndex + 1 < entries.length) { + setCurrentIndex((i) => i + 1); + } + }; + + const handleSaveEntry = async (dialectLabel: string, confidence: number) => { + startTransition(async () => { + // Build updated answers + const updatedAnswers = { ...answers }; + + // Initialize entry if it doesn't exist + if (!updatedAnswers[currentEntry.id]) { + updatedAnswers[currentEntry.id] = { + calibrationItemId: currentEntry.id, + dialectLabel: '', + confidence: 0, + }; + } + + // Update with new values (keep existing values if not provided) + if (dialectLabel) { + updatedAnswers[currentEntry.id].dialectLabel = dialectLabel; + } + if (confidence > 0) { + updatedAnswers[currentEntry.id].confidence = confidence; + } + + setAnswers(updatedAnswers); + + // Save to database immediately + try { + await saveCalibrationAnswers(experimentId, updatedAnswers); + } catch (error) { + console.error('Error saving calibration answers:', error); + } + + // Auto-advance to next entry only if both answers are selected + const entry = updatedAnswers[currentEntry.id]; + if (entry.dialectLabel && entry.confidence > 0) { + // Don't auto-advance if it's the last entry + if (currentIndex < entries.length - 1) { + handleNext(); + } + } + }); + }; + + const handleDialectSelect = async (dialect: string) => { + const currentConfidence = answers[currentEntry.id]?.confidence || 0; + await handleSaveEntry(dialect, currentConfidence); + }; + + const handleConfidenceSelect = async (confidence: number) => { + const currentDialect = answers[currentEntry.id]?.dialectLabel || ''; + await handleSaveEntry(currentDialect, confidence); + }; + + const handleCompleteCalibration = async () => { + startTransition(async () => { + // All answers are already saved, just trigger a page reload or redirect + // Reload the page to check calibration status again + window.location.reload(); + }); + }; + + if (isLoading) { + return ( +
+
+
+
+

Laden Sie Ihre vorherigen Antworten...

+
+
+
+ ); + } + + if (isComplete) { + return ( +
+
+
+
+
🎉
+

+ Kalibrierung abgeschlossen! +

+

+ Sie haben alle Kalibrierungssamples bewertet. Vielen Dank! Sie können jetzt mit der + Annotation beginnen. +

+ +
+
+
+
+ ); + } + + return ( +
+
+ {/* Header with progress */} +
+
+

Kalibrierungsphase

+ + {completeAnswers.length} / {entries.length} + +
+
+
+
+
+ + {/* Main content */} +
+
+ {currentEntry && ( + + )} +
+
+ + {/* Navigation footer */} +
+ + +
+
+
+ ); +} diff --git a/src/components/CalibrationViews/CalibrationPhase.tsx b/src/components/CalibrationViews/CalibrationPhase.tsx new file mode 100644 index 0000000..1f2297a --- /dev/null +++ b/src/components/CalibrationViews/CalibrationPhase.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getCalibrationEntries } from '@/app/actions/calibration'; +import { ExperimentCalibration } from '@/lib/model/experiment_calibration'; +import CalibrationPageView from './CalibrationPageView'; + +interface CalibrationPhaseProps { + experimentId: number; +} + +export default function CalibrationPhase({ experimentId }: CalibrationPhaseProps) { + const [entries, setEntries] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const load = async () => { + try { + const result = await getCalibrationEntries(experimentId); + if (result.success && result.data) { + // Sort by order + const sorted = [...result.data].sort((a, b) => a.order - b.order); + setEntries(sorted); + } + } catch (err) { + setError('Fehler beim Laden der Kalibrierungssamples'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + load(); + }, [experimentId]); + + if (isLoading) { + return ( +
+
+

Lädt Kalibrierungssamples...

+

Bitte warten Sie...

+
+ ); + } + + if (error || !entries || entries.length === 0) { + return ( +
+
+

Kalibrierung nicht verfügbar

+

+ {error || 'Für dieses Experiment sind keine Kalibrierungssamples vorhanden.'} +

+
+ ); + } + + return ; +} diff --git a/src/lib/dialects.ts b/src/lib/dialects.ts index c29c7c1..818fadf 100644 --- a/src/lib/dialects.ts +++ b/src/lib/dialects.ts @@ -9,6 +9,16 @@ export const DIALECT_LABELS: Record = { de: 'Deutsch', }; +export const DIALECT_LABELS_WITHOUT_DE: Record = { + ch_be: 'Bern', + ch_bs: 'Basel', + ch_gr: 'Graubünden', + ch_in: 'Innerschweiz', + ch_os: 'Ostschweiz', + ch_vs: 'Wallis', + ch_zh: 'Zürich' +}; + export type DatasetEntryForAnnotation = { id: number; externalId: string;