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';
export const user = sqliteTable('user', {
@@ -77,3 +77,17 @@ export const participantProgress = sqliteTable('participant_progress', {
maxReachedTime: real('max_reached_time').default(0),
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 { 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';
export async function load() {
@@ -42,6 +42,30 @@ export async function load() {
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
const timeseriesData = {};
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 {
ratings,
totalRatings: ratingStats.length,
timeseriesData
timeseriesData,
overallRatingsMap
};
}

View File

@@ -178,6 +178,11 @@
>
Duration Covered
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Overall Rating
</th>
<th
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>
{/if}
</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">
{#if lastUpdated}
{lastUpdated.toLocaleString()}
@@ -255,7 +269,7 @@
{/each}
{:else}
<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.
</td>
</tr>

View File

@@ -1,5 +1,5 @@
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 ExcelJS from 'exceljs';
@@ -24,6 +24,27 @@ export async function GET() {
))
.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
const timeseriesData = {};
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
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Timeseries Data');
@@ -76,6 +130,7 @@ export async function GET() {
'Status',
'Data Points Count',
'Duration Covered (seconds)',
'Overall Rating (0-100)',
'Timeseries Data (timestamp:value pairs)',
'Last Updated'
]);
@@ -112,6 +167,7 @@ export async function GET() {
participantData.isCompleted ? 'Completed' : 'In Progress',
participantData.ratings.length,
durationCovered.toFixed(2),
participantData.overallRating !== undefined ? participantData.overallRating : '-',
timeseriesString,
lastUpdated ? lastUpdated.toLocaleString() : '-'
]);

View File

@@ -1,6 +1,6 @@
import { redirect, error } from '@sveltejs/kit';
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';
export async function load({ params, url, cookies }) {
@@ -252,5 +252,51 @@ export const actions = {
console.error('Error updating progress:', error);
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>
import { onMount, onDestroy } from 'svelte';
import OverallRatingModal from '$lib/components/OverallRatingModal.svelte';
export let data;
@@ -20,6 +21,8 @@
let hasListenedToEnd = false;
let maxReachedTime = 0;
let isAudioCompleted = false;
let showOverallRatingModal = false;
let isSubmittingOverallRating = false;
const PROGRESS_SAVE_INTERVAL = 2000;
@@ -270,8 +273,8 @@
maxReachedTime = 0;
isAudioCompleted = false;
// Redirect back to participant dashboard
window.location.href = `/participate?token=${data.token}`;
// Show overall rating modal instead of immediately redirecting
showOverallRatingModal = true;
} else {
const result = await response.text();
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) {
const formData = new FormData();
formData.append('participantId', data.participantId);
@@ -447,7 +488,7 @@
{/if}
<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">
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>
@@ -461,9 +502,9 @@
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">
<span>Poor (0)</span>
<span>Not at all (0)</span>
<span class="font-semibold">Current Rating: {ratingValue}</span>
<span>Excellent (100)</span>
<span>Perfectly (100)</span>
</div>
</div>
</div>
@@ -513,6 +554,15 @@
</div>
</div>
<!-- Overall Rating Modal -->
<OverallRatingModal
isOpen={showOverallRatingModal}
audioFilename={data.audioFile.filename}
isSubmitting={isSubmittingOverallRating}
on:submit={handleOverallRatingSubmit}
on:close={handleOverallRatingClose}
/>
<style>
.slider::-webkit-slider-thumb {
appearance: none;