Compare commits

...

5 Commits

Author SHA1 Message Date
9cbef20644 added randomisation 2025-11-11 00:11:03 +01:00
ca5fedd65c updated demo based on musicians feedback 2025-11-10 23:56:55 +01:00
6f62256881 added post-listening rating 2025-11-10 23:40:41 +01:00
edd1d34900 fixed migrations 2025-11-10 22:51:01 +01:00
7a10cd7f5d pre-tags 2025-11-10 22:16:54 +01:00
28 changed files with 2182 additions and 196 deletions

View File

@@ -1,31 +0,0 @@
-- Redesign ratings table for single JSON timeseries entry with soft deletes
DROP TABLE IF EXISTS rating_new;
CREATE TABLE rating_new (
id TEXT PRIMARY KEY,
participant_id TEXT NOT NULL REFERENCES participant(id),
audio_file_id TEXT NOT NULL REFERENCES audio_file(id),
final_value REAL NOT NULL,
timeseries_data TEXT NOT NULL,
created_at INTEGER NOT NULL,
deleted_at INTEGER DEFAULT NULL
);
-- Copy existing data if any (transform to new format)
INSERT INTO rating_new (id, participant_id, audio_file_id, final_value, timeseries_data, created_at, deleted_at)
SELECT
id,
participant_id,
audio_file_id,
value as final_value,
'[{"timestamp":' || timestamp || ',"value":' || value || ',"time":' || (created_at * 1000) || '}]' as timeseries_data,
created_at,
CASE WHEN is_completed = 1 THEN NULL ELSE strftime('%s', 'now') * 1000 END as deleted_at
FROM rating
WHERE is_completed = 1; -- Only keep completed ratings, mark others as deleted
-- Drop old table and rename
DROP TABLE rating;
ALTER TABLE rating_new RENAME TO rating;
-- Create unique index for active ratings only
CREATE UNIQUE INDEX participant_audio_active ON rating (participant_id, audio_file_id) WHERE deleted_at IS NULL;

View File

@@ -0,0 +1,2 @@
-- Legacy placeholder migration retained for journal consistency.
SELECT 1;

View File

@@ -0,0 +1,2 @@
-- Legacy placeholder migration retained for journal consistency.
SELECT 1;

View File

@@ -0,0 +1,15 @@
CREATE TABLE `overall_rating` (
`id` text PRIMARY KEY NOT NULL,
`participant_id` text NOT NULL,
`audio_file_id` text NOT NULL,
`value` real NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`deleted_at` integer,
FOREIGN KEY (`participant_id`) REFERENCES `participant`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`audio_file_id`) REFERENCES `audio_file`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `audio_file` ADD `tags` text DEFAULT '[]' NOT NULL;
--> statement-breakpoint
ALTER TABLE `invite_link` ADD `tags` text DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `overall_rating` ADD `temporal_value` real DEFAULT 0 NOT NULL;

Binary file not shown.

View File

@@ -0,0 +1,602 @@
{
"version": "6",
"dialect": "sqlite",
"id": "662a64ad-8681-48f3-93fe-e0e07aaa5cb6",
"prevId": "90bc9682-1832-405b-9b16-394f8fa6eacb",
"tables": {
"audio_file": {
"name": "audio_file",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"s3_key": {
"name": "s3_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"invite_link": {
"name": "invite_link",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"participant_name": {
"name": "participant_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used_at": {
"name": "used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"overall_rating": {
"name": "overall_rating",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"overall_rating_participant_id_participant_id_fk": {
"name": "overall_rating_participant_id_participant_id_fk",
"tableFrom": "overall_rating",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"overall_rating_audio_file_id_audio_file_id_fk": {
"name": "overall_rating_audio_file_id_audio_file_id_fk",
"tableFrom": "overall_rating",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participant": {
"name": "participant",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"invite_token": {
"name": "invite_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"participant_invite_token_invite_link_token_fk": {
"name": "participant_invite_token_invite_link_token_fk",
"tableFrom": "participant",
"tableTo": "invite_link",
"columnsFrom": [
"invite_token"
],
"columnsTo": [
"token"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participant_progress": {
"name": "participant_progress",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"last_position": {
"name": "last_position",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"max_reached_time": {
"name": "max_reached_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"participant_progress_participant_id_participant_id_fk": {
"name": "participant_progress_participant_id_participant_id_fk",
"tableFrom": "participant_progress",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"participant_progress_audio_file_id_audio_file_id_fk": {
"name": "participant_progress_audio_file_id_audio_file_id_fk",
"tableFrom": "participant_progress",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"rating": {
"name": "rating",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"timeseries_data": {
"name": "timeseries_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"rating_participant_id_participant_id_fk": {
"name": "rating_participant_id_participant_id_fk",
"tableFrom": "rating",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"rating_audio_file_id_audio_file_id_fk": {
"name": "rating_audio_file_id_audio_file_id_fk",
"tableFrom": "rating",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,610 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c49d536a-0a0c-4c62-aed0-7b4fcb36d414",
"prevId": "662a64ad-8681-48f3-93fe-e0e07aaa5cb6",
"tables": {
"audio_file": {
"name": "audio_file",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"s3_key": {
"name": "s3_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"invite_link": {
"name": "invite_link",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"participant_name": {
"name": "participant_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_used": {
"name": "is_used",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used_at": {
"name": "used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
}
},
"indexes": {
"invite_link_token_unique": {
"name": "invite_link_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"overall_rating": {
"name": "overall_rating",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"temporal_value": {
"name": "temporal_value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"overall_rating_participant_id_participant_id_fk": {
"name": "overall_rating_participant_id_participant_id_fk",
"tableFrom": "overall_rating",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"overall_rating_audio_file_id_audio_file_id_fk": {
"name": "overall_rating_audio_file_id_audio_file_id_fk",
"tableFrom": "overall_rating",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participant": {
"name": "participant",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"invite_token": {
"name": "invite_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"participant_invite_token_invite_link_token_fk": {
"name": "participant_invite_token_invite_link_token_fk",
"tableFrom": "participant",
"tableTo": "invite_link",
"columnsFrom": [
"invite_token"
],
"columnsTo": [
"token"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participant_progress": {
"name": "participant_progress",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"last_position": {
"name": "last_position",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"max_reached_time": {
"name": "max_reached_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"participant_progress_participant_id_participant_id_fk": {
"name": "participant_progress_participant_id_participant_id_fk",
"tableFrom": "participant_progress",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"participant_progress_audio_file_id_audio_file_id_fk": {
"name": "participant_progress_audio_file_id_audio_file_id_fk",
"tableFrom": "participant_progress",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"rating": {
"name": "rating",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"audio_file_id": {
"name": "audio_file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"timeseries_data": {
"name": "timeseries_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"rating_participant_id_participant_id_fk": {
"name": "rating_participant_id_participant_id_fk",
"tableFrom": "rating",
"tableTo": "participant",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"rating_audio_file_id_audio_file_id_fk": {
"name": "rating_audio_file_id_audio_file_id_fk",
"tableFrom": "rating",
"tableTo": "audio_file",
"columnsFrom": [
"audio_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -64,6 +64,20 @@
"when": 1753195639650, "when": 1753195639650,
"tag": "0008_wild_texas_twister", "tag": "0008_wild_texas_twister",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1762809580759,
"tag": "0009_admin_tags",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762812000000,
"tag": "0010_dual_overall_questions",
"breakpoints": true
} }
] ]
} }

18
package-lock.json generated
View File

@@ -16,7 +16,8 @@
"@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-node": "^5.2.13",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.40.0",
"exceljs": "^4.4.0" "exceljs": "^4.4.0",
"knuth-shuffle-seeded": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
@@ -6462,6 +6463,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/knuth-shuffle-seeded": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz",
"integrity": "sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==",
"license": "Apache-2.0",
"dependencies": {
"seed-random": "~2.2.0"
}
},
"node_modules/lazystream": { "node_modules/lazystream": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -7750,6 +7760,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/seed-random": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz",
"integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",

View File

@@ -46,6 +46,7 @@
"@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-node": "^5.2.13",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.40.0",
"exceljs": "^4.4.0" "exceljs": "^4.4.0",
"knuth-shuffle-seeded": "^1.0.6"
} }
} }

View File

@@ -0,0 +1,48 @@
<script>
import { createEventDispatcher } from 'svelte';
export let isOpen = false;
export let title = '';
export let maxWidth = 'max-w-md';
const dispatch = createEventDispatcher();
function handleBackdropClick(event) {
if (event.target === event.currentTarget) {
dispatch('close');
}
}
function handleKeydown(event) {
if (event.key === 'Escape' && isOpen) {
dispatch('close');
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 transition-opacity"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
<div class="relative top-20 mx-auto p-5 border w-11/12 {maxWidth} shadow-lg rounded-md bg-white">
<div class="mt-3">
{#if title}
<h3 class="text-lg font-medium text-gray-900 mb-4" id="modal-title">
{title}
</h3>
{/if}
<div>
<slot />
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,135 @@
<script>
import { createEventDispatcher } from 'svelte';
import Modal from './Modal.svelte';
export let isOpen = false;
export let audioFilename = '';
export let isSubmitting = false;
const dispatch = createEventDispatcher();
let temporalRating = 50;
let overallRating = 50;
function handleClose() {
if (isSubmitting) return; // Prevent closing while submitting
dispatch('close');
}
function handleSubmit() {
dispatch('submit', { overallRating, temporalRating });
}
function handleRatingChange() {
// Optional: add any real-time feedback here
}
</script>
<Modal {isOpen} title="Overall Performance Rating" maxWidth="max-w-lg" on:close={handleClose}>
<div class="space-y-6">
<div class="text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-indigo-100 mb-4">
<i class="fas fa-star text-indigo-600 text-xl"></i>
</div>
<p class="text-sm text-gray-600">
You've completed rating: <strong>{audioFilename}</strong>
</p>
</div>
<div class="space-y-6">
<div class="space-y-4">
<h4 class="text-center text-lg font-medium text-gray-900">
How well do you think the two performers instantiated the temporal relationship prescribed in the score?
</h4>
<input
type="range"
min="0"
max="100"
bind:value={temporalRating}
on:input={handleRatingChange}
disabled={isSubmitting}
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 {isSubmitting ? 'opacity-50' : ''}"
/>
<div class="flex justify-between text-sm text-gray-600">
<span>Not well (0)</span>
<span class="font-semibold text-indigo-600">Rating: {temporalRating}</span>
<span>Perfectly (100)</span>
</div>
</div>
<div class="space-y-4">
<h4 class="text-center text-lg font-medium text-gray-900">
How good was this performance overall?
</h4>
<input
type="range"
min="0"
max="100"
bind:value={overallRating}
on:input={handleRatingChange}
disabled={isSubmitting}
class="slider h-3 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 {isSubmitting ? 'opacity-50' : ''}"
/>
<div class="flex justify-between text-sm text-gray-600">
<span>Poor (0)</span>
<span class="font-semibold text-indigo-600">Rating: {overallRating}</span>
<span>Excellent (100)</span>
</div>
</div>
</div>
<div class="flex space-x-3 justify-end">
<button
type="button"
on:click={handleClose}
disabled={isSubmitting}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="button"
on:click={handleSubmit}
disabled={isSubmitting}
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
{#if isSubmitting}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Submitting...
{:else}
Submit Overall Rating
{/if}
</button>
</div>
</div>
</Modal>
<style>
.slider::-webkit-slider-thumb {
appearance: none;
height: 24px;
width: 24px;
border-radius: 50%;
background: #4f46e5;
cursor: pointer;
}
.slider::-moz-range-thumb {
height: 24px;
width: 24px;
border-radius: 50%;
background: #4f46e5;
cursor: pointer;
border: none;
}
.slider:disabled::-webkit-slider-thumb {
cursor: not-allowed;
opacity: 0.5;
}
.slider:disabled::-moz-range-thumb {
cursor: not-allowed;
opacity: 0.5;
}
</style>

View File

@@ -25,7 +25,8 @@ export const audioFile = sqliteTable('audio_file', {
duration: real('duration'), duration: real('duration'),
fileSize: integer('file_size'), fileSize: integer('file_size'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
deletedAt: integer('deleted_at', { mode: 'timestamp' }) // Soft delete for audio files deletedAt: integer('deleted_at', { mode: 'timestamp' }), // Soft delete for audio files
tags: text('tags').notNull().default('[]') // JSON array of tags for admin-only classification
}); });
export const inviteLink = sqliteTable('invite_link', { export const inviteLink = sqliteTable('invite_link', {
@@ -35,7 +36,8 @@ export const inviteLink = sqliteTable('invite_link', {
isUsed: integer('is_used', { mode: 'boolean' }).notNull().default(false), isUsed: integer('is_used', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
usedAt: integer('used_at', { mode: 'timestamp' }), usedAt: integer('used_at', { mode: 'timestamp' }),
deletedAt: integer('deleted_at', { mode: 'timestamp' }) deletedAt: integer('deleted_at', { mode: 'timestamp' }),
tags: text('tags').notNull().default('[]')
}); });
export const participant = sqliteTable('participant', { export const participant = sqliteTable('participant', {
@@ -86,6 +88,7 @@ export const overallRating = sqliteTable('overall_rating', {
audioFileId: text('audio_file_id') audioFileId: text('audio_file_id')
.notNull() .notNull()
.references(() => audioFile.id), .references(() => audioFile.id),
temporalValue: real('temporal_value').notNull().default(0),
value: real('value').notNull(), // 0-100 rating value value: real('value').notNull(), // 0-100 rating value
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),

View File

@@ -0,0 +1,53 @@
export function parseStoredTags(value) {
if (!value) return [];
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) return [];
const seen = new Set();
const clean = [];
for (const rawTag of parsed) {
const value = (typeof rawTag === 'string' ? rawTag : String(rawTag ?? '')).trim();
if (!value) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
clean.push(value);
}
return clean;
} catch (err) {
console.warn('Failed to parse stored tags', err);
return [];
}
}
export function normalizeTagsInput(rawValue) {
if (!rawValue) return [];
const source = Array.isArray(rawValue)
? rawValue.join(',')
: String(rawValue);
const seen = new Set();
const tags = [];
for (const fragment of source.split(',')) {
const value = fragment.trim();
if (!value) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
tags.push(value);
}
return tags;
}
export function serializeTags(tags) {
return JSON.stringify(Array.isArray(tags) ? tags : []);
}
export function matchesInviteTags(audioTags, inviteTags) {
if (!inviteTags || inviteTags.length === 0) return true;
if (!audioTags || audioTags.length === 0) return false;
const audioSet = new Set(audioTags.map((tag) => tag.toLowerCase()));
return inviteTags.some((tag) => audioSet.has(tag.toLowerCase()));
}

View File

@@ -2,9 +2,14 @@ import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { inviteLink, participant } from '$lib/server/db/schema.js'; import { inviteLink, participant } from '$lib/server/db/schema.js';
import { eq, isNull } from 'drizzle-orm'; import { eq, isNull } from 'drizzle-orm';
import { parseStoredTags, normalizeTagsInput, serializeTags } from '$lib/server/tag-utils.js';
export async function load() { export async function load() {
const invites = await db.select().from(inviteLink).where(isNull(inviteLink.deletedAt)); const rows = await db.select().from(inviteLink).where(isNull(inviteLink.deletedAt));
const invites = rows.map((invite) => ({
...invite,
tags: parseStoredTags(invite.tags)
}));
return { return {
invites invites
}; };
@@ -125,5 +130,49 @@ export const actions = {
message: 'Failed to delete invite link' message: 'Failed to delete invite link'
}); });
} }
},
updateTags: async ({ request }) => {
const data = await request.formData();
const inviteId = data.get('inviteId');
const rawTags = data.get('tags') ?? '';
const removeTag = data.get('removeTag');
if (!inviteId) {
return fail(400, {
error: 'Missing invite id'
});
}
const records = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(eq(inviteLink.id, inviteId))
.limit(1);
if (records.length === 0) {
return fail(404, { error: 'Invite link not found' });
}
let tags;
if (removeTag) {
const current = parseStoredTags(records[0].tags);
tags = current.filter(
(tag) => tag.toLowerCase() !== String(removeTag).trim().toLowerCase()
);
} else {
tags = normalizeTagsInput(rawTags);
}
try {
await db
.update(inviteLink)
.set({ tags: serializeTags(tags) })
.where(eq(inviteLink.id, inviteId));
return { updatedTags: true, inviteId, tags };
} catch (error) {
console.error('Error updating invite tags:', error);
return fail(500, { error: 'Failed to update tags' });
}
} }
}; };

View File

@@ -148,16 +148,21 @@
> >
Created Created
</th> </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"
> >
Link Link
</th> </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"
> >
Tags (admin)
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody class="divide-y divide-gray-200 bg-white">
@@ -200,6 +205,50 @@
</button> </button>
</div> </div>
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-2">
{#if invite.tags?.length}
{#each invite.tags as tag (tag)}
<form
method="POST"
action="?/updateTags"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
use:enhance
>
<input type="hidden" name="inviteId" value={invite.id} />
<input type="hidden" name="removeTag" value={tag} />
<span>{tag}</span>
<button
type="submit"
class="text-gray-500 hover:text-gray-800"
title={`Remove ${tag}`}
>
×
</button>
</form>
{/each}
{:else}
<span class="text-xs uppercase tracking-wide text-gray-400">No tags</span>
{/if}
</div>
<form method="POST" action="?/updateTags" class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
<input type="hidden" name="inviteId" value={invite.id} />
<input
type="text"
name="tags"
value={invite.tags?.join(', ') ?? ''}
placeholder="tag1, tag2, tag3"
class="w-full rounded-md border-gray-300 px-2 py-1 text-xs focus:border-indigo-500 focus:ring-indigo-500"
title="Comma-separated list. Leave blank to remove all tags"
/>
<button
type="submit"
class="inline-flex items-center justify-center rounded bg-gray-800 px-2 py-1 text-xs font-medium text-white hover:bg-gray-700"
>
Save
</button>
</form>
</td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<form method="POST" action="?/delete" class="inline" use:enhance> <form method="POST" action="?/delete" class="inline" use:enhance>
<input type="hidden" name="inviteId" value={invite.id} /> <input type="hidden" name="inviteId" value={invite.id} />
@@ -219,7 +268,7 @@
</tr> </tr>
{:else} {:else}
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500"> <td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
No invite links created yet. No invite links created yet.
</td> </td>
</tr> </tr>

View File

@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { audioFile } from '$lib/server/db/schema.js'; import { audioFile } from '$lib/server/db/schema.js';
import { eq, isNull } from 'drizzle-orm'; import { eq, isNull } from 'drizzle-orm';
import { parseStoredTags, normalizeTagsInput, serializeTags } from '$lib/server/tag-utils.js';
import { uploadToS3, deleteFromS3, generateAudioS3Key } from '$lib/server/s3.js'; import { uploadToS3, deleteFromS3, generateAudioS3Key } from '$lib/server/s3.js';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
@@ -45,17 +46,23 @@ async function getAudioDuration(buffer) {
} }
export async function load() { export async function load() {
const audioFiles = await db.select({ const rows = await db.select({
id: audioFile.id, id: audioFile.id,
filename: audioFile.filename, filename: audioFile.filename,
contentType: audioFile.contentType, contentType: audioFile.contentType,
duration: audioFile.duration, duration: audioFile.duration,
fileSize: audioFile.fileSize, fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt createdAt: audioFile.createdAt,
tags: audioFile.tags
}) })
.from(audioFile) .from(audioFile)
.where(isNull(audioFile.deletedAt)); // Only show active audio files .where(isNull(audioFile.deletedAt)); // Only show active audio files
const audioFiles = rows.map((audio) => ({
...audio,
tags: parseStoredTags(audio.tags)
}));
return { return {
audioFiles audioFiles
}; };
@@ -196,4 +203,47 @@ export const actions = {
return fail(500, { error: 'Failed to rename audio file' }); return fail(500, { error: 'Failed to rename audio file' });
} }
} }
,
updateTags: async ({ request }) => {
const data = await request.formData();
const audioFileId = data.get('audioFileId');
const rawTags = data.get('tags') ?? '';
const removeTag = data.get('removeTag');
if (!audioFileId) {
return fail(400, { error: 'Missing audio file id' });
}
const records = await db
.select({ tags: audioFile.tags })
.from(audioFile)
.where(eq(audioFile.id, audioFileId))
.limit(1);
if (records.length === 0) {
return fail(404, { error: 'Audio file not found' });
}
let tags;
if (removeTag) {
const currentTags = parseStoredTags(records[0].tags);
tags = currentTags.filter(
(tag) => tag.toLowerCase() !== String(removeTag).trim().toLowerCase()
);
} else {
tags = normalizeTagsInput(rawTags);
}
try {
await db
.update(audioFile)
.set({ tags: serializeTags(tags) })
.where(eq(audioFile.id, audioFileId));
return { updatedTags: true, audioFileId, tags };
} catch (error) {
console.error('Error updating audio tags:', error);
return fail(500, { error: 'Failed to update tags' });
}
}
}; };

View File

@@ -366,16 +366,21 @@
> >
Duration Duration
</th> </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"
> >
Uploaded Uploaded
</th> </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"
> >
Tags (admin only)
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody class="divide-y divide-gray-200 bg-white">
@@ -416,6 +421,50 @@
<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">
{new Date(audio.createdAt).toLocaleDateString()} {new Date(audio.createdAt).toLocaleDateString()}
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-2">
{#if audio.tags?.length}
{#each audio.tags as tag (tag)}
<form
method="POST"
action="?/updateTags"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
use:enhance
>
<input type="hidden" name="audioFileId" value={audio.id} />
<input type="hidden" name="removeTag" value={tag} />
<span>{tag}</span>
<button
type="submit"
class="text-gray-500 hover:text-gray-800"
title={`Remove ${tag}`}
>
×
</button>
</form>
{/each}
{:else}
<span class="text-xs uppercase tracking-wide text-gray-400">No tags</span>
{/if}
</div>
<form method="POST" action="?/updateTags" class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
<input type="hidden" name="audioFileId" value={audio.id} />
<input
type="text"
name="tags"
value={audio.tags?.join(', ') ?? ''}
placeholder="tag1, tag2, tag3"
class="w-full rounded-md border-gray-300 px-2 py-1 text-xs focus:border-indigo-500 focus:ring-indigo-500"
title="Comma-separated list. Leave blank to remove all tags"
/>
<button
type="submit"
class="inline-flex items-center justify-center rounded bg-gray-800 px-2 py-1 text-xs font-medium text-white hover:bg-gray-700"
>
Save
</button>
</form>
</td>
<td class="space-x-2 px-6 py-4 text-sm font-medium whitespace-nowrap"> <td class="space-x-2 px-6 py-4 text-sm font-medium whitespace-nowrap">
{#if editingAudioId === audio.id} {#if editingAudioId === audio.id}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -469,7 +518,7 @@
</tr> </tr>
{:else} {:else}
<tr> <tr>
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500"> <td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">
No audio files uploaded yet. No audio files uploaded yet.
</td> </td>
</tr> </tr>

View File

@@ -62,7 +62,14 @@ export async function load() {
// Get overall ratings // Get overall ratings
const overallRatings = await db const overallRatings = await db
.select({ .select({
overallRating, overallRating: {
id: overallRating.id,
participantId: overallRating.participantId,
audioFileId: overallRating.audioFileId,
value: overallRating.value,
temporalValue: overallRating.temporalValue,
createdAt: overallRating.createdAt
},
participant, participant,
audioFile: { audioFile: {
id: audioFile.id, id: audioFile.id,
@@ -158,31 +165,45 @@ export async function load() {
overallRatingsMap[audioId][participantId] = { overallRatingsMap[audioId][participantId] = {
value: entry.overallRating.value, value: entry.overallRating.value,
temporalValue: entry.overallRating.temporalValue,
createdAt: entry.overallRating.createdAt, createdAt: entry.overallRating.createdAt,
participantName participantName
}; };
// Ensure the participant exists in timeseriesData // Ensure the participant exists in timeseriesData
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) { if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
timeseriesData[audioId].participants[participantId].overallRating = entry.overallRating.value; timeseriesData[audioId].participants[participantId].overallResponses = {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
};
timeseriesData[audioId].participants[participantId].isCompleted = true;
} else if (timeseriesData[audioId]) { } else if (timeseriesData[audioId]) {
// Participant has overall rating but no timeseries data yet // Participant has overall rating but no timeseries data yet
timeseriesData[audioId].participants[participantId] = { timeseriesData[audioId].participants[participantId] = {
name: participantName, name: participantName,
ratings: [], ratings: [],
isCompleted: false, isCompleted: true,
overallRating: entry.overallRating.value overallResponses: {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
}
}; };
} else { } else {
// Neither audio nor participant exists in timeseriesData yet // Neither audio nor participant exists in timeseriesData yet
timeseriesData[audioId] = { timeseriesData[audioId] = {
audioFile: entry.audioFile, audioFile: entry.audioFile,
participants: { participants: {
[participantId]: { [participantId]: {
name: participantName, name: participantName,
ratings: [], ratings: [],
isCompleted: false, isCompleted: true,
overallRating: entry.overallRating.value overallResponses: {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
}
} }
} }
}; };

View File

@@ -181,7 +181,7 @@
<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"
> >
Overall Rating Post-Listening Ratings
</th> </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"
@@ -203,8 +203,10 @@
{@const minTimestamp = participantData.ratings.length > 0 ? Math.min(...participantData.ratings.map(r => r.timestamp)) : 0} {@const minTimestamp = participantData.ratings.length > 0 ? Math.min(...participantData.ratings.map(r => r.timestamp)) : 0}
{@const maxTimestamp = participantData.ratings.length > 0 ? Math.max(...participantData.ratings.map(r => r.timestamp)) : 0} {@const maxTimestamp = participantData.ratings.length > 0 ? Math.max(...participantData.ratings.map(r => r.timestamp)) : 0}
{@const durationCovered = maxTimestamp - minTimestamp} {@const durationCovered = maxTimestamp - minTimestamp}
{@const lastUpdated = participantData.ratings.length > 0 ? {@const lastTimeseriesUpdate = participantData.ratings.length > 0 ?
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null} new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null}
{@const overallResponses = participantData.overallResponses}
{@const lastUpdated = lastTimeseriesUpdate ?? (overallResponses ? new Date(overallResponses.createdAt) : null)}
<tr> <tr>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900"> <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
{participantData.name} {participantData.name}
@@ -238,10 +240,11 @@
{/if} {/if}
</td> </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 participantData.overallRating !== undefined} {#if overallResponses}
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"> <div class="flex flex-col text-xs text-gray-700">
{participantData.overallRating}/100 <span class="font-medium text-gray-900">Temporal: <span class="text-blue-700">{overallResponses.temporal ?? '-'} / 100</span></span>
</span> <span class="font-medium text-gray-900">Overall: <span class="text-indigo-700">{overallResponses.overall ?? '-'} / 100</span></span>
</div>
{:else} {:else}
<span class="text-gray-400">-</span> <span class="text-gray-400">-</span>
{/if} {/if}

View File

@@ -1,17 +1,60 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { audioFile } from '$lib/server/db/schema.js'; import { audioFile, inviteLink, participant } from '$lib/server/db/schema.js';
import { eq, isNull, and } from 'drizzle-orm'; import { eq, isNull, and } from 'drizzle-orm';
import { getFromS3, getFromS3WithRange } from '$lib/server/s3.js'; import { getFromS3, getFromS3WithRange } from '$lib/server/s3.js';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
export async function GET({ params, request }) { export async function GET({ params, request, url, cookies }) {
const fileId = params.id; const fileId = params.id;
const token = url.searchParams.get('token');
if (!token) {
throw error(400, 'Missing token');
}
const participantId = cookies.get(`participant-${token}`);
if (!participantId) {
throw error(403, 'Unauthorized');
}
const participants = await db
.select({ inviteToken: participant.inviteToken })
.from(participant)
.where(
and(
eq(participant.id, participantId),
eq(participant.inviteToken, token),
isNull(participant.deletedAt)
)
);
if (participants.length === 0) {
throw error(403, 'Unauthorized');
}
const invites = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(
and(
eq(inviteLink.token, token),
isNull(inviteLink.deletedAt)
)
);
if (invites.length === 0) {
throw error(404, 'Invite not found');
}
const inviteTags = parseStoredTags(invites[0].tags);
// Get file metadata from database (only s3Key and contentType needed) // Get file metadata from database (only s3Key and contentType needed)
const files = await db.select({ const files = await db.select({
s3Key: audioFile.s3Key, s3Key: audioFile.s3Key,
contentType: audioFile.contentType, contentType: audioFile.contentType,
fileSize: audioFile.fileSize fileSize: audioFile.fileSize,
tags: audioFile.tags
}) })
.from(audioFile) .from(audioFile)
.where(and( .where(and(
@@ -24,6 +67,11 @@ export async function GET({ params, request }) {
} }
const file = files[0]; const file = files[0];
const audioTags = parseStoredTags(file.tags);
if (!matchesInviteTags(audioTags, inviteTags)) {
throw error(403, 'Unauthorized');
}
// Check if file has S3 key (new files) or fall back to error for old blob-based files // Check if file has S3 key (new files) or fall back to error for old blob-based files
if (!file.s3Key) { if (!file.s3Key) {

View File

@@ -28,7 +28,14 @@ export async function GET() {
// Get overall ratings // Get overall ratings
const overallRatings = await db const overallRatings = await db
.select({ .select({
overallRating, overallRating: {
id: overallRating.id,
participantId: overallRating.participantId,
audioFileId: overallRating.audioFileId,
value: overallRating.value,
temporalValue: overallRating.temporalValue,
createdAt: overallRating.createdAt
},
participant, participant,
audioFile, audioFile,
inviteLink inviteLink
@@ -96,14 +103,23 @@ export async function GET() {
// Ensure the participant exists in timeseriesData // Ensure the participant exists in timeseriesData
if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) { if (timeseriesData[audioId] && timeseriesData[audioId].participants[participantId]) {
timeseriesData[audioId].participants[participantId].overallRating = entry.overallRating.value; timeseriesData[audioId].participants[participantId].overallResponses = {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
};
timeseriesData[audioId].participants[participantId].isCompleted = true;
} else if (timeseriesData[audioId]) { } else if (timeseriesData[audioId]) {
// Participant has overall rating but no timeseries data yet // Participant has overall rating but no timeseries data yet
timeseriesData[audioId].participants[participantId] = { timeseriesData[audioId].participants[participantId] = {
name: participantName, name: participantName,
ratings: [], ratings: [],
isCompleted: false, isCompleted: true,
overallRating: entry.overallRating.value overallResponses: {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
}
}; };
} else { } else {
// Neither audio nor participant exists in timeseriesData yet // Neither audio nor participant exists in timeseriesData yet
@@ -113,8 +129,12 @@ export async function GET() {
[participantId]: { [participantId]: {
name: participantName, name: participantName,
ratings: [], ratings: [],
isCompleted: false, isCompleted: true,
overallRating: entry.overallRating.value overallResponses: {
temporal: entry.overallRating.temporalValue,
overall: entry.overallRating.value,
createdAt: entry.overallRating.createdAt
}
} }
} }
}; };
@@ -132,6 +152,7 @@ export async function GET() {
'Status', 'Status',
'Data Points Count', 'Data Points Count',
'Duration Covered (seconds)', 'Duration Covered (seconds)',
'Temporal Rating (0-100)',
'Overall Rating (0-100)', 'Overall Rating (0-100)',
'Timeseries Data (timestamp:value pairs)', 'Timeseries Data (timestamp:value pairs)',
'Last Updated' 'Last Updated'
@@ -155,8 +176,10 @@ export async function GET() {
Math.max(...participantData.ratings.map(r => r.timestamp)) : 0; Math.max(...participantData.ratings.map(r => r.timestamp)) : 0;
const durationCovered = maxTimestamp - minTimestamp; const durationCovered = maxTimestamp - minTimestamp;
const lastUpdated = participantData.ratings.length > 0 ? const overallResponses = participantData.overallResponses;
const lastTimeseriesUpdate = participantData.ratings.length > 0 ?
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null; new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null;
const lastUpdated = lastTimeseriesUpdate ?? (overallResponses ? new Date(overallResponses.createdAt) : null);
// Use the stored timeseries data from the rating record // Use the stored timeseries data from the rating record
const timeseriesData = participantData.ratings.find(r => r.isCompleted && r.timeseriesData)?.timeseriesData || const timeseriesData = participantData.ratings.find(r => r.isCompleted && r.timeseriesData)?.timeseriesData ||
@@ -168,7 +191,8 @@ 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 : '-', overallResponses?.temporal ?? '-',
overallResponses?.overall ?? '-',
timeseriesData, timeseriesData,
lastUpdated ? lastUpdated.toLocaleString() : '-' lastUpdated ? lastUpdated.toLocaleString() : '-'
]); ]);

View File

@@ -1,7 +1,10 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { inviteLink, participant, audioFile, rating } from '$lib/server/db/schema.js'; import { inviteLink, participant, audioFile, rating, overallRating } from '$lib/server/db/schema.js';
import { eq, isNull, and } from 'drizzle-orm'; import { eq, isNull, and, inArray } from 'drizzle-orm';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
import { env } from '$env/dynamic/private';
import shuffle from 'knuth-shuffle-seeded';
export async function load({ url, cookies }) { export async function load({ url, cookies }) {
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
@@ -21,7 +24,8 @@ export async function load({ url, cookies }) {
throw error(404, 'Invite link not found or has been deleted'); throw error(404, 'Invite link not found or has been deleted');
} }
const invite = invites[0]; const { tags: inviteTagString, ...safeInvite } = invites[0];
const inviteTags = parseStoredTags(inviteTagString);
let participantId = cookies.get(`participant-${token}`); let participantId = cookies.get(`participant-${token}`);
let isExistingParticipant = false; let isExistingParticipant = false;
@@ -66,49 +70,93 @@ export async function load({ url, cookies }) {
}); });
} }
const audioFiles = await db.select({ const audioRows = await db.select({
id: audioFile.id, id: audioFile.id,
filename: audioFile.filename, filename: audioFile.filename,
contentType: audioFile.contentType, contentType: audioFile.contentType,
duration: audioFile.duration, duration: audioFile.duration,
fileSize: audioFile.fileSize, fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt createdAt: audioFile.createdAt,
tags: audioFile.tags
}) })
.from(audioFile) .from(audioFile)
.where(isNull(audioFile.deletedAt)); // Only show active audio files .where(isNull(audioFile.deletedAt)); // Only show active audio files
// Get completed ratings for this participant (only active, non-deleted ratings for active audio files) const baseAudioList = audioRows
const completedRatings = await db .map((audio) => ({
.select({ data: {
audioFileId: rating.audioFileId id: audio.id,
}) filename: audio.filename,
.from(rating) contentType: audio.contentType,
.innerJoin(audioFile, and( duration: audio.duration,
eq(rating.audioFileId, audioFile.id), fileSize: audio.fileSize,
isNull(audioFile.deletedAt) // Only count ratings for active audio files createdAt: audio.createdAt
)) },
.where( tags: parseStoredTags(audio.tags)
and( }))
eq(rating.participantId, participantId), .filter(({ tags }) => matchesInviteTags(tags, inviteTags))
eq(rating.isCompleted, true), .map(({ data }) => data);
isNull(rating.deletedAt) // Only count active ratings
)
);
const completedAudioIds = new Set(completedRatings.map(r => r.audioFileId)); const shuffleSeed = participantId || token;
const filteredAudio = baseAudioList.length > 0
? shuffle([...baseAudioList], shuffleSeed)
: baseAudioList;
// Add completion status to audio files const allowedAudioIds = filteredAudio.map((file) => file.id);
const audioFilesWithStatus = audioFiles.map(file => ({
const displayContinuousRating = env.DISPLAY_CONT_RATING !== 'false';
let completedRatings = [];
if (allowedAudioIds.length > 0) {
if (displayContinuousRating) {
completedRatings = await db
.select({
audioFileId: rating.audioFileId
})
.from(rating)
.innerJoin(audioFile, and(
eq(rating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt)
))
.where(
and(
eq(rating.participantId, participantId),
eq(rating.isCompleted, true),
isNull(rating.deletedAt),
inArray(rating.audioFileId, allowedAudioIds)
)
);
} else {
completedRatings = await db
.select({ audioFileId: overallRating.audioFileId })
.from(overallRating)
.innerJoin(audioFile, and(
eq(overallRating.audioFileId, audioFile.id),
isNull(audioFile.deletedAt)
))
.where(
and(
eq(overallRating.participantId, participantId),
isNull(overallRating.deletedAt),
inArray(overallRating.audioFileId, allowedAudioIds)
)
);
}
}
const completedAudioIds = new Set(completedRatings.map((r) => r.audioFileId));
const audioFilesWithStatus = filteredAudio.map(file => ({
...file, ...file,
isCompleted: completedAudioIds.has(file.id) isCompleted: completedAudioIds.has(file.id)
})); }));
return { return {
invite, invite: safeInvite,
participantId, participantId,
audioFiles: audioFilesWithStatus, audioFiles: audioFilesWithStatus,
token, token,
completedCount: completedRatings.length, completedCount: completedRatings.length,
totalCount: audioFiles.length totalCount: filteredAudio.length,
displayContinuousRating
}; };
} }

View File

@@ -1,6 +1,8 @@
<script> <script>
export let data; export let data;
const showContinuousRating = data?.displayContinuousRating ?? true;
</script> </script>
<div class="min-h-screen bg-gray-50 py-8"> <div class="min-h-screen bg-gray-50 py-8">
@@ -11,8 +13,12 @@
<p class="text-lg text-gray-600">Hello, {data.invite.participantName}!</p> <p class="text-lg text-gray-600">Hello, {data.invite.participantName}!</p>
{/if} {/if}
<p class="mt-4 text-gray-600"> <p class="mt-4 text-gray-600">
Thank you for participating in our study. You will listen to audio files and rate them using Thank you for participating in our study. You will listen to audio files
a slider. Click "Submit Rating" when you're finished rating each file. {#if showContinuousRating}
and rate them using a slider. Click "Submit Rating" when you're finished rating each file.
{:else}
and rate them.
{/if}
</p> </p>
</div> </div>
@@ -50,8 +56,11 @@
<h2 class="mb-4 text-xl font-semibold text-gray-900">Instructions</h2> <h2 class="mb-4 text-xl font-semibold text-gray-900">Instructions</h2>
<ul class="list-inside list-disc space-y-2 text-gray-700"> <ul class="list-inside list-disc space-y-2 text-gray-700">
<li>Click on any audio file below to start listening and rating</li> <li>Click on any audio file below to start listening and rating</li>
<li>Use the slider to rate the audio while listening (move it as your opinion changes)</li> {#if showContinuousRating}
<li>Click "Submit Rating" when you're finished to save your rating</li> <li>Use the slider to rate the audio while listening (move it as your opinion changes)</li>
<li>Click "Submit Rating" when you're finished to save your rating</li>
{:else}
{/if}
<li>If you've already rated a file, click "View" to see it again or submit a new rating to replace the old one</li> <li>If you've already rated a file, click "View" to see it again or submit a new rating to replace the old one</li>
<li>You can come back later to rate remaining files</li> <li>You can come back later to rate remaining files</li>
</ul> </ul>

View File

@@ -1,7 +1,65 @@
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, overallRating } from '$lib/server/db/schema.js'; import { audioFile, rating, participantProgress, participant, overallRating, inviteLink } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull } from 'drizzle-orm';
import { parseStoredTags, matchesInviteTags } from '$lib/server/tag-utils.js';
import { env } from '$env/dynamic/private';
async function ensureParticipantAccess(participantId, audioFileId) {
const participantRecords = await db
.select({ inviteToken: participant.inviteToken })
.from(participant)
.where(
and(
eq(participant.id, participantId),
isNull(participant.deletedAt)
)
)
.limit(1);
if (participantRecords.length === 0) {
return { allowed: false, reason: 'Participant not found' };
}
const inviteRecords = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(
and(
eq(inviteLink.token, participantRecords[0].inviteToken),
isNull(inviteLink.deletedAt)
)
)
.limit(1);
if (inviteRecords.length === 0) {
return { allowed: false, reason: 'Invite not found' };
}
const inviteTags = parseStoredTags(inviteRecords[0].tags);
const audioRecords = await db
.select({ id: audioFile.id, tags: audioFile.tags })
.from(audioFile)
.where(
and(
eq(audioFile.id, audioFileId),
isNull(audioFile.deletedAt)
)
)
.limit(1);
if (audioRecords.length === 0) {
return { allowed: false, reason: 'Audio file not found' };
}
const audioTags = parseStoredTags(audioRecords[0].tags);
if (!matchesInviteTags(audioTags, inviteTags)) {
return { allowed: false, reason: 'Forbidden' };
}
return { allowed: true };
}
export async function load({ params, url, cookies }) { export async function load({ params, url, cookies }) {
const audioId = params.id; const audioId = params.id;
@@ -16,6 +74,24 @@ export async function load({ params, url, cookies }) {
redirect(302, `/participate?token=${token}`); redirect(302, `/participate?token=${token}`);
} }
const displayContinuousRating = env.DISPLAY_CONT_RATING !== 'false';
const inviteRecords = await db
.select({ tags: inviteLink.tags })
.from(inviteLink)
.where(
and(
eq(inviteLink.token, token),
isNull(inviteLink.deletedAt)
)
);
if (inviteRecords.length === 0) {
throw error(404, 'Invite link not found or has been deleted');
}
const inviteTags = parseStoredTags(inviteRecords[0].tags);
// Verify participant exists and isn't soft-deleted // Verify participant exists and isn't soft-deleted
const participants = await db const participants = await db
.select() .select()
@@ -37,7 +113,8 @@ export async function load({ params, url, cookies }) {
contentType: audioFile.contentType, contentType: audioFile.contentType,
duration: audioFile.duration, duration: audioFile.duration,
fileSize: audioFile.fileSize, fileSize: audioFile.fileSize,
createdAt: audioFile.createdAt createdAt: audioFile.createdAt,
tags: audioFile.tags
}) })
.from(audioFile) .from(audioFile)
.where(and( .where(and(
@@ -49,6 +126,13 @@ export async function load({ params, url, cookies }) {
throw error(404, 'Audio file not found'); throw error(404, 'Audio file not found');
} }
const [audioRecord] = audioFiles;
const audioTags = parseStoredTags(audioRecord.tags);
if (!matchesInviteTags(audioTags, inviteTags)) {
throw error(403, 'You do not have access to this audio file');
}
const progressData = await db const progressData = await db
.select({ .select({
id: participantProgress.id, id: participantProgress.id,
@@ -76,12 +160,30 @@ export async function load({ params, url, cookies }) {
isNull(rating.deletedAt) isNull(rating.deletedAt)
)); ));
const existingOverallRatings = await db
.select()
.from(overallRating)
.where(and(
eq(overallRating.participantId, participantId),
eq(overallRating.audioFileId, audioId),
isNull(overallRating.deletedAt)
));
return { return {
audioFile: audioFiles[0], audioFile: {
id: audioRecord.id,
filename: audioRecord.filename,
contentType: audioRecord.contentType,
duration: audioRecord.duration,
fileSize: audioRecord.fileSize,
createdAt: audioRecord.createdAt
},
participantId, participantId,
token, token,
progress, progress,
existingRatings existingRatings,
existingOverallRatings,
displayContinuousRating
}; };
} }
@@ -100,6 +202,11 @@ export const actions = {
} }
try { try {
const access = await ensureParticipantAccess(participantId, audioFileId);
if (!access.allowed) {
return { error: access.reason || 'You do not have access to this audio file' };
}
const ratingHistory = JSON.parse(ratingHistoryStr); const ratingHistory = JSON.parse(ratingHistoryStr);
console.log('Rating history length:', ratingHistory.length); console.log('Rating history length:', ratingHistory.length);
console.log('Rating history:', ratingHistory); console.log('Rating history:', ratingHistory);
@@ -185,6 +292,11 @@ export const actions = {
} }
try { try {
const access = await ensureParticipantAccess(participantId, audioFileId);
if (!access.allowed) {
return { error: access.reason || 'You do not have access to this audio file' };
}
// Soft delete the active rating for this participant and audio file // Soft delete the active rating for this participant and audio file
await db.update(rating) await db.update(rating)
.set({ deletedAt: new Date() }) .set({ deletedAt: new Date() })
@@ -259,8 +371,9 @@ export const actions = {
const participantId = data.get('participantId'); const participantId = data.get('participantId');
const audioFileId = data.get('audioFileId'); const audioFileId = data.get('audioFileId');
const overallRatingValue = parseFloat(data.get('overallRating')); const overallRatingValue = parseFloat(data.get('overallRating'));
const temporalRatingValue = parseFloat(data.get('temporalRating'));
if (!participantId || !audioFileId || isNaN(overallRatingValue)) { if (!participantId || !audioFileId || isNaN(overallRatingValue) || isNaN(temporalRatingValue)) {
return { error: 'Invalid overall rating data' }; return { error: 'Invalid overall rating data' };
} }
@@ -286,6 +399,7 @@ export const actions = {
id: overallRatingId, id: overallRatingId,
participantId, participantId,
audioFileId, audioFileId,
temporalValue: temporalRatingValue,
value: overallRatingValue, value: overallRatingValue,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -1,8 +1,13 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import OverallRatingModal from '$lib/components/OverallRatingModal.svelte'; import OverallRatingModal from '$lib/components/OverallRatingModal.svelte';
export let data; export let data;
let audioApiUrl = '';
$: audioApiUrl = `/api/audio/${data.audioFile.id}?token=${data.token}`;
const showContinuousRating = data?.displayContinuousRating ?? true;
const existingOverallRatings = data?.existingOverallRatings ?? [];
let hasSubmittedOverallRating = existingOverallRatings.length > 0;
let audio; let audio;
let isPlaying = false; let isPlaying = false;
@@ -31,7 +36,7 @@
currentTime = 0; currentTime = 0;
console.log('Component mounted, audio file:', data.audioFile); console.log('Component mounted, audio file:', data.audioFile);
console.log('Audio API URL:', `/api/audio/${data.audioFile.id}`); console.log('Audio API URL:', audioApiUrl);
// Always reset completion tracking - require full listening every time // Always reset completion tracking - require full listening every time
hasListenedToEnd = false; hasListenedToEnd = false;
@@ -69,7 +74,7 @@
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
const response = await fetch(`/api/audio/${data.audioFile.id}`); const response = await fetch(audioApiUrl);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
@@ -233,6 +238,9 @@
hasListenedToEnd = true; hasListenedToEnd = true;
isAudioCompleted = true; isAudioCompleted = true;
maxReachedTime = duration; maxReachedTime = duration;
if (!showContinuousRating && !hasSubmittedOverallRating) {
showOverallRatingModal = true;
}
} }
function handleRatingChange() { function handleRatingChange() {
@@ -288,13 +296,14 @@
} }
async function handleOverallRatingSubmit(event) { async function handleOverallRatingSubmit(event) {
const { overallRating } = event.detail; const { overallRating, temporalRating } = event.detail;
isSubmittingOverallRating = true; isSubmittingOverallRating = true;
const formData = new FormData(); const formData = new FormData();
formData.append('participantId', data.participantId); formData.append('participantId', data.participantId);
formData.append('audioFileId', data.audioFile.id); formData.append('audioFileId', data.audioFile.id);
formData.append('overallRating', overallRating.toString()); formData.append('overallRating', overallRating.toString());
formData.append('temporalRating', temporalRating.toString());
try { try {
const response = await fetch('?/saveOverallRating', { const response = await fetch('?/saveOverallRating', {
@@ -315,9 +324,15 @@
alert('Error saving overall rating. Please try again.'); alert('Error saving overall rating. Please try again.');
} finally { } finally {
isSubmittingOverallRating = false; isSubmittingOverallRating = false;
hasSubmittedOverallRating = true;
} }
} }
function openOverallRatingModal() {
if (!isAudioCompleted || isSubmittingOverallRating) return;
showOverallRatingModal = true;
}
function handleOverallRatingClose() { function handleOverallRatingClose() {
if (isSubmittingOverallRating) return; // Prevent closing while submitting if (isSubmittingOverallRating) return; // Prevent closing while submitting
showOverallRatingModal = false; showOverallRatingModal = false;
@@ -362,15 +377,19 @@
<div class="rounded-lg bg-white p-8 shadow"> <div class="rounded-lg bg-white p-8 shadow">
<h1 class="mb-2 text-2xl font-bold text-gray-900">{data.audioFile.filename}</h1> <h1 class="mb-2 text-2xl font-bold text-gray-900">{data.audioFile.filename}</h1>
<p class="mb-8 text-gray-600"> <p class="mb-8 text-gray-600">
Listen to the audio and move the slider to rate it continuously {#if showContinuousRating}
</p> Listen to the audio and move the slider to rate it continuously
{:else}
Listen closely to the entire audio clip. Continuous slider ratings are disabled; you'll answer two quick questions after listening.
{/if}
</p>
<!-- Audio Player --> <!-- Audio Player -->
<div class="mb-8"> <div class="mb-8">
<audio <audio
bind:this={audio} bind:this={audio}
src="/api/audio/{data.audioFile.id}" src={audioApiUrl}
on:timeupdate={handleTimeUpdate} on:timeupdate={handleTimeUpdate}
on:loadedmetadata={handleLoadedMetadata} on:loadedmetadata={handleLoadedMetadata}
on:play={handlePlay} on:play={handlePlay}
@@ -476,80 +495,112 @@
</div> </div>
<!-- Rating Slider --> <!-- Rating Slider -->
<div class="space-y-6"> {#if showContinuousRating}
{#if data.existingRatings && data.existingRatings.length > 0} <div class="space-y-6">
<div class="rounded-md border border-amber-200 bg-amber-50 p-4"> {#if data.existingRatings && data.existingRatings.length > 0}
<p class="font-medium text-amber-800">⚠️ You have already submitted a rating for this audio file</p> <div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<p class="text-sm text-amber-700 mt-1"> <p class="font-medium text-amber-800">⚠️ You have already submitted a rating for this audio file</p>
To redo your rating: listen to the full audio clip again, adjust the slider as needed, and click "Update Rating". <p class="text-sm text-amber-700 mt-1">
This will replace your previous rating completely. To redo your rating: listen to the full audio clip again, adjust the slider as needed, and click "Update Rating".
</p> This will replace your previous rating completely.
</div> </p>
{/if} </div>
{/if}
<div> <div>
<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> <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>
<div class="space-y-4"> <div class="space-y-4">
<input <input
type="range" type="range"
min="0" min="0"
max="100" max="100"
bind:value={ratingValue} bind:value={ratingValue}
on:input={handleRatingChange} on:input={handleRatingChange}
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>Not at all (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>Perfectly (100)</span> <span>Perfectly (100)</span>
</div>
</div> </div>
</div> </div>
</div>
<!-- Save Rating Button --> <!-- Save Rating Button -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<button <button
on:click={saveRating} on:click={saveRating}
disabled={!hasRatingChanged || isSavingRating || !isAudioCompleted} disabled={!hasRatingChanged || isSavingRating || !isAudioCompleted}
class="flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white {(!hasRatingChanged || isSavingRating || !isAudioCompleted) ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'} transition-colors" class="flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white {(!hasRatingChanged || isSavingRating || !isAudioCompleted) ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'} transition-colors"
> >
{#if isSavingRating} {#if isSavingRating}
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div> <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Saving... Saving...
{:else}
{#if data.existingRatings && data.existingRatings.length > 0}
Update Rating
{:else} {:else}
Submit Rating {#if data.existingRatings && data.existingRatings.length > 0}
Update Rating
{:else}
Submit Rating
{/if}
{/if} {/if}
{/if} </button>
</button>
<div class="flex flex-col items-end space-y-1"> <div class="flex flex-col items-end space-y-1">
{#if hasRatingChanged} {#if hasRatingChanged}
<p class="text-sm text-amber-600"> <p class="text-sm text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i> <i class="fas fa-exclamation-triangle mr-1"></i>
You have unsaved changes You have unsaved changes
</p> </p>
{/if} {/if}
{#if !isAudioCompleted} {#if !isAudioCompleted}
<p class="text-sm text-red-600"> <p class="text-sm text-red-600">
<i class="fas fa-play-circle mr-1"></i> <i class="fas fa-play-circle mr-1"></i>
Must listen to full audio clip to submit Must listen to full audio clip to submit
</p> </p>
{/if} {/if}
</div>
</div> </div>
{#if data.progress?.isCompleted}
<div class="rounded-md border border-blue-200 bg-blue-50 p-4">
<p class="font-medium text-blue-800">✓ You have finished listening to this audio file</p>
</div>
{/if}
</div> </div>
{:else}
{#if data.progress?.isCompleted} <div class="mt-10 rounded-lg border border-blue-200 bg-blue-50 p-6 space-y-4">
<div class="rounded-md border border-blue-200 bg-blue-50 p-4"> {#if existingOverallRatings.length > 0 || hasSubmittedOverallRating}
<p class="font-medium text-blue-800">✓ You have finished listening to this audio file</p> <p class="text-sm text-blue-800 font-medium">
</div> You have already answered the required questions for this clip.
{/if} </p>
</div> <a
href="/participate?token={data.token}"
class="inline-flex items-center justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Return to Dashboard
</a>
{:else}
<p class="text-sm text-blue-800 font-medium">
After fully listening to the clip, please answer two quick questions.
</p>
<button
on:click={openOverallRatingModal}
disabled={!isAudioCompleted || isSubmittingOverallRating}
class="inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium text-white {(!isAudioCompleted || isSubmittingOverallRating) ? 'bg-gray-500 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'}"
>
Answer Questions
</button>
{#if !isAudioCompleted}
<p class="text-xs text-blue-700">
Listen to the entire clip to enable the questions.
</p>
{/if}
{/if}
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>