diff --git a/backend/app.js b/backend/app.js index f4a6923..bfa1308 100644 --- a/backend/app.js +++ b/backend/app.js @@ -42,6 +42,9 @@ const populateClosedDays = require('./schedule/populateClosedDays'); //Register focus routes app.use('/', require('./endpoints/checkFocus/route')(databaseConnection)); + //Register Quiz routes + app.use('/', require('./endpoints/hpcQuestion/route')(databaseConnection)); + // Register report routes app.use('/', require('./endpoints/report/routes')(databaseConnection)); diff --git a/backend/db/mongodb-schema.json b/backend/db/mongodb-schema.json index e041a3a..a2293f3 100644 --- a/backend/db/mongodb-schema.json +++ b/backend/db/mongodb-schema.json @@ -177,6 +177,47 @@ "validationLevel": "moderate", "validationAction": "error" }, + { + "name": "hpcQuestion", + "validator": { + "$jsonSchema": { + "bsonType": "object", + "required": [ + "_id", + "question", + "options", + "correctAnswerIndex", + "explanation", + "active" + ], + "properties": { + "_id": { + "bsonType": "objectId" + }, + "question": { + "bsonType": "string" + }, + "options": { + "bsonType": "array", + "items": { + "bsonType": "string" + } + }, + "correctAnswerIndex": { + "bsonType": "int" + }, + "explanation": { + "bsonType": "string" + }, + "active": { + "bsonType": "bool" + } + } + } + }, + "validationLevel": "moderate", + "validationAction": "error" + }, { "name": "method", "validator": { diff --git a/backend/endpoints/hpcQuestion/controller/checkAnswer.js b/backend/endpoints/hpcQuestion/controller/checkAnswer.js new file mode 100644 index 0000000..a6b9667 --- /dev/null +++ b/backend/endpoints/hpcQuestion/controller/checkAnswer.js @@ -0,0 +1,112 @@ +const { ObjectId } = require('mongodb'); + +/** + * @param {import('mongodb').Db} db + */ +module.exports = (db) => { + /** + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise} + */ + return async (req, res) => { + try { + const { questionId, selectedAnswerIndex, optionOrder } = req.body || {}; + + if (!questionId) { + return res.status(400).json({ + success: false, + error: 'Missing question id' + }); + } + + const sanitizedQuestionId = String(questionId).trim(); + + if (!ObjectId.isValid(sanitizedQuestionId)) { + return res.status(400).json({ + success: false, + error: 'Invalid question id' + }); + } + + if (!Number.isInteger(selectedAnswerIndex)) { + return res.status(400).json({ + success: false, + error: 'Selected answer index must be an integer' + }); + } + + if (!Array.isArray(optionOrder)) { + return res.status(400).json({ + success: false, + error: 'Option order must be an array' + }); + } + + if (!optionOrder.every((value) => Number.isInteger(value))) { + return res.status(400).json({ + success: false, + error: 'Option order must only contain integers' + }); + } + + const question = await db.collection('hpcQuestion').findOne({ + _id: new ObjectId(sanitizedQuestionId), + active: true + }); + + if (!question) { + return res.status(404).json({ + success: false, + error: 'HPC question does not exist' + }); + } + + if (selectedAnswerIndex < 0 || selectedAnswerIndex >= optionOrder.length) { + return res.status(400).json({ + success: false, + error: 'Selected answer index is out of range' + }); + } + + if (optionOrder.length !== question.options.length) { + return res.status(400).json({ + success: false, + error: 'Option order length does not match question options' + }); + } + + const originalSelectedAnswerIndex = optionOrder[selectedAnswerIndex]; + + if ( + originalSelectedAnswerIndex < 0 || + originalSelectedAnswerIndex >= question.options.length + ) { + return res.status(400).json({ + success: false, + error: 'Original selected answer index is out of range' + }); + } + + const correct = originalSelectedAnswerIndex === question.correctAnswerIndex; + + const correctAnswerDisplayIndex = optionOrder.findIndex( + (originalIndex) => originalIndex === question.correctAnswerIndex + ); + + return res.status(200).json({ + success: true, + body: { + correct, + correctAnswerIndex: correctAnswerDisplayIndex, + explanation: question.explanation + } + }); + } catch (error) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + }; +}; diff --git a/backend/endpoints/hpcQuestion/controller/getRandomQuestion.js b/backend/endpoints/hpcQuestion/controller/getRandomQuestion.js new file mode 100644 index 0000000..dfa34da --- /dev/null +++ b/backend/endpoints/hpcQuestion/controller/getRandomQuestion.js @@ -0,0 +1,61 @@ +/** + * @param {import('mongodb').Db} db + */ +module.exports = (db) => { + /** + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise} + */ + return async (req, res) => { + try { + if (Math.random() >= 0.35) { + return res.status(200).json({ + success: true, + body: null + }); + } + + const [question] = await db + .collection('hpcQuestion') + .aggregate([{ $match: { active: true } }, { $sample: { size: 1 } }]) + .toArray(); + + if (!question) { + return res.status(404).json({ + success: false, + error: 'No active HPC questions found' + }); + } + + const optionOrder = question.options.map((_, index) => index); + + for (let i = optionOrder.length - 1; i > 0; i--) { + const randomIndex = Math.floor(Math.random() * (i + 1)); + const temporaryValue = optionOrder[i]; + + optionOrder[i] = optionOrder[randomIndex]; + optionOrder[randomIndex] = temporaryValue; + } + + const shuffledOptions = optionOrder.map( + (originalIndex) => question.options[originalIndex] + ); + + return res.status(200).json({ + success: true, + body: { + id: question._id.toString(), + question: question.question, + options: shuffledOptions, + optionOrder + } + }); + } catch (error) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + }; +}; diff --git a/backend/endpoints/hpcQuestion/route.js b/backend/endpoints/hpcQuestion/route.js new file mode 100644 index 0000000..937a1f5 --- /dev/null +++ b/backend/endpoints/hpcQuestion/route.js @@ -0,0 +1,8 @@ +/** + * @param {import('mongodb').Db} db + */ +module.exports = (db) => + require('express') + .Router() + .get('/hpc-question/random', require('./controller/getRandomQuestion')(db)) + .post('/hpc-question/check', require('./controller/checkAnswer')(db)); diff --git a/backend/scripts/data/hpcQuestion.js b/backend/scripts/data/hpcQuestion.js new file mode 100644 index 0000000..5fc746b --- /dev/null +++ b/backend/scripts/data/hpcQuestion.js @@ -0,0 +1,407 @@ +const { ObjectId } = require('mongodb'); + +const id = () => new ObjectId(); + +const hpcQuestions = [ + { + _id: id(), + question: 'What does squeue show?', + options: [ + 'Jobs currently known to the scheduler', + 'Available disk space', + 'Loaded software modules', + 'CPU temperature' + ], + correctAnswerIndex: 0, + explanation: + 'squeue shows jobs in the scheduler queue, including jobs that are pending, running, or in other scheduler states.', + active: true + }, + { + _id: id(), + question: 'What is sinfo commonly used for?', + options: [ + 'Checking Slurm partitions and node states', + 'Viewing shell command history', + 'Copying files between servers', + 'Editing files' + ], + correctAnswerIndex: 0, + explanation: + 'sinfo is commonly used to inspect Slurm partitions, node availability, and node states such as idle, allocated, mixed, or down.', + active: true + }, + { + _id: id(), + question: 'What command is commonly used to submit a Slurm job script?', + options: ['sbatch', 'squeue', 'scancel', 'sinfo'], + correctAnswerIndex: 0, + explanation: 'sbatch submits a batch job script to Slurm.', + active: true + }, + { + _id: id(), + question: 'What command is commonly used to view your current Slurm jobs?', + options: ['squeue -u $USER', 'chmod +x $USER', 'module load $USER', 'df -h $USER'], + correctAnswerIndex: 0, + explanation: 'squeue -u $USER shows jobs belonging to the current user.', + active: true + }, + { + _id: id(), + question: 'What does an array job in Slurm allow you to do?', + options: [ + 'Run many similar tasks using one job submission', + 'Store output in an array file format', + 'Load multiple modules at the same time', + 'Split one CPU core into multiple virtual cores' + ], + correctAnswerIndex: 0, + explanation: + 'A Slurm array job lets you run many similar tasks from one submission, each with its own array task ID.', + active: true + }, + { + _id: id(), + question: 'What does %j commonly mean in a Slurm output filename?', + options: ['The Slurm job ID', 'The job name', 'The username', 'The node name'], + correctAnswerIndex: 0, + explanation: 'In Slurm output filename patterns, %j is commonly replaced with the job ID.', + active: true + }, + { + _id: id(), + question: 'What does module list usually show?', + options: [ + 'Software modules currently loaded in your environment', + 'All jobs currently running on the cluster', + 'All files in the current directory', + 'All available Slurm partitions' + ], + correctAnswerIndex: 0, + explanation: + 'module list shows which software modules are currently loaded in your shell environment.', + active: true + }, + { + _id: id(), + question: 'What does module purge usually do?', + options: [ + 'Removes loaded modules from the current environment', + 'Deletes job output files', + 'Cancels every pending job', + 'Clears disk quota usage' + ], + correctAnswerIndex: 0, + explanation: + 'module purge unloads currently loaded modules, giving you a cleaner environment.', + active: true + }, + { + _id: id(), + question: 'What does ls -la usually show?', + options: [ + 'A detailed listing of files, including hidden files', + 'Only running Slurm jobs', + 'Only available modules', + 'The current memory usage' + ], + correctAnswerIndex: 0, + explanation: + 'ls -la lists files in long format and includes hidden files that begin with a dot.', + active: true + }, + { + _id: id(), + question: 'What does grep usually help with?', + options: [ + 'Searching text for matching words or patterns', + 'Submitting a batch job', + 'Changing a file owner', + 'Showing all partitions' + ], + correctAnswerIndex: 0, + explanation: + 'grep searches text for matching words or patterns, which is useful when checking logs.', + active: true + }, + { + _id: id(), + question: 'What does tail -f usually do?', + options: [ + 'Shows new lines added to a file as they appear', + 'Deletes the end of a file', + 'Shows only hidden files', + 'Follows a Slurm partition' + ], + correctAnswerIndex: 0, + explanation: + 'tail -f follows a file and displays new lines as they are written, which is useful for watching logs.', + active: true + }, + { + _id: id(), + question: 'What does cp source.txt copy.txt do?', + options: [ + 'Copies source.txt to copy.txt', + 'Moves source.txt into copy.txt', + 'Deletes source.txt after creating copy.txt', + 'Compares the two files' + ], + correctAnswerIndex: 0, + explanation: + 'cp copies files or directories. In this example it creates copy.txt from source.txt.', + active: true + }, + { + _id: id(), + question: 'What does less file.txt help you do?', + options: [ + 'View a file page by page', + 'Make the file smaller', + 'Delete lines from the file', + 'Submit the file as a job' + ], + correctAnswerIndex: 0, + explanation: + 'less lets you view text files page by page without loading everything into the terminal at once.', + active: true + }, + { + _id: id(), + question: 'What does df -h help check?', + options: [ + 'Filesystem disk usage in a human-readable format', + 'Loaded software modules', + 'Current job queue', + 'File permissions' + ], + correctAnswerIndex: 0, + explanation: + 'df -h shows filesystem disk usage in human-readable units, which helps identify full or nearly full filesystems.', + active: true + }, + { + _id: id(), + question: 'In JavaScript, what does === check?', + options: [ + 'Strict equality without type coercion', + 'Assignment of a new value', + 'Greater than or equal comparison', + 'Loose equality with type conversion' + ], + correctAnswerIndex: 0, + explanation: + '=== checks strict equality. It compares both value and type without converting between types.', + active: true + }, + { + _id: id(), + question: 'What does JSON.stringify do?', + options: [ + 'Converts a JavaScript value into a JSON string', + 'Converts JSON text into a JavaScript object', + 'Deletes empty JSON fields', + 'Checks if JSON is valid without changing it' + ], + correctAnswerIndex: 0, + explanation: + 'JSON.stringify converts JavaScript values such as objects or arrays into JSON text.', + active: true + }, + { + _id: id(), + question: 'What does JSON.parse do?', + options: [ + 'Converts JSON text into a JavaScript value', + 'Converts an object into JSON text', + 'Deletes JSON from memory', + 'Uploads JSON to a server' + ], + correctAnswerIndex: 0, + explanation: + 'JSON.parse converts valid JSON text into a JavaScript value such as an object or array.', + active: true + }, + { + _id: id(), + question: 'In Express, what does res.status(404) usually mean?', + options: [ + 'The requested resource was not found', + 'The request succeeded', + 'The server crashed', + 'The user is not allowed to access the route' + ], + correctAnswerIndex: 0, + explanation: + 'HTTP 404 means Not Found. It is used when the requested resource or route cannot be found.', + active: true + }, + { + _id: id(), + question: 'In Express, what does res.status(500) usually mean?', + options: [ + 'An internal server error occurred', + 'The client sent no body', + 'The request succeeded', + 'The user cancelled the request' + ], + correctAnswerIndex: 0, + explanation: + 'HTTP 500 means Internal Server Error. It usually means something went wrong on the server.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does .each commonly do?', + options: [ + 'Iterates over each item in a collection', + 'Deletes each item in a collection', + 'Sorts a collection automatically', + 'Converts a collection into a string' + ], + correctAnswerIndex: 0, + explanation: '.each loops through each item in a collection such as an array or hash.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does .map usually return?', + options: [ + 'A new array containing the transformed values', + 'The original array unchanged every time', + 'Only the first matching value', + 'A boolean true or false' + ], + correctAnswerIndex: 0, + explanation: + '.map transforms each item in a collection and returns a new array with the results.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does .select usually return?', + options: [ + 'Items that match the condition', + 'The first item only', + 'The number of items in an array', + 'The original string split into words' + ], + correctAnswerIndex: 0, + explanation: '.select returns the items for which the block condition is truthy.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does .include? check?', + options: [ + 'Whether a collection or string contains a value', + 'Whether a method has been defined privately', + 'Whether a file exists on disk', + 'Whether a class has been inherited' + ], + correctAnswerIndex: 0, + explanation: + '.include? checks whether a value exists inside a collection or string and returns true or false.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does split usually do to a string?', + options: [ + 'Breaks the string into an array of smaller strings', + 'Joins an array into one string', + 'Deletes the string', + 'Converts the string into an integer automatically' + ], + correctAnswerIndex: 0, + explanation: + 'split breaks a string into an array, usually using whitespace or a provided separator.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does join usually do?', + options: [ + 'Combines array elements into one string', + 'Splits a string into an array', + 'Starts a new thread', + 'Connects to a database automatically' + ], + correctAnswerIndex: 0, + explanation: + 'join combines array elements into a single string, optionally using a separator.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does chomp usually remove?', + options: [ + 'A trailing newline from a string', + 'All spaces from a string', + 'The first character from a string', + 'All numbers from a string' + ], + correctAnswerIndex: 0, + explanation: + 'chomp commonly removes a trailing newline from a string, which is useful when reading lines from files.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does File.readlines usually return?', + options: [ + 'An array containing the lines of a file', + 'A single boolean showing whether the file exists', + 'The filename without its extension', + 'The file permissions only' + ], + correctAnswerIndex: 0, + explanation: 'File.readlines reads a file and returns its lines as an array.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what is a symbol such as :name commonly used for?', + options: [ + 'A lightweight identifier often used as a hash key', + 'A string that automatically changes value', + 'A hidden password field', + 'A special type of integer' + ], + correctAnswerIndex: 0, + explanation: + 'Symbols are lightweight identifiers. They are commonly used as hash keys and method names.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does attr_reader create?', + options: [ + 'Getter methods for instance variables', + 'Setter methods only', + 'A new class automatically', + 'A private constant' + ], + correctAnswerIndex: 0, + explanation: + 'attr_reader creates getter methods so instance variables can be read from outside the object.', + active: true + }, + { + _id: id(), + question: 'In Ruby, what does a hash store?', + options: [ + 'Key-value pairs', + 'Only ordered numbers', + 'Only one string value', + 'Only method definitions' + ], + correctAnswerIndex: 0, + explanation: + 'A Ruby hash stores key-value pairs, similar to a dictionary or object map in other languages.', + active: true + } +]; + +module.exports = hpcQuestions; diff --git a/backend/scripts/testData.js b/backend/scripts/testData.js index 5f24ae6..c8be15a 100644 --- a/backend/scripts/testData.js +++ b/backend/scripts/testData.js @@ -1,4 +1,5 @@ const { ObjectId } = require('mongodb'); +const hpcQuestions = require('./data/hpcQuestion'); module.exports.seedData = async (db) => { const personCol = db.collection('person'); @@ -10,6 +11,8 @@ module.exports.seedData = async (db) => { const methodCol = db.collection('method'); const bonusChallengeCol = db.collection('bonusChallenge'); + const hpcQuestionCol = db.collection('hpcQuestion'); + const checkFocusCol = db.collection('checkFocus'); // ----------------------------- @@ -230,6 +233,11 @@ module.exports.seedData = async (db) => { await bonusChallengeCol.insertMany(bonusChallenges); + // ----------------------------- + // HPC QUICK CHECK QUESTIONS + // ----------------------------- + await hpcQuestionCol.insertMany(hpcQuestions); + // ----------------------------- // CHECKING OPERATIONAL FOCUS OF THE CHECK // E.G. GRUMPY OLD RESEARCHER diff --git a/frontend/app/components/Form.jsx b/frontend/app/components/Form.jsx index 6512dc2..c40f84e 100644 --- a/frontend/app/components/Form.jsx +++ b/frontend/app/components/Form.jsx @@ -98,6 +98,10 @@ export default function Form() { const [checkFocus, setCheckFocus] = useState(null); const [focusReflection, setFocusReflection] = useState(''); + const [hpcQuestion, setHpcQuestion] = useState(null); + const [selectedAnswerIndex, setSelectedAnswerIndex] = useState(null); + const [hpcQuestionResult, setHpcQuestionResult] = useState(null); + //METHOD-HIDING SETTINGS const [hiddenMethodIds, setHiddenMethodIds] = useState([]); const [revealedMethodIds, setRevealedMethodIds] = useState([]); @@ -288,6 +292,56 @@ export default function Form() { getBonusChallenge(); }, []); + useEffect(() => { + async function getHpcQuestion() { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/hpc-question/random`); + const data = await res.json(); + + if (!data.success) { + setHpcQuestion(null); + return; + } + + setHpcQuestion(data.body); + } catch (error) { + console.error('Failed to fetch HPC question', error); + setHpcQuestion(null); + } + } + getHpcQuestion(); + }, []); + + async function checkHpcAnswer(answerIndex) { + if (!hpcQuestion || hpcQuestionResult) return; + + try { + setSelectedAnswerIndex(answerIndex); + + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/hpc-question/check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + questionId: hpcQuestion.id, + selectedAnswerIndex: answerIndex, + optionOrder: hpcQuestion.optionOrder + }) + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || 'Failed to check answer'); + } + + setHpcQuestionResult(data.body); + } catch (error) { + console.error('Failed to check HPC answer:', error); + } + } + useEffect(() => { async function getCheckFocus() { try { @@ -509,7 +563,7 @@ export default function Form() {

- {/* Focus*/} + {/* Focus */} {checkFocus && (

@@ -527,89 +581,95 @@ export default function Form() { {steps.length == 0 && (

No instruction available

)} + {steps.map((step, index) => { const isCompleted = Boolean(completedSteps[step.id]); - const isEditing = editingStepID === step.id; const isAddingMethod = addMethodStepID === step.id; + const shouldShowQuiz = + hpcQuestion && index === Math.floor(steps.length / 2) - 1; return ( -
-
- - {index + 1} - - -

{step.title}

- {step.expectedTime && ( - - {step.expectedTime} +
+
+
+ + {index + 1} - )} -
- -

{step.description}

- -
- - View Methods - - -
    - {(step.methods || []).map((method, i) => { - const isMethodHidden = hiddenMethodIds.includes(method.id); - const isMethodRevealed = revealedMethodIds.includes(method.id); - return ( -
  • - {i > 0 &&
    } - - {editingMethodId === method.id ? ( -
    -