From 4ea6ee17bb249fb4b14a65c3f067e159403870ba Mon Sep 17 00:00:00 2001 From: Shaheed Azaad Date: Thu, 24 Jul 2025 22:38:28 +0200 Subject: [PATCH] added overall rating --- src/lib/server/db/schema.js | 16 +++- src/routes/admin/ratings/+page.server.js | 73 ++++++++++++++++++- src/routes/admin/ratings/+page.svelte | 16 +++- src/routes/api/export/timeseries/+server.js | 58 ++++++++++++++- .../participate/audio/[id]/+page.server.js | 48 +++++++++++- .../participate/audio/[id]/+page.svelte | 60 +++++++++++++-- 6 files changed, 260 insertions(+), 11 deletions(-) diff --git a/src/lib/server/db/schema.js b/src/lib/server/db/schema.js index e0d8670..1ee22ad 100644 --- a/src/lib/server/db/schema.js +++ b/src/lib/server/db/schema.js @@ -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 +}); diff --git a/src/routes/admin/ratings/+page.server.js b/src/routes/admin/ratings/+page.server.js index b38df9c..5668bda 100644 --- a/src/routes/admin/ratings/+page.server.js +++ b/src/routes/admin/ratings/+page.server.js @@ -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 }; } diff --git a/src/routes/admin/ratings/+page.svelte b/src/routes/admin/ratings/+page.svelte index 80b447f..7315da7 100644 --- a/src/routes/admin/ratings/+page.svelte +++ b/src/routes/admin/ratings/+page.svelte @@ -178,6 +178,11 @@ > Duration Covered + + Overall Rating + @@ -232,6 +237,15 @@ - {/if} + + {#if participantData.overallRating !== undefined} + + {participantData.overallRating}/100 + + {:else} + - + {/if} + {#if lastUpdated} {lastUpdated.toLocaleString()} @@ -255,7 +269,7 @@ {/each} {:else} - + No ratings collected yet. diff --git a/src/routes/api/export/timeseries/+server.js b/src/routes/api/export/timeseries/+server.js index 1c5e0d0..05c8958 100644 --- a/src/routes/api/export/timeseries/+server.js +++ b/src/routes/api/export/timeseries/+server.js @@ -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() : '-' ]); diff --git a/src/routes/participate/audio/[id]/+page.server.js b/src/routes/participate/audio/[id]/+page.server.js index 838322e..8ce17f3 100644 --- a/src/routes/participate/audio/[id]/+page.server.js +++ b/src/routes/participate/audio/[id]/+page.server.js @@ -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' }; + } } }; diff --git a/src/routes/participate/audio/[id]/+page.svelte b/src/routes/participate/audio/[id]/+page.svelte index e655dd8..3675064 100644 --- a/src/routes/participate/audio/[id]/+page.svelte +++ b/src/routes/participate/audio/[id]/+page.svelte @@ -1,5 +1,6 @@