diff --git a/backend/db.js b/backend/db.js index da81fc7..65c3485 100644 --- a/backend/db.js +++ b/backend/db.js @@ -18,5 +18,6 @@ const Song = require('./models/songModel.js'); const User = require('./models/userModel.js'); const DailyChallenge = require('./models/dailyChallenge.js') const Match = require('./models/matchModel.js'); +const RoomModel = require('./models/roomModel.js') -module.exports = {User, Song, DailyChallenge, Match}; \ No newline at end of file +module.exports = {User, Song, DailyChallenge, Match, RoomModel}; \ No newline at end of file diff --git a/backend/models/roomModel.js b/backend/models/roomModel.js new file mode 100644 index 0000000..8cd6b0a --- /dev/null +++ b/backend/models/roomModel.js @@ -0,0 +1,22 @@ +const mongoose = require("mongoose"); + +const roomSchema = new mongoose.Schema({ + players: { + type: Array, + default: [] // { userId, score } + } +}); + +// Save game records +roomSchema.methods.saveGameRecords = async function (players) { + const playerRecords = players.map((player) => ({ + userId: player.id, + score: player.score + })); + + this.players = playerRecords; + + return await this.save(); +} + +module.exports = mongoose.model("RoomModel", roomSchema) \ No newline at end of file diff --git a/backend/routes/gameRouter.js b/backend/routes/gameRouter.js index cd0ae6f..9df1989 100644 --- a/backend/routes/gameRouter.js +++ b/backend/routes/gameRouter.js @@ -5,6 +5,9 @@ const { getAccessToken } = require("../utility/tokenManager"); const DailyChallenge = require("../models/dailyChallenge"); const User = require("../models/userModel"); const mongoose = require("mongoose"); +const { Room } = require("../utility/room"); +const { Player } = require("../utility/player"); +const { generateQuestionSets } = require("../utility/gameManager"); function normalizeDate(date) { const normalized = new Date(date); @@ -12,6 +15,125 @@ function normalizeDate(date) { return normalized; } +router.post("/joinRoom", async (req, res) => { + try { + const { code, userId } = req.body // Get room code & player infos + + console.log("User %s requests to join room %s", userId, code) // complete log + + let room = Room.getRoom(code) + if (room !== undefined) { // room exists + console.log("Room %s found.", code) + console.log(room) + + let player; + + // Player exists? + if (room.players.has(userId)) { + console.log("Player %s found.", userId) + player = room.players.get(userId) + } else { + console.log("Player %s not found. Creating new instance.", userId) + player = new Player(userId) + room.join(player) // join + } + + // Response + const updatedQuestionSets = room.questionSets.map(({ correct, ...rest }) => rest); + console.log("Question sets generated", room.questionSets) + + res.status(200).json({ + code: code, + players: Array.from(room.players), + questionSets: updatedQuestionSets, // hide correct answers + progress: player.progress, // idx of question answered + score: player.score // player's current score + }) + } + else { + console.log("Room %s not found, bad request.", code) + + res.status(400) // bad request, room doesn't exist + throw new Error("Room doesn't exist") + // TODO: we can choose to create room with specific code + } + + } catch (error) { + console.log("Error fetch room status:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + +router.post("/createRoom", async (req, res) => { + try { + const { userId } = req.body // Get room code & player infos + + // Create room + let newRoom = new Room() + + let player = new Player(userId) + newRoom.join(player) + + console.log("Created room with code", newRoom.getRoomCode()) + + // Generate question sets + newRoom.addQuestionSets(await generateQuestionSets(5)) // TODO: more options + + console.log("Question sets generated", newRoom.questionSets) + + // Response + const updatedQuestionSets = newRoom.questionSets.map(({ correct, ...rest }) => rest); + + res.status(200).json({ + code: newRoom.getRoomCode(), + questionSets: updatedQuestionSets // hide correct answers + }) + } catch(error) { + console.error("Error creating room:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + +router.post("/submitAnswer", async (req, res) => { + try { + const { code, userId, idx, choice, score } = req.body // Room code | userId | questionSet index | choice index + + // Verify answer + let room = Room.getRoom(code) + let questionSet = room.questionSets[idx] // TODO: error handling + + let player = room.players.get(userId) + if (player.answered(idx)) { // duplicate answer + player.pulse() + throw new Error("Duplicate submission") + } else { + let correct = questionSet.isCorrect(choice) + + if (correct) { // correct answer + let player = room.players.get(userId) + player.addScore(score) + console.log("Room %s, player %s add %d score", code, userId, score) + } else { // wrong answer + let player = room.players.get(userId) + player.pulse() + console.log("Room %s, player %s wrong answer", code, userId) + } + + player.updateProgress(idx) // player has answered question up to idx + console.log("Player %s progress updated to %d", userId, player.progress) + + res.status(200).json({ + correct: correct, + score: score + }) + } + + } catch(error) { + console.error("Error verifying answer:", error); + res.status(500).json({ error: "Internal server error." }); + } +}) + router.get("/dailyChallenge", async (req, res) => { try { @@ -42,7 +164,7 @@ router.post("/updateDailyScore", async(req, res) => { // if (user.dailyScore !== -1) { // return res.status(400).json({ error: "Daily challenge already completed." }); - // } + // } user.dailyScore = score; await user.save(); diff --git a/backend/server.js b/backend/server.js index 78e49da..5082b77 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,6 +23,7 @@ const server = http.createServer(app); const db = require('./db.js'); // connect to database const matchModel = require('./models/matchModel.js'); +const { Room } = require('./utility/room.js'); // Routes // --- Spotify access token @@ -48,5 +49,8 @@ cron.schedule("0 0 * * *", async () => { timezone: "America/Los_Angeles", }); +// Room Game Cleanup +Room.periodicCleanup() // by default 60 seconds + // uncomment this and run server to generate new dailychallenge questions //runCronJob(); \ No newline at end of file diff --git a/backend/utility/gameManager.js b/backend/utility/gameManager.js new file mode 100644 index 0000000..3c11537 --- /dev/null +++ b/backend/utility/gameManager.js @@ -0,0 +1,59 @@ +const express = require('express') +require("dotenv").config() + +const DumbProxy = require("../controller/dumbProxy") +// import { SpotifyProxy } from "../controller/spotifyProxy"; +const { QuestionSet } = require("./questionSet") + +// Spotify proxy +const proxy = DumbProxy.getInstance() + +// Generate a question set +const generateQuestionSet = async(genre='pop', choices=4) => { + let tracks = [] + let withPreview = [] + + while (withPreview.length == 0) { + tracks = await proxy.recommendTracks(genre, choices*2) // increase the hit rate of preview_url + withPreview = tracks.filter((track) => track.preview_url) + } + + const correctTrack = withPreview[0] + const incorrectTracks = tracks + .filter((track) => track !== correctTrack) + .sort(() => 0.5 - Math.random()) + .slice(0, choices - 1); + const correct = Math.floor(Math.random() * choices) + const finalTracks = [ + ...incorrectTracks.slice(0, correct), + correctTrack, + ...incorrectTracks.slice(correct) + ] + + let qs = new QuestionSet(finalTracks, correct) // generate question sets + return qs +} + +// Generate question sets +const generateQuestionSets = async(count, genre='pop', choices=4) => { + let qss = [] + + for (let i = 0; i < count; i++) { + qss.push(await generateQuestionSet(genre, choices)) + } + + return qss +} + +const test = async() => { + let proxy = DumbProxy.getInstance() + let qss = await generateQuestionSets(5) + console.log(qss) +} + +// test() + +module.exports = { + generateQuestionSet, + generateQuestionSets +} \ No newline at end of file diff --git a/backend/utility/player.js b/backend/utility/player.js new file mode 100644 index 0000000..fa18aaf --- /dev/null +++ b/backend/utility/player.js @@ -0,0 +1,46 @@ +// Player model used in Rooms +class Player { // player is specific to room + static STATE = { + ACTIVE: 1, + INACTIVE: 0 + } + + constructor(userId) { + this.id = userId // unique identifier + this.score = 0 // starts with 0 score + this.progress = 0 // idx of question set the player has reached + this.state = Player.STATE.ACTIVE // 0 - inactive, 1 - active + this.lastUpdate = Date.now() // last update + } + + // Is the player expired? + isExpired(expiration = 1000 * 60 * 5) { // default to be 5 minutes + return Date.now() > this.lastUpdate + expiration + } + + // Add score + addScore(delta) { + this.score += delta + this.pulse() + } + + // Answer question + updateProgress(idx) { + this.progress = idx + 1 + this.pulse() + } + + // Update the index of question set the player has reached + answered(idx) { + return idx < this.progress + } + + // Pulse - update lastUpdate + pulse() { + this.lastUpdate = Date.now() + } +} + +module.exports = { + Player +} \ No newline at end of file diff --git a/backend/utility/questionSet.js b/backend/utility/questionSet.js new file mode 100644 index 0000000..d262602 --- /dev/null +++ b/backend/utility/questionSet.js @@ -0,0 +1,26 @@ +class QuestionSet { // Class for multiple-choice with one answer + static QID = 0 // unique identifier + + constructor(tracks, correct) { + this.id = QuestionSet.QID++ + + this.options = tracks.filter((track) => { + return { + name: track.name, + artist: track.artists[0].name + } + }) + + this.correct = correct + this.url = tracks[correct].preview_url // url + } + + // If choice is correct + isCorrect(choice) { + return choice == this.correct + } +} + +module.exports = { + QuestionSet +} \ No newline at end of file diff --git a/backend/utility/room.js b/backend/utility/room.js new file mode 100644 index 0000000..fc28223 --- /dev/null +++ b/backend/utility/room.js @@ -0,0 +1,183 @@ +const { RoomModel } = require("../db") +const { Player } = require("./player") + +const GAME_STATES = { + OPEN: "open", + PENDING: "pending", + STARTED: "started", + CLOSED: "closed" +} + +class ExpirationHandler { + static INSTANT_EXPIRATION = 5 * 1000 // 5 seconds, note that this causes error since it is shorter than a game session + static QUICK_EXPIRATION = 5 * 60 * 1000 // 5 minutes + static MID_EXPIRATION = 10 * 60 * 1000 // 10 minutes + static LONG_EXPIRATION = 15 * 60 * 1000 // 15 minutes + + static getExpiration() { + // TODO: conditional + return ExpirationHandler.MID_EXPIRATION + } +} + +const ROOM_CODE_DIGITS = 4 // room code is 4-digits long + +// Room where games are held +// TODO: for now we store rooms in memory, and the total numbers +// of active rooms in any given moment is assumed to be < 10000 +class Room { + static gameId = 0 + + static codePair = {} + + static cleanupTimeoutId = null // storing timeout for periodic cleanup + + constructor() { + this.id = ++Room.gameId // id + this.state = GAME_STATES.OPEN // game is by default open + this.players = new Map() // a map of players + this.questionSets = [] // questionSets + + this.model = new RoomModel() // database model + + this.code = this.generateRoomCode() + Room.codePair[this.code] = this // generate room code & add to the dictionary + + console.log(Room.codePair) + } + + // generate room code, which is a number ROOM_CODE_DIGITS long + generateRoomCode() { + var code = this.id % (10 ** ROOM_CODE_DIGITS) + while (code in Room.codePair) code = (code + 1) % (10 ** ROOM_CODE_DIGITS) + return String(code).padStart(ROOM_CODE_DIGITS, '0') + } + getRoomCode() { + return this.code + } + + // get a room + static getRoom(code) { + return Room.codePair[code] + } + + /* Functionalities */ + join(player) { + console.log("Player %s join room %d", player, this.id) + this.#addPlayer(player) + } + + exit(player) { + this.#removePlayer(player) + } + + // Save this room (results) to database + async saveGameRecords() { + await this.model.saveGameRecords(Array.from(this.players.values())) // save player records + console.log("Room %s records saved.", this.getRoomCode()) + } + + // players + #addPlayer(player) { // Player + this.players.set(player.id, player) + } + + #removePlayer(player) { + this.players.delete(player.id) + } + + /* Switch States */ + open() { + this.state = GAME_STATES.OPEN + } + + start() { + this.state = GAME_STATES.STARTED + } + + stop() { + this.state = GAME_STATES.PENDING + } + + close() { + this.state = GAME_STATES.CLOSED + delete Room.codePair[this.code] // reuse room ID + this.saveGameRecords() + + // TODO: it might be beneficial to clean room ID & save records separately, in bulk + } + + /* Question Sets */ + addQuesitonSet(questionSet) { + this.questionSets.push(questionSet) + } + + addQuestionSets(questionSets) { + this.questionSets.push(...questionSets) + } + + /* Clean-Up */ + // TODO: cleanup methods + cleanup() { + // Update active/inactive + for (let [id, player] of this.players) { + if ((Date.now() - player.lastUpdate) > ExpirationHandler.getExpiration()) { // expires + console.log("Player %s set to inactive.", id) + player.state = Player.STATE.INACTIVE + } + } + + // Scan for inactivity + for (let [id, player] of this.players) { + if (player.state == Player.STATE.ACTIVE) return; + } + console.log("Room %s now closed due to inactivity.", this.code) + + // Close the room since all players are inactive for a while + this.close() + } + + // Static cleanup + // -- period: in seconds + static periodicCleanup(period=60) { + // if (Room.cleanupTimeoutId !== null) { + // return // period cleanup already in progress + // } + + Room.cleanupTimeoutId = setTimeout(() => { + console.log("Room: periodic cleanup (%ds) begins", period) + for (let [code, room] of Object.entries(Room.codePair)) { + room.cleanup() + } + console.log("Room: periodic cleanup (%ds) ends", period) + Room.periodicCleanup(period) + }, period * 1000) + } + + /* Getters */ + get playerCount() { + return this.players.size + } +} + +function test() { + let room1 = new Room() + let room2 = new Room() + let room3 = new Room() + let room4 = new Room() + + console.log(Room.codePair) + room3.close() + + room4.saveGameRecords() + + let room5 = new Room() + + console.log(Room.codePair) +} + +// test() + +module.exports = { + Room +} \ No newline at end of file diff --git a/frontend/src/Game.js b/frontend/src/Game.js index 650bce8..e501793 100644 --- a/frontend/src/Game.js +++ b/frontend/src/Game.js @@ -1,125 +1,107 @@ import React, { useState } from "react"; import "./Game.css"; +import { useLocation } from "react-router-dom"; +import QuestionComponent from "./pages/components/QuestionComponent"; +import TimeoutBar from "./pages/components/TimeoutBar"; const SERVER = process.env.REACT_APP_SERVER; function Game() { + const location = useLocation() + const { state } = location || {} + + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + const [selectedGenre, setSelectedGenre] = useState(""); // state to hold genre const [answerOptions, setAnswerOptions] = useState([]); // state to hold 4 answer options const [correctAnswer, setCorrectAnswer] = useState(""); // state to hold correct answer const [isPlaying, setIsPlaying] = useState(false); // state to hold current round const [audio, setAudio] = useState(null); // state to hold audio player - const [startTime, setStartTime] = useState(null); // state to hold time - const [points, setPoints] = useState(0); // state to hold points - const [round, setRound] = useState(1); + const [points, setPoints] = useState(state.score); // state to hold points + const [idx, setIdx] = useState(state.progress); // state to hold which question we are playing + const [resetTrigger, setResetTrigger] = useState(0) // state to hold timeout reset trigger - // play random previewurl - const playRandomPreview = async () => { - + // Handle answer submission to the server for verification + const handleAnswerSubmission = async(idx, selectedOption, elapsedTime) => { + const score = calculateScore(elapsedTime) // local score try { - const response = await fetch( - `${SERVER}/songModel/recommendations?genres=${selectedGenre}&limit=30` - ); + const response = await fetch(`${SERVER}/game/submitAnswer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: state.code, // room code + userId: userId, // userId + idx: idx, // qSet index + choice: selectedOption, // index of selected option + score: score // local score + }) + }); + + if (!response.ok) { + throw new Error("Failed to submit answer."); + } + const data = await response.json(); + console.log("Server feedback:", data); - // only get tracks with previewurls - const tracksWithPreview = data.tracks.filter((track) => track.preview_url); - - if (tracksWithPreview.length >= 4) { - // pick one random song as correct answer - const correctTrack = tracksWithPreview[Math.floor(Math.random() * tracksWithPreview.length)]; - // get 3 wrong answers - const incorrectTracks = tracksWithPreview - .filter((track) => track !== correctTrack) - .sort(() => 0.5 - Math.random()) - .slice(0, 3); - - // combine and shuffle all 4 answer choices - const options = [correctTrack, ...incorrectTracks].sort(() => 0.5 - Math.random()); - - setAnswerOptions(options); - setCorrectAnswer(`${correctTrack.name} - ${correctTrack.artists[0].name}`); - - // play the correct song - const newAudio = new Audio(correctTrack.preview_url); - newAudio.play(); - setAudio(newAudio); - setIsPlaying(true); - setStartTime(Date.now()); + // Post-verifcation + if (data.correct === true) { // correct answer + setPoints(points + data.score) + console.log("Correct answer!") } else { - alert("Not enough previews available for this genre."); + console.log("Wrong answer!") } + + // Increase current index + setIdx(idx + 1) + + // Reset timeout + setResetTrigger(resetTrigger + 1) } catch (error) { - console.error("Error fetching recommendations:", error); + console.error("Error submitting answers:", error); } - }; + } + // Calculate local score when submitting answer + const calculateScore = (elapsedTime) => { + // exponential decay calculation for points + const timeElapsed = (elapsedTime) / 1000; + const initialPoints = 100; // max possible points + const decayRate = 0.05; // how quickly points decrease over time + const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); - const handleAnswerSelection = (answer) => { - // stop playing song - if (audio) { - audio.pause(); - setAudio(null); - } - setIsPlaying(false); - if (answer === correctAnswer) { - // exponential decay calculation for points - const timeElapsed = (Date.now() - startTime) / 1000; - const initialPoints = 100; // max possible points - const decayRate = 0.05; // how quickly points decrease over time - const earnedPoints = Math.round(initialPoints * Math.exp(-decayRate * timeElapsed)); - setPoints((prevPoints) => prevPoints + earnedPoints); - // NEED TO MODIFY AFTER - alert(`Correct! ${earnedPoints} points.`); - } else { - alert( - `Incorrect. 0 points.` - ); - } - }; + return earnedPoints + } + + // Timeout handler + const handleTimeout = () => { + console.log("Timeout!") + + // Increase current index + setIdx(idx + 1) + + // Reset timeout + setResetTrigger(resetTrigger + 1) + } return (
- - - - - {isPlaying && answerOptions.length > 0 && ( -
-

Guess the Track:

- {answerOptions.map((option, index) => ( - - ))} -
- )} +

Room {state.code}

Total Points: {points}

+ + {idx < state.questionSets.length ? ( + <> + + + + ) : ( +

You have completed all the questions! Congratulations!

+ )}
); diff --git a/frontend/src/PageRouter.js b/frontend/src/PageRouter.js index d67a7c5..c0e6818 100644 --- a/frontend/src/PageRouter.js +++ b/frontend/src/PageRouter.js @@ -7,6 +7,8 @@ import Game from './Game.js'; import DailyChallengePage from './pages/DailyChallenge.js'; import DailyChallengeLeaderboard from './pages/DailyChallengeLeaderboard.js'; import MatchHistory from './pages/MatchHistory.js'; +import CreateJoinRoom from './pages/CreateJoinRoom.js'; +import JoinRoom from './pages/JoinRoom.js'; const PageRouter = () => { @@ -14,7 +16,9 @@ const PageRouter = () => { } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/CreateJoinRoom.js b/frontend/src/pages/CreateJoinRoom.js new file mode 100644 index 0000000..9c45303 --- /dev/null +++ b/frontend/src/pages/CreateJoinRoom.js @@ -0,0 +1,53 @@ +import React, { useState, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; + +const SERVER = process.env.REACT_APP_SERVER; + +const CreateJoinRoom = () => { + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + + const navigate = useNavigate() + + const createRoom = async (e) => { + try { + const response = await fetch(`${SERVER}/game/createRoom`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userId: userId + }) + }); + + if (!response.ok) { + throw new Error("Failed to create new rooms."); + } + + const data = await response.json(); + console.log("Room created:", data); + + navigate("game", {state: { + code: data.code, + questionSets: data.questionSets, + progress: 0, + score: 0 + }}) + } catch (error) { + console.error("Error creating room:", error); + } + }; + + return ( +
+

MuseGuesser

+
+ + Join Room +
+
+ ); +} + +export default CreateJoinRoom; diff --git a/frontend/src/pages/FrontPage.js b/frontend/src/pages/FrontPage.js index dbbc12e..0e68fea 100644 --- a/frontend/src/pages/FrontPage.js +++ b/frontend/src/pages/FrontPage.js @@ -28,12 +28,12 @@ const FrontPage = () => {

MuseGuesser

{!doneDaily && loggedIn ? Daily Challenge : null} - {!loggedIn ?<> + {!loggedIn ?<> Sign In Sign Up - : + : <> - Play Game + Play Game Match History }
diff --git a/frontend/src/pages/JoinRoom.js b/frontend/src/pages/JoinRoom.js new file mode 100644 index 0000000..a8e2042 --- /dev/null +++ b/frontend/src/pages/JoinRoom.js @@ -0,0 +1,120 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +const SERVER = process.env.REACT_APP_SERVER; + +const JoinRoom = () => { + const [roomCode, setRoomCode] = useState(""); + + const userData = localStorage.getItem("userData"); + const { userId } = JSON.parse(userData); + + const navigate = useNavigate() + + // Default join room mechanism + const joinRoom = async (code) => { + try { + const response = await fetch(`${SERVER}/game/joinRoom`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: code, + userId: userId + }) + }); + + if (!response.ok) { + throw new Error("Failed to join room."); + } + + const data = await response.json(); + console.log("Joined room:", data); + + navigate("/room/game", {state: { + code: data.code, + questionSets: data.questionSets, + progress: data.progress, + score: data.score + }}) + } catch (error) { + alert("Failed to join room") + console.error("Error joining room:", error); + } + } + + const handleInputChange = (e) => { + const code = e.target.value; + if (/^\d{0,4}$/.test(code)) { + // Only allow up to 4 digits + setRoomCode(code); + } + }; + + const handleJoinClick = () => { + if (roomCode.length === 4) { + joinRoom(roomCode); // Trigger the join action with the room code + } else { + alert("Please enter a valid 4-digit code."); + } + }; + + return ( +
+

Join a Room

+
+ + +
+ +
+ ); +}; + +const styles = { + container: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100vh", + backgroundColor: "#f5f5f5", + }, + inputContainer: { + margin: "10px 0", + textAlign: "center", + }, + input: { + width: "200px", + padding: "10px", + fontSize: "16px", + borderRadius: "5px", + border: "1px solid #ccc", + textAlign: "center", + marginTop: "5px", + }, + button: { + marginTop: "20px", + padding: "10px 20px", + fontSize: "16px", + borderRadius: "5px", + border: "none", + backgroundColor: "#4caf50", + color: "#fff", + cursor: "pointer", + transition: "background-color 0.3s ease", + }, +}; + +export default JoinRoom; diff --git a/frontend/src/pages/components/QuestionComponent.js b/frontend/src/pages/components/QuestionComponent.js new file mode 100644 index 0000000..229c1f9 --- /dev/null +++ b/frontend/src/pages/components/QuestionComponent.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from "react"; + +const QuestionComponent = ({ idx, questionSet, onSubmit }) => { + const [selectedOption, setSelectedOption] = useState(null); + const [startTime, setStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(null); + + useEffect(() => { + // Record the start time when the component is mounted + setStartTime(Date.now()); + }, [idx, questionSet]); + + const handleOptionClick = (index) => { + console.log("index", index) + setSelectedOption(index); + + // Calculate the time taken and store it + const decisionTime = Date.now() - startTime; + setElapsedTime(decisionTime); + }; + + const handleSubmit = async() => { + if (selectedOption === null) { + alert("Please select an option before submitting."); + return; + } + + // Call the parent callback with the selected option and time taken + await onSubmit(idx, selectedOption, elapsedTime) + }; + + return ( +
+

Question {idx + 1}

+

Choose the correct track based on the preview:

+
    + {questionSet.options.map((option, index) => ( +
  • + +
  • + ))} +
+ +
+ ); +}; + +export default QuestionComponent; diff --git a/frontend/src/pages/components/TimeoutBar.js b/frontend/src/pages/components/TimeoutBar.js index 48188e5..2af35f2 100644 --- a/frontend/src/pages/components/TimeoutBar.js +++ b/frontend/src/pages/components/TimeoutBar.js @@ -3,8 +3,18 @@ import React, { useEffect, useState } from "react"; // A timeout bar used to show the time left to answer a question // totalTime - in seconds // onTimeout - function to trigger on timeout -const TimeoutBar = ({ totalTime, onTimeout }) => { +const TimeoutBar = ({ totalTime, onTimeout, resetTrigger }) => { const [timeLeft, setTimeLeft] = useState(totalTime); + const [disableTransition, setDisableTransition] = useState(false); + + useEffect(() => { + setTimeLeft(totalTime) + + setDisableTransition(true) // So the timebar resets immediately, css-wise + setTimeout(() => { + setDisableTransition(false); + }, 10); + }, [resetTrigger, totalTime]) useEffect(() => { const interval = setInterval(() => { @@ -38,7 +48,7 @@ const TimeoutBar = ({ totalTime, onTimeout }) => { width: `${percentage}%`, height: "100%", background: getColor(), - transition: "width 1s linear, background 1s linear", + transition: disableTransition ? "none" : "width 1s linear, background 1s linear", borderRadius: "5px", }} > diff --git a/frontend/src/pages/styles/QuestionComponent.css b/frontend/src/pages/styles/QuestionComponent.css new file mode 100644 index 0000000..e69de29