added overall rating
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() : '-'
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user