added post-listening rating

This commit is contained in:
2025-11-10 23:40:41 +01:00
parent edd1d34900
commit 6f62256881
11 changed files with 875 additions and 120 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `overall_rating` ADD `temporal_value` real DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,610 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c49d536a-0a0c-4c62-aed0-7b4fcb36d414",
"prevId": "662a64ad-8681-48f3-93fe-e0e07aaa5cb6",
"tables": {
"audio_file": {
"name": "audio_file",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"s3_key": {
"name": "s3_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"invite_link": {
"name": "invite_link",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"participant_name": {
"name": "participant_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used_at": {
"name": "used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"overall_rating": {
"name": "overall_rating",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"temporal_value": {
"name": "temporal_value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"overall_rating_participant_id_participant_id_fk": {
"name": "overall_rating_participant_id_participant_id_fk",
"tableFrom": "overall_rating",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"overall_rating_audio_file_id_audio_file_id_fk": {
"name": "overall_rating_audio_file_id_audio_file_id_fk",
"tableFrom": "overall_rating",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participant": {
"name": "participant",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"invite_token": {
"name": "invite_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"participant_invite_token_invite_link_token_fk": {
"name": "participant_invite_token_invite_link_token_fk",
"tableFrom": "participant",
"tableTo": "invite_link",
"columnsFrom": [
"invite_token"
],
"columnsTo": [
"token"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participant_progress": {
"name": "participant_progress",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"last_position": {
"name": "last_position",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"max_reached_time": {
"name": "max_reached_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"participant_progress_participant_id_participant_id_fk": {
"name": "participant_progress_participant_id_participant_id_fk",
"tableFrom": "participant_progress",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"participant_progress_audio_file_id_audio_file_id_fk": {
"name": "participant_progress_audio_file_id_audio_file_id_fk",
"tableFrom": "participant_progress",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"rating": {
"name": "rating",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"timeseries_data": {
"name": "timeseries_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"rating_participant_id_participant_id_fk": {
"name": "rating_participant_id_participant_id_fk",
"tableFrom": "rating",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"rating_audio_file_id_audio_file_id_fk": {
"name": "rating_audio_file_id_audio_file_id_fk",
"tableFrom": "rating",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -71,6 +71,13 @@
"when": 1762809580759, "when": 1762809580759,
"tag": "0009_admin_tags", "tag": "0009_admin_tags",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762812000000,
"tag": "0010_dual_overall_questions",
"breakpoints": true
} }
] ]
} }

View File

@@ -8,6 +8,7 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let temporalRating = 50;
let overallRating = 50; let overallRating = 50;
function handleClose() { function handleClose() {
@@ -16,7 +17,7 @@
} }
function handleSubmit() { function handleSubmit() {
dispatch('submit', { overallRating }); dispatch('submit', { overallRating, temporalRating });
} }
function handleRatingChange() { function handleRatingChange() {
@@ -35,12 +36,31 @@
</p> </p>
</div> </div>
<div class="space-y-4"> <div class="space-y-6">
<h4 class="text-center text-lg font-medium text-gray-900"> <div class="space-y-4">
How good was this performance overall? <h4 class="text-center text-lg font-medium text-gray-900">
</h4> How well do you think the two performers instantiated the temporal relationship prescribed in the score?
</h4>
<input
type="range"
min="0"
max="100"
bind:value={temporalRating}
on:input={handleRatingChange}
disabled={isSubmitting}
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 {isSubmitting ? 'opacity-50' : ''}"
/>
<div class="flex justify-between text-sm text-gray-600">
<span>Not well (0)</span>
<span class="font-semibold text-indigo-600">Rating: {temporalRating}</span>
<span>Perfectly (100)</span>
</div>
</div>
<div class="space-y-4"> <div class="space-y-4">
<h4 class="text-center text-lg font-medium text-gray-900">
How good was this performance overall?
</h4>
<input <input
type="range" type="range"
min="0" min="0"

View File

@@ -88,6 +88,7 @@ export const overallRating = sqliteTable('overall_rating', {
audioFileId: text('audio_file_id') audioFileId: text('audio_file_id')
.notNull() .notNull()
.references(() => audioFile.id), .references(() => audioFile.id),
temporalValue: real('temporal_value').notNull().default(0),
value: real('value').notNull(), // 0-100 rating value value: real('value').notNull(), // 0-100 rating value
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),

View File

@@ -62,7 +62,14 @@ export async function load() {
// Get overall ratings // Get overall ratings
const overallRatings = await db const overallRatings = await db
.select({ .select({
overallRating, overallRating: {
id: overallRating.id,
participantId: overallRating.participantId,
audioFileId: overallRating.audioFileId,
value: overallRating.value,
temporalValue: overallRating.temporalValue,
createdAt: overallRating.createdAt
},
participant, participant,
audioFile: { audioFile: {
id: audioFile.id, id: audioFile.id,
@@ -158,31 +165,45 @@ export async function load() {
overallRatingsMap[audioId][participantId] = { overallRatingsMap[audioId][participantId] = {
value: entry.overallRating.value, value: entry.overallRating.value,
temporalValue: entry.overallRating.temporalValue,
createdAt: entry.overallRating.createdAt, createdAt: entry.overallRating.createdAt,
participantName participantName
}; };
// Ensure the participant exists in timeseriesData // Ensure the participant exists in timeseriesData
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) { if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
timeseriesData[audioId].participants[participantId].overallRating = entry.overallRating.value; timeseriesData[audioId].participants[participantId].overallResponses = {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
};
timeseriesData[audioId].participants[participantId].isCompleted = true;
} else if (timeseriesData[audioId]) { } else if (timeseriesData[audioId]) {
// Participant has overall rating but no timeseries data yet // Participant has overall rating but no timeseries data yet
timeseriesData[audioId].participants[participantId] = { timeseriesData[audioId].participants[participantId] = {
name: participantName, name: participantName,
ratings: [], ratings: [],
isCompleted: false, isCompleted: true,
overallRating: entry.overallRating.value overallResponses: {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
}
}; };
} else { } else {
// Neither audio nor participant exists in timeseriesData yet // Neither audio nor participant exists in timeseriesData yet
timeseriesData[audioId] = { timeseriesData[audioId] = {
audioFile: entry.audioFile, audioFile: entry.audioFile,
participants: { participants: {
[participantId]: { [participantId]: {
name: participantName, name: participantName,
ratings: [], ratings: [],
isCompleted: false, isCompleted: true,
overallRating: entry.overallRating.value overallResponses: {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
}
} }
} }
}; };

View File

@@ -181,7 +181,7 @@
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
> >
Overall Rating Post-Listening Ratings
</th> </th>
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
@@ -203,8 +203,10 @@
{@const minTimestamp = participantData.ratings.length > 0 ? Math.min(...participantData.ratings.map(r => r.timestamp)) : 0} {@const minTimestamp = participantData.ratings.length > 0 ? Math.min(...participantData.ratings.map(r => r.timestamp)) : 0}
{@const maxTimestamp = participantData.ratings.length > 0 ? Math.max(...participantData.ratings.map(r => r.timestamp)) : 0} {@const maxTimestamp = participantData.ratings.length > 0 ? Math.max(...participantData.ratings.map(r => r.timestamp)) : 0}
{@const durationCovered = maxTimestamp - minTimestamp} {@const durationCovered = maxTimestamp - minTimestamp}
{@const lastUpdated = participantData.ratings.length > 0 ? {@const lastTimeseriesUpdate = participantData.ratings.length > 0 ?
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null} new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null}
{@const overallResponses = participantData.overallResponses}
{@const lastUpdated = lastTimeseriesUpdate ?? (overallResponses ? new Date(overallResponses.createdAt) : null)}
<tr> <tr>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900"> <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
{participantData.name} {participantData.name}
@@ -238,10 +240,11 @@
{/if} {/if}
</td> </td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500"> <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{#if participantData.overallRating !== undefined} {#if overallResponses}
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"> <div class="flex flex-col text-xs text-gray-700">
{participantData.overallRating}/100 <span class="font-medium text-gray-900">Temporal: <span class="text-blue-700">{overallResponses.temporal ?? '-'} / 100</span></span>
</span> <span class="font-medium text-gray-900">Overall: <span class="text-indigo-700">{overallResponses.overall ?? '-'} / 100</span></span>
</div>
{:else} {:else}
<span class="text-gray-400">-</span> <span class="text-gray-400">-</span>
{/if} {/if}

View File

@@ -1,8 +1,9 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { inviteLink, participant, audioFile, rating } from '$lib/server/db/schema.js'; import { inviteLink, participant, audioFile, rating, overallRating } from '$lib/server/db/schema.js';
import { eq, isNull, and, inArray } from 'drizzle-orm'; import { eq, isNull, and, inArray } from 'drizzle-orm';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js'; import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
import { env } from '$env/dynamic/private';
export async function load({ url, cookies }) { export async function load({ url, cookies }) {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
@@ -97,31 +98,47 @@ export async function load({ url, cookies }) {
const allowedAudioIds = filteredAudio.map((file) => file.id); const allowedAudioIds = filteredAudio.map((file) => file.id);
// Get completed ratings for this participant (only active, non-deleted ratings for active audio files) const displayContinuousRating = env.DISPLAY_CONT_RATING !== 'false';
let completedRatings = []; let completedRatings = [];
if (allowedAudioIds.length > 0) { if (allowedAudioIds.length > 0) {
completedRatings = await db if (displayContinuousRating) {
.select({ completedRatings = await db
audioFileId: rating.audioFileId .select({
}) audioFileId: rating.audioFileId
.from(rating) })
.innerJoin(audioFile, and( .from(rating)
eq(rating.audioFileId, audioFile.id), .innerJoin(audioFile, and(
isNull(audioFile.deletedAt) // Only count ratings for active audio files eq(rating.audioFileId, audioFile.id),
)) isNull(audioFile.deletedAt)
.where( ))
and( .where(
eq(rating.participantId, participantId), and(
eq(rating.isCompleted, true), eq(rating.participantId, participantId),
isNull(rating.deletedAt), // Only count active ratings eq(rating.isCompleted, true),
inArray(rating.audioFileId, allowedAudioIds) isNull(rating.deletedAt),
) inArray(rating.audioFileId, allowedAudioIds)
); )
);
} else {
completedRatings = await db
.select({ audioFileId: overallRating.audioFileId })
.from(overallRating)
.innerJoin(audioFile, and(
eq(overallRating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt)
))
.where(
and(
eq(overallRating.participantId, participantId),
isNull(overallRating.deletedAt),
inArray(overallRating.audioFileId, allowedAudioIds)
)
);
}
} }
const completedAudioIds = new Set(completedRatings.map(r => r.audioFileId)); const completedAudioIds = new Set(completedRatings.map((r) => r.audioFileId));
// Add completion status to audio files
const audioFilesWithStatus = filteredAudio.map(file => ({ const audioFilesWithStatus = filteredAudio.map(file => ({
...file, ...file,
isCompleted: completedAudioIds.has(file.id) isCompleted: completedAudioIds.has(file.id)
@@ -133,6 +150,7 @@ export async function load({ url, cookies }) {
audioFiles: audioFilesWithStatus, audioFiles: audioFilesWithStatus,
token, token,
completedCount: completedRatings.length, completedCount: completedRatings.length,
totalCount: filteredAudio.length totalCount: filteredAudio.length,
displayContinuousRating
}; };
} }

View File

@@ -1,6 +1,8 @@
<script> <script>
export let data; export let data;
const showContinuousRating = data?.displayContinuousRating ?? true;
</script> </script>
<div class="min-h-screen bg-gray-50 py-8"> <div class="min-h-screen bg-gray-50 py-8">
@@ -11,8 +13,12 @@
<p class="text-lg text-gray-600">Hello, {data.invite.participantName}!</p> <p class="text-lg text-gray-600">Hello, {data.invite.participantName}!</p>
{/if} {/if}
<p class="mt-4 text-gray-600"> <p class="mt-4 text-gray-600">
Thank you for participating in our study. You will listen to audio files and rate them using Thank you for participating in our study. You will listen to audio files
a slider. Click "Submit Rating" when you're finished rating each file. {#if showContinuousRating}
and rate them using a slider. Click "Submit Rating" when you're finished rating each file.
{:else}
and rate them.
{/if}
</p> </p>
</div> </div>
@@ -50,8 +56,11 @@
<h2 class="mb-4 text-xl font-semibold text-gray-900">Instructions</h2> <h2 class="mb-4 text-xl font-semibold text-gray-900">Instructions</h2>
<ul class="list-inside list-disc space-y-2 text-gray-700"> <ul class="list-inside list-disc space-y-2 text-gray-700">
<li>Click on any audio file below to start listening and rating</li> <li>Click on any audio file below to start listening and rating</li>
<li>Use the slider to rate the audio while listening (move it as your opinion changes)</li> {#if showContinuousRating}
<li>Click "Submit Rating" when you're finished to save your rating</li> <li>Use the slider to rate the audio while listening (move it as your opinion changes)</li>
<li>Click "Submit Rating" when you're finished to save your rating</li>
{:else}
{/if}
<li>If you've already rated a file, click "View" to see it again or submit a new rating to replace the old one</li> <li>If you've already rated a file, click "View" to see it again or submit a new rating to replace the old one</li>
<li>You can come back later to rate remaining files</li> <li>You can come back later to rate remaining files</li>
</ul> </ul>

View File

@@ -3,6 +3,7 @@ import { db } from '$lib/server/db/index.js';
import { audioFile, rating, participantProgress, participant, overallRating, inviteLink } from '$lib/server/db/schema.js'; import { audioFile, rating, participantProgress, participant, overallRating, inviteLink } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull } from 'drizzle-orm';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js'; import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
import { env } from '$env/dynamic/private';
async function ensureParticipantAccess(participantId, audioFileId) { async function ensureParticipantAccess(participantId, audioFileId) {
const participantRecords = await db const participantRecords = await db
@@ -73,6 +74,8 @@ export async function load({ params, url, cookies }) {
redirect(302, `/participate?token=${token}`); redirect(302, `/participate?token=${token}`);
} }
const displayContinuousRating = env.DISPLAY_CONT_RATING !== 'false';
const inviteRecords = await db const inviteRecords = await db
.select({ tags: inviteLink.tags }) .select({ tags: inviteLink.tags })
.from(inviteLink) .from(inviteLink)
@@ -157,6 +160,15 @@ export async function load({ params, url, cookies }) {
isNull(rating.deletedAt) isNull(rating.deletedAt)
)); ));
const existingOverallRatings = await db
.select()
.from(overallRating)
.where(and(
eq(overallRating.participantId, participantId),
eq(overallRating.audioFileId, audioId),
isNull(overallRating.deletedAt)
));
return { return {
audioFile: { audioFile: {
id: audioRecord.id, id: audioRecord.id,
@@ -169,7 +181,9 @@ export async function load({ params, url, cookies }) {
participantId, participantId,
token, token,
progress, progress,
existingRatings existingRatings,
existingOverallRatings,
displayContinuousRating
}; };
} }
@@ -357,8 +371,9 @@ export const actions = {
const participantId = data.get('participantId'); const participantId = data.get('participantId');
const audioFileId = data.get('audioFileId'); const audioFileId = data.get('audioFileId');
const overallRatingValue = parseFloat(data.get('overallRating')); const overallRatingValue = parseFloat(data.get('overallRating'));
const temporalRatingValue = parseFloat(data.get('temporalRating'));
if (!participantId || !audioFileId || isNaN(overallRatingValue)) { if (!participantId || !audioFileId || isNaN(overallRatingValue) || isNaN(temporalRatingValue)) {
return { error: 'Invalid overall rating data' }; return { error: 'Invalid overall rating data' };
} }
@@ -384,6 +399,7 @@ export const actions = {
id: overallRatingId, id: overallRatingId,
participantId, participantId,
audioFileId, audioFileId,
temporalValue: temporalRatingValue,
value: overallRatingValue, value: overallRatingValue,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -1,10 +1,13 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import OverallRatingModal from '$lib/components/OverallRatingModal.svelte'; import OverallRatingModal from '$lib/components/OverallRatingModal.svelte';
export let data; export let data;
let audioApiUrl = ''; let audioApiUrl = '';
$: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`; $: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
const showContinuousRating = data?.displayContinuousRating ?? true;
const existingOverallRatings = data?.existingOverallRatings ?? [];
let hasSubmittedOverallRating = existingOverallRatings.length > 0;
let audio; let audio;
let isPlaying = false; let isPlaying = false;
@@ -235,6 +238,9 @@ $: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
hasListenedToEnd = true; hasListenedToEnd = true;
isAudioCompleted = true; isAudioCompleted = true;
maxReachedTime = duration; maxReachedTime = duration;
if (!showContinuousRating && !hasSubmittedOverallRating) {
showOverallRatingModal = true;
}
} }
function handleRatingChange() { function handleRatingChange() {
@@ -290,13 +296,14 @@ $: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
} }
async function handleOverallRatingSubmit(event) { async function handleOverallRatingSubmit(event) {
const { overallRating } = event.detail; const { overallRating, temporalRating } = event.detail;
isSubmittingOverallRating = true; isSubmittingOverallRating = true;
const formData = new FormData(); const formData = new FormData();
formData.append('participantId', data.participantId); formData.append('participantId', data.participantId);
formData.append('audioFileId', data.audioFile.id); formData.append('audioFileId', data.audioFile.id);
formData.append('overallRating', overallRating.toString()); formData.append('overallRating', overallRating.toString());
formData.append('temporalRating', temporalRating.toString());
try { try {
const response = await fetch('?/saveOverallRating', { const response = await fetch('?/saveOverallRating', {
@@ -317,9 +324,15 @@ $: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
alert('Error saving overall rating. Please try again.'); alert('Error saving overall rating. Please try again.');
} finally { } finally {
isSubmittingOverallRating = false; isSubmittingOverallRating = false;
hasSubmittedOverallRating = true;
} }
} }
function openOverallRatingModal() {
if (!isAudioCompleted || isSubmittingOverallRating) return;
showOverallRatingModal = true;
}
function handleOverallRatingClose() { function handleOverallRatingClose() {
if (isSubmittingOverallRating) return; // Prevent closing while submitting if (isSubmittingOverallRating) return; // Prevent closing while submitting
showOverallRatingModal = false; showOverallRatingModal = false;
@@ -364,9 +377,13 @@ $: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
<div class="rounded-lg bg-white p-8 shadow"> <div class="rounded-lg bg-white p-8 shadow">
<h1 class="mb-2 text-2xl font-bold text-gray-900">{data.audioFile.filename}</h1> <h1 class="mb-2 text-2xl font-bold text-gray-900">{data.audioFile.filename}</h1>
<p class="mb-8 text-gray-600"> <p class="mb-8 text-gray-600">
Listen to the audio and move the slider to rate it continuously {#if showContinuousRating}
</p> Listen to the audio and move the slider to rate it continuously
{:else}
Listen closely to the entire audio clip. Continuous slider ratings are disabled; you'll answer two quick questions after listening.
{/if}
</p>
<!-- Audio Player --> <!-- Audio Player -->
<div class="mb-8"> <div class="mb-8">
@@ -478,80 +495,112 @@ $: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
</div> </div>
<!-- Rating Slider --> <!-- Rating Slider -->
<div class="space-y-6"> {#if showContinuousRating}
{#if data.existingRatings && data.existingRatings.length > 0} <div class="space-y-6">
<div class="rounded-md border border-amber-200 bg-amber-50 p-4"> {#if data.existingRatings && data.existingRatings.length > 0}
<p class="font-medium text-amber-800">⚠️ You have already submitted a rating for this audio file</p> <div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<p class="text-sm text-amber-700 mt-1"> <p class="font-medium text-amber-800">⚠️ You have already submitted a rating for this audio file</p>
To redo your rating: listen to the full audio clip again, adjust the slider as needed, and click "Update Rating". <p class="text-sm text-amber-700 mt-1">
This will replace your previous rating completely. To redo your rating: listen to the full audio clip again, adjust the slider as needed, and click "Update Rating".
</p> This will replace your previous rating completely.
</div> </p>
{/if} </div>
{/if}
<div> <div>
<h3 class="mb-4 text-lg font-medium text-gray-900">How well do you think the performers manage to follow the temporal coordination requirements?</h3> <h3 class="mb-4 text-lg font-medium text-gray-900">How well do you think the performers manage to follow the temporal coordination requirements?</h3>
<p class="mb-4 text-sm text-gray-600"> <p class="mb-4 text-sm text-gray-600">
Move the slider as you listen to rate the audio continuously. You must listen to the full audio clip before you can submit your rating. Move the slider as you listen to rate the audio continuously. You must listen to the full audio clip before you can submit your rating.
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<input <input
type="range" type="range"
min="0" min="0"
max="100" max="100"
bind:value={ratingValue} bind:value={ratingValue}
on:input={handleRatingChange} on:input={handleRatingChange}
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200" class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200"
/> />
<div class="flex justify-between text-sm text-gray-600"> <div class="flex justify-between text-sm text-gray-600">
<span>Not at all (0)</span> <span>Not at all (0)</span>
<span class="font-semibold">Current Rating: {ratingValue}</span> <span class="font-semibold">Current Rating: {ratingValue}</span>
<span>Perfectly (100)</span> <span>Perfectly (100)</span>
</div>
</div> </div>
</div> </div>
</div>
<!-- Save Rating Button --> <!-- Save Rating Button -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<button <button
on:click={saveRating} on:click={saveRating}
disabled={!hasRatingChanged || isSavingRating || !isAudioCompleted} disabled={!hasRatingChanged || isSavingRating || !isAudioCompleted}
class="flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white {(!hasRatingChanged || isSavingRating || !isAudioCompleted) ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'} transition-colors" class="flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white {(!hasRatingChanged || isSavingRating || !isAudioCompleted) ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'} transition-colors"
> >
{#if isSavingRating} {#if isSavingRating}
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div> <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Saving... Saving...
{:else}
{#if data.existingRatings && data.existingRatings.length > 0}
Update Rating
{:else} {:else}
Submit Rating {#if data.existingRatings && data.existingRatings.length > 0}
Update Rating
{:else}
Submit Rating
{/if}
{/if} {/if}
{/if} </button>
</button>
<div class="flex flex-col items-end space-y-1"> <div class="flex flex-col items-end space-y-1">
{#if hasRatingChanged} {#if hasRatingChanged}
<p class="text-sm text-amber-600"> <p class="text-sm text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i> <i class="fas fa-exclamation-triangle mr-1"></i>
You have unsaved changes You have unsaved changes
</p> </p>
{/if} {/if}
{#if !isAudioCompleted} {#if !isAudioCompleted}
<p class="text-sm text-red-600"> <p class="text-sm text-red-600">
<i class="fas fa-play-circle mr-1"></i> <i class="fas fa-play-circle mr-1"></i>
Must listen to full audio clip to submit Must listen to full audio clip to submit
</p> </p>
{/if} {/if}
</div>
</div> </div>
{#if data.progress?.isCompleted}
<div class="rounded-md border border-blue-200 bg-blue-50 p-4">
<p class="font-medium text-blue-800">✓ You have finished listening to this audio file</p>
</div>
{/if}
</div> </div>
{:else}
{#if data.progress?.isCompleted} <div class="mt-10 rounded-lg border border-blue-200 bg-blue-50 p-6 space-y-4">
<div class="rounded-md border border-blue-200 bg-blue-50 p-4"> {#if existingOverallRatings.length > 0 || hasSubmittedOverallRating}
<p class="font-medium text-blue-800">✓ You have finished listening to this audio file</p> <p class="text-sm text-blue-800 font-medium">
</div> You have already answered the required questions for this clip.
{/if} </p>
</div> <a
href="/participate?token={data.token}"
class="inline-flex items-center justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Return to Dashboard
</a>
{:else}
<p class="text-sm text-blue-800 font-medium">
After fully listening to the clip, please answer two quick questions.
</p>
<button
on:click={openOverallRatingModal}
disabled={!isAudioCompleted || isSubmittingOverallRating}
class="inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium text-white {(!isAudioCompleted || isSubmittingOverallRating) ? 'bg-gray-500 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'}"
>
Answer Questions
</button>
{#if !isAudioCompleted}
<p class="text-xs text-blue-700">
Listen to the entire clip to enable the questions.
</p>
{/if}
{/if}
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>