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 @@