added overall rating

This commit is contained in:
2025-07-24 22:38:28 +02:00
parent b084910dc0
commit 4ea6ee17bb
6 changed files with 260 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import { sqliteTable, integer, text, blob, real, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { sqliteTable, integer, text, blob, real } from 'drizzle-orm/sqlite-core';
import { isNull } from 'drizzle-orm'; import { isNull } from 'drizzle-orm';
export const user = sqliteTable('user', { export const user = sqliteTable('user', {
@@ -77,3 +77,17 @@ export const participantProgress = sqliteTable('participant_progress', {
maxReachedTime: real('max_reached_time').default(0), maxReachedTime: real('max_reached_time').default(0),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull() updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
}); });
export const overallRating = sqliteTable('overall_rating', {
id: text('id').primaryKey(),
participantId: text('participant_id')
.notNull()
.references(() => participant.id),
audioFileId: text('audio_file_id')
.notNull()
.references(() => audioFile.id),
value: real('value').notNull(), // 0-100 rating value
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
deletedAt: integer('deleted_at', { mode: 'timestamp' }) // Soft delete for potential redos
});

View File

@@ -1,5 +1,5 @@
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { rating, participant, audioFile, inviteLink } from '$lib/server/db/schema.js'; import { rating, participant, audioFile, inviteLink, overallRating } from '$lib/server/db/schema.js';
import { eq, isNull, and, desc } from 'drizzle-orm'; import { eq, isNull, and, desc } from 'drizzle-orm';
export async function load() { export async function load() {
@@ -42,6 +42,30 @@ export async function load() {
isNull(rating.deletedAt) // Only active ratings isNull(rating.deletedAt) // Only active ratings
)); ));
// Get overall ratings
const overallRatings = await db
.select({
overallRating,
participant,
audioFile,
inviteLink
})
.from(overallRating)
.innerJoin(participant, and(
eq(overallRating.participantId, participant.id),
isNull(participant.deletedAt)
))
.innerJoin(audioFile, and(
eq(overallRating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt)
))
.innerJoin(inviteLink, and(
eq(participant.inviteToken, inviteLink.token),
isNull(inviteLink.deletedAt)
))
.where(isNull(overallRating.deletedAt))
.orderBy(desc(overallRating.createdAt));
// Group ratings by audio file and participant for individual timeseries visualization // Group ratings by audio file and participant for individual timeseries visualization
const timeseriesData = {}; const timeseriesData = {};
ratings.forEach(entry => { ratings.forEach(entry => {
@@ -98,9 +122,54 @@ export async function load() {
}); });
}); });
// Add overall ratings to the data structure
const overallRatingsMap = {};
overallRatings.forEach(entry => {
const audioId = entry.audioFile.id;
const participantId = entry.participant.id;
const participantName = entry.inviteLink.participantName || 'Unnamed';
if (!overallRatingsMap[audioId]) {
overallRatingsMap[audioId] = {};
}
overallRatingsMap[audioId][participantId] = {
value: entry.overallRating.value,
createdAt: entry.overallRating.createdAt,
participantName
};
// Ensure the participant exists in timeseriesData
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
timeseriesData[audioId].participants[participantId].overallRating = entry.overallRating.value;
} else if (timeseriesData[audioId]) {
// Participant has overall rating but no timeseries data yet
timeseriesData[audioId].participants[participantId] = {
name: participantName,
ratings: [],
isCompleted: false,
overallRating: entry.overallRating.value
};
} else {
// Neither audio nor participant exists in timeseriesData yet
timeseriesData[audioId] = {
audioFile: entry.audioFile,
participants: {
[participantId]: {
name: participantName,
ratings: [],
isCompleted: false,
overallRating: entry.overallRating.value
}
}
};
}
});
return { return {
ratings, ratings,
totalRatings: ratingStats.length, totalRatings: ratingStats.length,
timeseriesData timeseriesData,
overallRatingsMap
}; };
} }

View File

@@ -178,6 +178,11 @@
> >
Duration Covered Duration Covered
</th> </th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Overall Rating
</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"
> >
@@ -232,6 +237,15 @@
<span class="text-gray-400">-</span> <span class="text-gray-400">-</span>
{/if} {/if}
</td> </td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{#if participantData.overallRating !== undefined}
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{participantData.overallRating}/100
</span>
{:else}
<span class="text-gray-400">-</span>
{/if}
</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 lastUpdated} {#if lastUpdated}
{lastUpdated.toLocaleString()} {lastUpdated.toLocaleString()}
@@ -255,7 +269,7 @@
{/each} {/each}
{:else} {:else}
<tr> <tr>
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500"> <td colspan="8" class="px-6 py-4 text-center text-sm text-gray-500">
No ratings collected yet. No ratings collected yet.
</td> </td>
</tr> </tr>

View File

@@ -1,5 +1,5 @@
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { rating, participant, audioFile, inviteLink } from '$lib/server/db/schema.js'; import { rating, participant, audioFile, inviteLink, overallRating } from '$lib/server/db/schema.js';
import { eq, isNull, and, desc } from 'drizzle-orm'; import { eq, isNull, and, desc } from 'drizzle-orm';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
@@ -24,6 +24,27 @@ export async function GET() {
)) ))
.orderBy(desc(rating.createdAt)); .orderBy(desc(rating.createdAt));
// Get overall ratings
const overallRatings = await db
.select({
overallRating,
participant,
audioFile,
inviteLink
})
.from(overallRating)
.innerJoin(participant, and(
eq(overallRating.participantId, participant.id),
isNull(participant.deletedAt)
))
.innerJoin(audioFile, eq(overallRating.audioFileId, audioFile.id))
.innerJoin(inviteLink, and(
eq(participant.inviteToken, inviteLink.token),
isNull(inviteLink.deletedAt)
))
.where(isNull(overallRating.deletedAt))
.orderBy(desc(overallRating.createdAt));
// Group ratings by audio file and participant // Group ratings by audio file and participant
const timeseriesData = {}; const timeseriesData = {};
ratings.forEach(entry => { ratings.forEach(entry => {
@@ -65,6 +86,39 @@ export async function GET() {
}); });
}); });
// Add overall ratings to the data structure
overallRatings.forEach(entry => {
const audioId = entry.audioFile.id;
const participantId = entry.participant.id;
const participantName = entry.inviteLink.participantName || 'Unnamed';
// Ensure the participant exists in timeseriesData
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
timeseriesData[audioId].participants[participantId].overallRating = entry.overallRating.value;
} else if (timeseriesData[audioId]) {
// Participant has overall rating but no timeseries data yet
timeseriesData[audioId].participants[participantId] = {
name: participantName,
ratings: [],
isCompleted: false,
overallRating: entry.overallRating.value
};
} else {
// Neither audio nor participant exists in timeseriesData yet
timeseriesData[audioId] = {
audioFile: entry.audioFile,
participants: {
[participantId]: {
name: participantName,
ratings: [],
isCompleted: false,
overallRating: entry.overallRating.value
}
}
};
}
});
// Create Excel workbook // Create Excel workbook
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Timeseries Data'); const worksheet = workbook.addWorksheet('Timeseries Data');
@@ -76,6 +130,7 @@ export async function GET() {
'Status', 'Status',
'Data Points Count', 'Data Points Count',
'Duration Covered (seconds)', 'Duration Covered (seconds)',
'Overall Rating (0-100)',
'Timeseries Data (timestamp:value pairs)', 'Timeseries Data (timestamp:value pairs)',
'Last Updated' 'Last Updated'
]); ]);
@@ -112,6 +167,7 @@ export async function GET() {
participantData.isCompleted ? 'Completed' : 'In Progress', participantData.isCompleted ? 'Completed' : 'In Progress',
participantData.ratings.length, participantData.ratings.length,
durationCovered.toFixed(2), durationCovered.toFixed(2),
participantData.overallRating !== undefined ? participantData.overallRating : '-',
timeseriesString, timeseriesString,
lastUpdated ? lastUpdated.toLocaleString() : '-' lastUpdated ? lastUpdated.toLocaleString() : '-'
]); ]);

View File

@@ -1,6 +1,6 @@
import { redirect, error } from '@sveltejs/kit'; import { redirect, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { audioFile, rating, participantProgress, participant } from '$lib/server/db/schema.js'; import { audioFile, rating, participantProgress, participant, overallRating } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull } from 'drizzle-orm';
export async function load({ params, url, cookies }) { export async function load({ params, url, cookies }) {
@@ -252,5 +252,51 @@ export const actions = {
console.error('Error updating progress:', error); console.error('Error updating progress:', error);
return { error: 'Failed to update progress' }; return { error: 'Failed to update progress' };
} }
},
saveOverallRating: async ({ request }) => {
const data = await request.formData();
const participantId = data.get('participantId');
const audioFileId = data.get('audioFileId');
const overallRatingValue = parseFloat(data.get('overallRating'));
if (!participantId || !audioFileId || isNaN(overallRatingValue)) {
return { error: 'Invalid overall rating data' };
}
try {
const overallRatingId = `${participantId}-${audioFileId}-overall-${Date.now()}`;
const now = new Date();
// Use a transaction to ensure atomicity (same pattern as saveRating)
await db.transaction(async (tx) => {
// Soft delete any existing overall ratings for this participant and audio file (for redo functionality)
await tx.update(overallRating)
.set({ deletedAt: now })
.where(
and(
eq(overallRating.participantId, participantId),
eq(overallRating.audioFileId, audioFileId),
isNull(overallRating.deletedAt)
)
);
// Always insert new overall rating record
await tx.insert(overallRating).values({
id: overallRatingId,
participantId,
audioFileId,
value: overallRatingValue,
createdAt: now,
updatedAt: now,
deletedAt: null
});
});
return { success: true };
} catch (error) {
console.error('Error saving overall rating:', error);
return { error: 'Failed to save overall rating' };
}
} }
}; };

View File

@@ -1,5 +1,6 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import OverallRatingModal from '$lib/components/OverallRatingModal.svelte';
export let data; export let data;
@@ -20,6 +21,8 @@
let hasListenedToEnd = false; let hasListenedToEnd = false;
let maxReachedTime = 0; let maxReachedTime = 0;
let isAudioCompleted = false; let isAudioCompleted = false;
let showOverallRatingModal = false;
let isSubmittingOverallRating = false;
const PROGRESS_SAVE_INTERVAL = 2000; const PROGRESS_SAVE_INTERVAL = 2000;
@@ -270,8 +273,8 @@
maxReachedTime = 0; maxReachedTime = 0;
isAudioCompleted = false; isAudioCompleted = false;
// Redirect back to participant dashboard // Show overall rating modal instead of immediately redirecting
window.location.href = `/participate?token=${data.token}`; showOverallRatingModal = true;
} else { } else {
const result = await response.text(); const result = await response.text();
alert('Error saving rating: ' + result); alert('Error saving rating: ' + result);
@@ -284,6 +287,44 @@
} }
} }
async function handleOverallRatingSubmit(event) {
const { overallRating } = event.detail;
isSubmittingOverallRating = true;
const formData = new FormData();
formData.append('participantId', data.participantId);
formData.append('audioFileId', data.audioFile.id);
formData.append('overallRating', overallRating.toString());
try {
const response = await fetch('?/saveOverallRating', {
method: 'POST',
body: formData
});
if (response.ok) {
// Close modal and redirect to participant dashboard
showOverallRatingModal = false;
window.location.href = `/participate?token=${data.token}`;
} else {
const result = await response.text();
alert('Error saving overall rating: ' + result);
}
} catch (error) {
console.error('Error saving overall rating:', error);
alert('Error saving overall rating. Please try again.');
} finally {
isSubmittingOverallRating = false;
}
}
function handleOverallRatingClose() {
if (isSubmittingOverallRating) return; // Prevent closing while submitting
showOverallRatingModal = false;
// Redirect to participant dashboard even if modal is closed without submitting
window.location.href = `/participate?token=${data.token}`;
}
async function saveProgress(completed = false) { async function saveProgress(completed = false) {
const formData = new FormData(); const formData = new FormData();
formData.append('participantId', data.participantId); formData.append('participantId', data.participantId);
@@ -447,7 +488,7 @@
{/if} {/if}
<div> <div>
<h3 class="mb-4 text-lg font-medium text-gray-900">Rate the Audio (0-100)</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>
@@ -461,9 +502,9 @@
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>Poor (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>Excellent (100)</span> <span>Perfectly (100)</span>
</div> </div>
</div> </div>
</div> </div>
@@ -513,6 +554,15 @@
</div> </div>
</div> </div>
<!-- Overall Rating Modal -->
<OverallRatingModal
isOpen={showOverallRatingModal}
audioFilename={data.audioFile.filename}
isSubmitting={isSubmittingOverallRating}
on:submit={handleOverallRatingSubmit}
on:close={handleOverallRatingClose}
/>
<style> <style>
.slider::-webkit-slider-thumb { .slider::-webkit-slider-thumb {
appearance: none; appearance: none;