From 9685f406645f7069a822ce7d53dcbe833a4a7c98 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 13:40:56 -0500 Subject: [PATCH 01/48] Add interviewer question templates and answer keys --- app.html | 30 ++- scripts/firepad.js | 53 ++++- scripts/interview-templates.js | 344 +++++++++++++++++++++++++++++++++ styles/main.css | 33 ++++ 4 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 scripts/interview-templates.js diff --git a/app.html b/app.html index c4dba1f..bb1d843 100644 --- a/app.html +++ b/app.html @@ -557,6 +557,21 @@

Sessions Management

+ + + - + @@ -731,7 +731,7 @@

- + diff --git a/scripts/interview-templates.js b/scripts/interview-templates.js index b711317..2160043 100644 --- a/scripts/interview-templates.js +++ b/scripts/interview-templates.js @@ -52,43 +52,93 @@ Do not penalize formatting or whether they use foreach, for, or LINQ.` }, 'csharp-rest-api': { - title: 'C# REST API Basics', + title: 'C# Public REST API Test', language: 'csharp', content: `// 5-minute REST API question // -// Complete GetPolicyAsync. -// It should GET "api/policies/42", reject an unsuccessful response, -// and return the response body as text. +// We are using JSONPlaceholder, a free API made for testing. +// +// Documentation: +// https://jsonplaceholder.typicode.com/ +// +// Request: +// GET https://jsonplaceholder.typicode.com/todos/1 +// +// Expected response shape: +// { +// "userId": 1, +// "id": 1, +// "title": "delectus aut autem", +// "completed": false +// } +// +// Task: Complete GetTodoAsync using exactly these steps: +// 1. Send a GET request to "todos/1". +// 2. Throw an error if the response is not successful. +// 3. Read and return the response body as text. +// +// Before coding, open this URL in a browser to confirm the API is available: +// https://jsonplaceholder.typicode.com/todos/1 +// +// The interview editor's hosted C# runner might not allow outbound HTTP. +// Grade the method itself and ask the candidate to explain each line. +using System; using System.Net.Http; using System.Threading.Tasks; -public class PolicyClient +public class TodoClient { private readonly HttpClient _client; - public PolicyClient(HttpClient client) + public TodoClient(HttpClient client) { _client = client; } - public async Task GetPolicyAsync() + public async Task GetTodoAsync() { // Write 3 lines here. return ""; } +} + +public class Program +{ + public static async Task Main() + { + using var httpClient = new HttpClient + { + BaseAddress = new Uri("https://jsonplaceholder.typicode.com/") + }; + + var todoClient = new TodoClient(httpClient); + string json = await todoClient.GetTodoAsync(); + Console.WriteLine(json); + } }`, answerKey: `PLAIN-ENGLISH ANSWER Use the provided HttpClient to send a GET request. Check that the server returned success. Then read and return the response text. ONE GOOD SOLUTION -var response = await _client.GetAsync("api/policies/42"); +var response = await _client.GetAsync("todos/1"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); +WHAT EACH LINE DOES +1. GetAsync sends an HTTP GET and waits for the real server response. +2. EnsureSuccessStatusCode throws if the server returns 4xx or 5xx. +3. ReadAsStringAsync reads the JSON response body into a string. + +EXPECTED RESULT +Opening the endpoint in a browser should show JSON containing: +- "id": 1 +- "title": "delectus aut autem" +- "completed": false + HOW TO GRADE (0–3) -3 β€” Uses await, the injected _client, checks success, and returns the body. +3 β€” Writes all three lines correctly and can explain request, status, and body. 2 β€” Gets and returns the response but misses the status check or needs a hint. 1 β€” Knows an HTTP GET is needed but cannot form the method. 0 β€” Creates unrelated code or cannot explain request versus response. @@ -96,7 +146,8 @@ HOW TO GRADE (0–3) BONUS DISCUSSION (do not require) - API keys belong in configuration, not source code. - Production code should accept a CancellationToken. -- JSON would normally be deserialized into a C# model.` +- JSON would normally be deserialized into a C# Todo model. +- HttpClient should usually be injected rather than recreated per request.` }, 'aspnet-mvc': { From e18566577952bd19a2e32a5b60fd0cdaff768e2f Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 13:50:15 -0500 Subject: [PATCH 05/48] Make activity card draggable and refresh panel styles --- app.html | 2 +- scripts/activity-monitor.js | 53 +++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app.html b/app.html index 3b9fa44..f9458cd 100644 --- a/app.html +++ b/app.html @@ -738,7 +738,7 @@

- + diff --git a/scripts/activity-monitor.js b/scripts/activity-monitor.js index 1de1817..f5a20aa 100644 --- a/scripts/activity-monitor.js +++ b/scripts/activity-monitor.js @@ -551,6 +551,55 @@ box-shadow: 0 4px 12px rgba(0,0,0,0.3); `; document.body.appendChild(indicator); + + const savedPosition = sessionStorage.getItem('activity-indicator-position'); + if (savedPosition) { + try { + const position = JSON.parse(savedPosition); + indicator.style.left = `${position.left}px`; + indicator.style.top = `${position.top}px`; + indicator.style.bottom = 'auto'; + } catch (error) { + sessionStorage.removeItem('activity-indicator-position'); + } + } + + let dragState = null; + indicator.addEventListener('pointerdown', function(event) { + if (!event.target.closest('.activity-drag-handle')) return; + + const rect = indicator.getBoundingClientRect(); + dragState = { + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top + }; + indicator.setPointerCapture(event.pointerId); + indicator.style.left = `${rect.left}px`; + indicator.style.top = `${rect.top}px`; + indicator.style.bottom = 'auto'; + event.preventDefault(); + }); + + indicator.addEventListener('pointermove', function(event) { + if (!dragState) return; + + const maxLeft = Math.max(0, window.innerWidth - indicator.offsetWidth); + const maxTop = Math.max(0, window.innerHeight - indicator.offsetHeight); + const left = Math.min(maxLeft, Math.max(0, event.clientX - dragState.offsetX)); + const top = Math.min(maxTop, Math.max(0, event.clientY - dragState.offsetY)); + indicator.style.left = `${left}px`; + indicator.style.top = `${top}px`; + }); + + indicator.addEventListener('pointerup', function(event) { + if (!dragState) return; + dragState = null; + indicator.releasePointerCapture(event.pointerId); + sessionStorage.setItem('activity-indicator-position', JSON.stringify({ + left: parseFloat(indicator.style.left) || 0, + top: parseFloat(indicator.style.top) || 0 + })); + }); } // Color based on activity score @@ -558,7 +607,7 @@ summary.activityScore > 60 ? '#ff9800' : '#ff4444'; indicator.innerHTML = ` -
+
πŸ“Š Candidate Activity
@@ -626,4 +675,4 @@ console.error('Error details:', err.message, err.code); }); }; -})(); \ No newline at end of file +})(); From a71668f31275ccaec563024b0b828331274db241 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 13:59:13 -0500 Subject: [PATCH 06/48] Expand MVC validation interview exercise --- app.html | 2 +- scripts/interview-templates.js | 73 ++++++++++++++++++++++++++++------ 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/app.html b/app.html index f9458cd..39a37b1 100644 --- a/app.html +++ b/app.html @@ -731,7 +731,7 @@

- + diff --git a/scripts/interview-templates.js b/scripts/interview-templates.js index 2160043..9794b3e 100644 --- a/scripts/interview-templates.js +++ b/scripts/interview-templates.js @@ -151,26 +151,64 @@ BONUS DISCUSSION (do not require) }, 'aspnet-mvc': { - title: 'ASP.NET MVC Validation', + title: 'ASP.NET MVC β€” Form Validation', language: 'csharp', content: `// 5-minute ASP.NET MVC question // -// If the form is invalid, show the same view again. -// If it is valid, save it and go to the Index page. +// The Razor page below submits a Policy Number to this controller. +// A browser's "required" check can be bypassed, so the controller must +// also reject a blank PolicyNumber. +// +// Complete the two TODOs in the controller: +// 1. Add a field-specific validation error when PolicyNumber is blank. +// 2. If validation failed, show the form again with the entered values. + +// Create.cshtml (frontend) +// ----------------------- +// @model PolicyViewModel +// +//
+// +// +// +// +//
+ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +public class PolicyViewModel +{ + public string PolicyNumber { get; set; } = ""; +} [HttpPost] +[ValidateAntiForgeryToken] public async Task Create(PolicyViewModel model) { - // Add the missing validation check here. + // TODO 1: If PolicyNumber is blank, add this message to ModelState: + // "Policy number is required." + + // TODO 2: If ModelState is invalid, return View(model). await _policyService.CreateAsync(model); return RedirectToAction("Index"); }`, answerKey: `PLAIN-ENGLISH ANSWER -Before saving, ask MVC whether validation failed. If it did, return the same -view with the entered model so the user can correct it. +The HTML "required" attribute helps the user, but it is not security: a request +can skip the browser. The controller checks PolicyNumber again. It adds an +error for that field, then returns the same view and model when validation +failed. The displays the message. ONE GOOD SOLUTION +if (string.IsNullOrWhiteSpace(model.PolicyNumber)) +{ + ModelState.AddModelError( + nameof(model.PolicyNumber), + "Policy number is required."); +} + if (!ModelState.IsValid) { return View(model); @@ -179,13 +217,22 @@ if (!ModelState.IsValid) await _policyService.CreateAsync(model); return RedirectToAction("Index"); -HOW TO GRADE (0–3) -3 β€” Correct ModelState check, returns View(model), saves only when valid. -2 β€” Correct idea with a small syntax mistake or returns View() without model. -1 β€” Says validation must happen but does not know ModelState. -0 β€” Always saves invalid input or cannot explain the two paths. - -BONUS: Redirecting after success prevents accidental duplicate form posts.` +HOW TO GRADE (0-3) +3 β€” Adds a field-specific ModelState error, returns View(model) when invalid, + and saves only valid input. +2 β€” Correct server-side check and invalid return, with a small syntax mistake + or a non-field-specific error. +1 β€” Understands that the server must validate, but cannot implement both TODOs. +0 β€” Relies only on HTML "required" or still saves a blank PolicyNumber. + +WHAT TO LISTEN FOR +- The browser check improves usability; server validation protects the app. +- Returning View(model) preserves what the user typed and shows the error. +- Redirecting after success helps prevent an accidental duplicate form post. + +ALTERNATIVE +A [Required] attribute on PolicyNumber is also a good production approach. +If the candidate uses it correctly and still checks ModelState, give full credit.` }, 'sql-policy-query': { From 58fe8460a5ec5008efee5d9daf321a138e728481 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:01:13 -0500 Subject: [PATCH 07/48] Add sample data to SQL interview exercise --- app.html | 2 +- scripts/interview-templates.js | 36 +++++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app.html b/app.html index 39a37b1..3e1039f 100644 --- a/app.html +++ b/app.html @@ -731,7 +731,7 @@

- + diff --git a/scripts/interview-templates.js b/scripts/interview-templates.js index 9794b3e..9721d43 100644 --- a/scripts/interview-templates.js +++ b/scripts/interview-templates.js @@ -240,10 +240,40 @@ If the candidate uses it correctly and still checks ModelState, give full credit language: 'sql', content: `-- 5-minute SQL Server question -- --- Customers(CustomerId, Name) --- Policies(PolicyId, CustomerId, PolicyNumber, Status) +-- TABLES -- --- Return the customer name and policy number for Active policies. +-- Customers +-- +------------+---------------+ +-- | CustomerId | Name | +-- +------------+---------------+ +-- | 1 | Alice Johnson | +-- | 2 | Bob Smith | +-- | 3 | Carla Gomez | +-- +------------+---------------+ +-- +-- Policies +-- +----------+------------+--------------+-----------+ +-- | PolicyId | CustomerId | PolicyNumber | Status | +-- +----------+------------+--------------+-----------+ +-- | 101 | 1 | AUTO-1001 | Active | +-- | 102 | 1 | HOME-2001 | Cancelled | +-- | 103 | 2 | AUTO-1002 | Active | +-- | 104 | 3 | LIFE-3001 | Pending | +-- +----------+------------+--------------+-----------+ +-- +-- CustomerId connects each policy to its customer. For example, +-- policy AUTO-1001 has CustomerId 1, so it belongs to Alice Johnson. +-- +-- TASK +-- Return the customer name and policy number for Active policies only. +-- +-- EXPECTED RESULT +-- +---------------+--------------+ +-- | Name | PolicyNumber | +-- +---------------+--------------+ +-- | Alice Johnson | AUTO-1001 | +-- | Bob Smith | AUTO-1002 | +-- +---------------+--------------+ SELECT -- Write the two columns here From 7dc8335f32fd9d995cb9a63cfd4f1d68839fd0a3 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:03:33 -0500 Subject: [PATCH 08/48] Document LINQ employee exercise data --- app.html | 2 +- scripts/interview-templates.js | 39 ++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/app.html b/app.html index 3e1039f..da89570 100644 --- a/app.html +++ b/app.html @@ -731,7 +731,7 @@

- + diff --git a/scripts/interview-templates.js b/scripts/interview-templates.js index 9721d43..2e7e3e9 100644 --- a/scripts/interview-templates.js +++ b/scripts/interview-templates.js @@ -308,17 +308,52 @@ Important: INNER JOIN or JOIN are both correct here.` language: 'csharp', content: `// 5-minute LINQ question // +// Each Employee object has these fields: +// - Id: the employee's unique number +// - Name: the employee's full name +// - Department: the team where the employee works +// - IsActive: true if the employee currently works for the company +// +// SAMPLE DATA +// Id | Name | Department | IsActive +// 1 | Carla Gomez | Claims | true +// 2 | Bob Smith | Sales | false +// 3 | Alice Johnson | Claims | true +// // Complete the query so it returns the names of active employees, // sorted alphabetically. +// +// EXPECTED RESULT +// Alice Johnson +// Carla Gomez + +using System.Collections.Generic; +using System.Linq; + +var employees = new List +{ + new Employee { Id = 1, Name = "Carla Gomez", Department = "Claims", IsActive = true }, + new Employee { Id = 2, Name = "Bob Smith", Department = "Sales", IsActive = false }, + new Employee { Id = 3, Name = "Alice Johnson", Department = "Claims", IsActive = true } +}; var names = employees // Filter IsActive == true // Sort by Name // Select Name - .ToList();`, + .ToList(); + +public class Employee +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Department { get; set; } = ""; + public bool IsActive { get; set; } +}`, answerKey: `PLAIN-ENGLISH ANSWER Filter the list to active employees, sort those employees by name, and then -select just each employee's name. +select just each employee's name. Bob is removed because IsActive is false. +Alice appears before Carla because OrderBy sorts the names alphabetically. ONE GOOD SOLUTION var names = employees From 2e0565081b2f5391c55cc055d3895af234faca53 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:04:47 -0500 Subject: [PATCH 09/48] Explain SOLID in interviewer answer key --- app.html | 2 +- scripts/interview-templates.js | 51 +++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/app.html b/app.html index da89570..4f2ced5 100644 --- a/app.html +++ b/app.html @@ -731,7 +731,7 @@

- + diff --git a/scripts/interview-templates.js b/scripts/interview-templates.js index 2e7e3e9..d0c2b91 100644 --- a/scripts/interview-templates.js +++ b/scripts/interview-templates.js @@ -397,9 +397,34 @@ public class PolicyService // Responsibility 2: // Interface 1: // Interface 2:`, - answerKey: `PLAIN-ENGLISH ANSWER -The class has two jobs: storing policy data and sending email. Separating them -makes each part easier to change and test. + answerKey: `WHAT IS SOLID? +SOLID is a set of five guidelines for organizing object-oriented code so it is +easier to change, test, and maintain: + +S β€” Single Responsibility: + A class should have one main job or one reason to change. +O β€” Open/Closed: + Add new behavior without repeatedly rewriting stable existing code. +L β€” Liskov Substitution: + A replacement implementation should work wherever its interface is expected. +I β€” Interface Segregation: + Prefer small, focused interfaces over one large interface with unrelated jobs. +D β€” Dependency Inversion: + Business code should depend on interfaces, not directly on database or email + implementations. + +Do not expect the candidate to recite all five definitions. This exercise mainly +tests the S: Single Responsibility Principle. + +WHAT IS WRONG WITH THIS CLASS? +PolicyService has two unrelated jobs: +1. Storing policy data in SQL Server. +2. Sending an email notification. + +It therefore has two reasons to change. A database change and an email-provider +change would both require editing the same class. Separating those jobs also +makes the code easier to test: a test can substitute fake implementations +instead of connecting to a real database or sending a real email. ONE GOOD ANSWER // Responsibility 1: Save/read policy data. @@ -407,13 +432,31 @@ ONE GOOD ANSWER // Interface 1: IPolicyRepository // Interface 2: IEmailService (or INotificationService) +WHAT "INJECT THE INTERFACES" MEANS +The class receives the two helpers, usually through its constructor, instead of +creating a SQL connection or email sender itself: + +public PolicyService( + IPolicyRepository policies, + IEmailService email) +{ + _policies = policies; + _email = email; +} + +CreatePolicy would then call _policies.Save(policy) and +_email.SendConfirmation(policy.CustomerEmail). The exact names do not matter. + HOW TO GRADE (0–3) 3 β€” Clearly identifies both jobs and proposes two sensible interfaces. 2 β€” Identifies both jobs but names only one interface or needs a hint. 1 β€” Says "too much in one class" without identifying the two jobs. 0 β€” Cannot see any reason to separate database and email work. -Do not require memorized SOLID definitions. Practical reasoning is better.` +INTERVIEWER TIP +Give full credit for names such as IPolicyDataService, IRepository, +INotificationService, or IMailer when their intended jobs are clear. Practical +reasoning matters more than memorizing the acronym.` }, 'csharp-debugging': { From 9b2a02282fd366ebfc521563e7b8ba50d02a93a8 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:06:27 -0500 Subject: [PATCH 10/48] Replace debugging prompt with easy running sum --- app.html | 4 +- scripts/interview-templates.js | 68 ++++++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/app.html b/app.html index 4f2ced5..6fdcdd1 100644 --- a/app.html +++ b/app.html @@ -566,7 +566,7 @@

Sessions Management

- + +`, + answerKey: `PLAIN-ENGLISH ANSWER +width: 100% lets the card shrink to fit its container. max-width: 400px stops +it from becoming wider than 400px on a large screen. Padding adds space inside +the border. The media query changes the button only on small screens. + +ONE GOOD SOLUTION +.policy-card { + width: 100%; + max-width: 400px; + padding: 16px; + border: 1px solid gray; + border-radius: 8px; + box-sizing: border-box; +} + +.details-button { + width: auto; +} + +@media (max-width: 480px) { + .details-button { + width: 100%; + } +} + +WHY BOX-SIZING HELPS +With box-sizing: border-box, the declared width includes the padding and border. +Without it, a width: 100% element can become slightly wider than its container. +Treat this as a useful bonus, not a requirement unless overflow occurs. + +HOW TO GRADE (0-3) +3 β€” Adds responsive width/max-width, padding, border, rounded corners, and the + mobile button rule. +2 β€” Completes most changes but misses one detail or makes a minor syntax error. +1 β€” Can add basic padding or border but cannot make the card responsive. +0 β€” Does not know which CSS properties control these visible changes. + +ACCEPTABLE VARIATIONS +- Colors such as #999, #ccc, or another reasonable gray are fine. +- A close breakpoint such as 500px is fine if the candidate explains it. +- Equivalent selectors and reasonable pixel/rem values are fine. + +OPTIONAL FOLLOW-UP +Ask the candidate to explain the difference between margin and padding: +padding is inside the element's border; margin is outside the border.` + }, + 'dotnet-interview-plan': { title: 'Your Junior .NET Interview', language: 'markdown', content: `# Welcome to your junior .NET interview -You will work through nine short exercises. Each one is intended to take about +You will work through ten short exercises. Each one is intended to take about five minutes. The goal is to understand how you approach a problemβ€”not to test whether you have memorized every piece of syntax. @@ -708,6 +792,7 @@ whether you have memorized every piece of syntax. 7. C# β€” REST API Basics 8. SOLID β€” Single Responsibility 9. AI β€” Prompt a Coding Assistant +10. HTML/CSS β€” Responsive Policy Card ## How to approach each exercise @@ -723,11 +808,11 @@ reasoning, honest communication, and learning from a hint are all valuable.`, answerKey: `OVERALL GRADING Each exercise has a 0–3 score in its own Answer Key. -Suggested total for 9 questions: 27 points -24–27 β€” Strong junior performance -18–23 β€” Reasonable junior performance; discuss weak areas -11–17 β€” Significant gaps; consider experience claims carefully -0–10 β€” Fundamentals were not demonstrated +Suggested total for 10 questions: 30 points +27–30 β€” Strong junior performance +20–26 β€” Reasonable junior performance; discuss weak areas +12–19 β€” Significant gaps; consider experience claims carefully +0–11 β€” Fundamentals were not demonstrated This is only a guide. Communication, honesty, response to hints, and ability to explain their own resume should influence the final decision. From ec7628b61e2eb95a04a3dca405c0f07126319a8c Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:18:58 -0500 Subject: [PATCH 15/48] Protect interview templates behind admin auth --- .../interview/templates-data.js | 8 ++--- api/interview/templates.js | 34 +++++++++++++++++++ app.html | 16 ++------- scripts/activity-monitor.js | 5 +-- scripts/firepad.js | 31 ++++++++++++++++- 5 files changed, 73 insertions(+), 21 deletions(-) rename scripts/interview-templates.js => api/interview/templates-data.js (99%) create mode 100644 api/interview/templates.js diff --git a/scripts/interview-templates.js b/api/interview/templates-data.js similarity index 99% rename from scripts/interview-templates.js rename to api/interview/templates-data.js index a2444b8..5ff760f 100644 --- a/scripts/interview-templates.js +++ b/api/interview/templates-data.js @@ -1,5 +1,6 @@ -(function() { - window.InterviewTemplates = { +'use strict'; + +module.exports = { 'csharp-warmup': { title: 'C# Very Simple Warm-up', language: 'csharp', @@ -829,5 +830,4 @@ Warning signs: - Claims the editor is the problem before reading simple errors. - Resume examples cannot be described in practical detail.` } - }; -})(); +}; diff --git a/api/interview/templates.js b/api/interview/templates.js new file mode 100644 index 0000000..e88ab89 --- /dev/null +++ b/api/interview/templates.js @@ -0,0 +1,34 @@ +'use strict'; + +const jwt = require('jsonwebtoken'); +const templates = require('./templates-data'); + +module.exports = async function handler(req, res) { + res.setHeader('Cache-Control', 'private, no-store'); + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret) { + console.error('Missing required environment variable: JWT_SECRET'); + return res.status(503).json({ error: 'Interview templates are unavailable' }); + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + try { + const decoded = jwt.verify(authHeader.substring(7), jwtSecret); + if (decoded.isAdmin !== true) { + return res.status(403).json({ error: 'Interviewer access required' }); + } + + return res.status(200).json({ templates }); + } catch (error) { + return res.status(401).json({ error: 'Invalid or expired authentication' }); + } +}; diff --git a/app.html b/app.html index 7ebdf57..f618235 100644 --- a/app.html +++ b/app.html @@ -558,19 +558,8 @@

Sessions Management

- - - - - - - - - - - - +
@@ -558,9 +563,14 @@

Sessions Management

+ + + @@ -588,6 +598,7 @@

Sessions Management

Atom Tickets +
diff --git a/docs/QUESTION_SETS.md b/docs/QUESTION_SETS.md new file mode 100644 index 0000000..e7666d1 --- /dev/null +++ b/docs/QUESTION_SETS.md @@ -0,0 +1,31 @@ +# Custom interview question sets + +Administrators can choose a built-in question set from the interview toolbar or +select **Import Set** to load their own JSON file. + +An imported file has this shape: + +```json +{ + "name": "Backend Fundamentals", + "description": "A short custom interview for backend candidates.", + "questions": [ + { + "title": "C# β€” Calculate a Total", + "language": "csharp", + "content": "// Starter code shown to the candidate", + "answerKey": "Interviewer-only answer and grading guidance" + } + ] +} +``` + +See [question-set-example.json](question-set-example.json) for an importable +example. Every question requires `title`, `language`, `content`, and `answerKey`. +The language must match one supported by the editor, such as `csharp`, `sql`, +`javascript`, `html`, or `markdown`. + +Imported sets are validated and saved in that browser's local storage. They are +not uploaded to the server or written into the shared interview session. +Importing another file with the same `id` updates that set. Administrators using +a shared computer should clear site data after importing confidential material. diff --git a/docs/question-set-example.json b/docs/question-set-example.json new file mode 100644 index 0000000..7f4bd92 --- /dev/null +++ b/docs/question-set-example.json @@ -0,0 +1,12 @@ +{ + "name": "Backend Fundamentals", + "description": "A short custom interview for backend candidates.", + "questions": [ + { + "title": "C# β€” Calculate a Total", + "language": "csharp", + "content": "// Ask the candidate to complete this method.\npublic decimal Total(decimal[] values)\n{\n return 0m;\n}", + "answerKey": "A good answer sums the values with a loop or values.Sum().\n\nGrade for a correct result and a clear explanation." + } + ] +} diff --git a/scripts/app.js b/scripts/app.js index f050736..5ad046e 100644 --- a/scripts/app.js +++ b/scripts/app.js @@ -204,6 +204,14 @@ const viewAllSessionsBtn = document.getElementById('viewAllSessionsBtn'); const closeSessionsModalBtn = document.getElementById('closeSessionsModalBtn'); const interviewerNameInput = document.getElementById('interviewerName'); + const returnToInterviewBtn = document.getElementById('returnToInterviewBtn'); + + if (returnToInterviewBtn && !returnToInterviewBtn.dataset.bound) { + returnToInterviewBtn.dataset.bound = 'true'; + returnToInterviewBtn.addEventListener('click', function() { + document.getElementById('adminDashboardModal').style.display = 'none'; + }); + } restoreRecentAdminSession(); diff --git a/scripts/firepad.js b/scripts/firepad.js index 8611d01..d28ed98 100644 --- a/scripts/firepad.js +++ b/scripts/firepad.js @@ -98,6 +98,7 @@ // Update UI based on role const endSessionBtn = document.getElementById('end-session-btn'); + const dashboardBtn = document.getElementById('dashboard-btn'); if (isAdmin) { console.log('Admin user detected - showing End Interview button'); @@ -110,12 +111,14 @@ if (endSessionBtn) { console.log('End Interview button is visible for admin'); } + if (dashboardBtn) dashboardBtn.style.display = 'inline-block'; } else { console.log('Non-admin user - hiding End Interview button'); // Hide button for non-admin users if (endSessionBtn) { endSessionBtn.style.display = 'none'; } + if (dashboardBtn) dashboardBtn.style.display = 'none'; } } @@ -478,6 +481,9 @@ // Interview question templates (interviewer only) const templateSelector = document.getElementById('template-selector'); + const questionSetSelector = document.getElementById('question-set-selector'); + const importQuestionSetButton = document.getElementById('import-question-set-btn'); + const questionSetFileInput = document.getElementById('question-set-file-input'); const answerKeyButton = document.getElementById('answer-key-btn'); const answerKeyPanel = document.getElementById('answer-key-panel'); const firepadContainer = document.getElementById('firepad-container'); @@ -513,9 +519,113 @@ if (templateSelector) { if (!currentUser || !currentUser.isAdmin) { templateSelector.style.display = 'none'; + if (questionSetSelector) questionSetSelector.style.display = 'none'; + if (importQuestionSetButton) importQuestionSetButton.style.display = 'none'; if (answerKeyButton) answerKeyButton.style.display = 'none'; closeAnswerKeyPanel(); } else { + const customSetStorageKey = 'opencollab_custom_question_sets'; + let questionSets = {}; + let selectedQuestionSet = ''; + + function readCustomQuestionSets() { + try { + const saved = JSON.parse(localStorage.getItem(customSetStorageKey) || '[]'); + return Array.isArray(saved) ? saved : []; + } catch (error) { + console.warn('Ignoring invalid saved question sets:', error); + return []; + } + } + + function validateImportedQuestionSet(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The JSON file must contain one question-set object.'); + } + if (typeof value.name !== 'string' || !value.name.trim()) { + throw new Error('The question set needs a non-empty "name".'); + } + if (!Array.isArray(value.questions) || value.questions.length === 0) { + throw new Error('The question set needs a non-empty "questions" array.'); + } + + const allowedLanguages = Object.keys(languageConfig); + const questions = value.questions.map((question, index) => { + if (!question || typeof question !== 'object') { + throw new Error(`Question ${index + 1} must be an object.`); + } + for (const field of ['title', 'language', 'content', 'answerKey']) { + if (typeof question[field] !== 'string' || !question[field].trim()) { + throw new Error(`Question ${index + 1} needs a non-empty "${field}".`); + } + } + if (!allowedLanguages.includes(question.language)) { + throw new Error( + `Question ${index + 1} uses unsupported language "${question.language}".` + ); + } + return { + title: question.title.trim().slice(0, 120), + language: question.language, + content: question.content, + answerKey: question.answerKey + }; + }); + + return { + id: value.id || `set-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: value.name.trim().slice(0, 120), + description: typeof value.description === 'string' + ? value.description.trim().slice(0, 300) + : '', + questions + }; + } + + function installCustomQuestionSets(customSets) { + customSets.forEach(customSet => { + const setKey = `custom:${customSet.id}`; + const questionKeys = customSet.questions.map((question, index) => { + const questionKey = `${setKey}:${index}`; + window.InterviewTemplates[questionKey] = question; + return questionKey; + }); + questionSets[setKey] = { + title: customSet.name, + description: customSet.description, + questions: questionKeys, + custom: true + }; + }); + } + + function renderQuestionSetOptions() { + if (!questionSetSelector) return; + questionSetSelector.replaceChildren(new Option('Choose question set…', '')); + Object.entries(questionSets).forEach(([key, set]) => { + const option = new Option(set.custom ? `My Set β€” ${set.title}` : set.title, key); + option.title = set.description || ''; + questionSetSelector.appendChild(option); + }); + questionSetSelector.value = selectedQuestionSet; + questionSetSelector.style.display = 'inline-block'; + } + + function selectQuestionSet(setKey) { + const set = questionSets[setKey]; + if (!set) return; + selectedQuestionSet = setKey; + localStorage.setItem('opencollab_selected_question_set', setKey); + templateSelector.replaceChildren(new Option(`Load from ${set.title}…`, '')); + set.questions.forEach(questionKey => { + const template = window.InterviewTemplates[questionKey]; + if (!template) return; + templateSelector.appendChild(new Option(template.title, questionKey)); + }); + templateSelector.style.display = 'inline-block'; + if (questionSetSelector) questionSetSelector.value = setKey; + } + try { const response = await fetch('/api/interview/templates', { method: 'GET', @@ -528,16 +638,19 @@ } window.InterviewTemplates = data.templates; - while (templateSelector.options.length > 1) { - templateSelector.remove(1); - } - Object.entries(data.templates).forEach(([key, template]) => { - const option = document.createElement('option'); - option.value = key; - option.textContent = template.title; - templateSelector.appendChild(option); - }); - templateSelector.style.display = 'inline-block'; + questionSets = data.questionSets || { + 'all-questions': { + title: 'All Questions', + questions: Object.keys(data.templates) + } + }; + installCustomQuestionSets(readCustomQuestionSets()); + const savedSelection = localStorage.getItem('opencollab_selected_question_set'); + selectedQuestionSet = questionSets[savedSelection] + ? savedSelection + : (data.defaultQuestionSet || Object.keys(questionSets)[0]); + renderQuestionSetOptions(); + selectQuestionSet(selectedQuestionSet); } catch (error) { console.error('Failed to load protected interview templates:', error); templateSelector.style.display = 'none'; @@ -545,6 +658,39 @@ return; } + if (importQuestionSetButton && questionSetFileInput) { + importQuestionSetButton.style.display = 'inline-block'; + importQuestionSetButton.addEventListener('click', function() { + questionSetFileInput.value = ''; + questionSetFileInput.click(); + }); + questionSetFileInput.addEventListener('change', async function() { + const file = this.files?.[0]; + if (!file) return; + try { + const importedSet = validateImportedQuestionSet( + JSON.parse(await file.text()) + ); + const customSets = readCustomQuestionSets() + .filter(set => set.id !== importedSet.id); + customSets.push(importedSet); + localStorage.setItem(customSetStorageKey, JSON.stringify(customSets)); + installCustomQuestionSets([importedSet]); + renderQuestionSetOptions(); + selectQuestionSet(`custom:${importedSet.id}`); + showNotification(`Imported "${importedSet.name}" with ${importedSet.questions.length} questions.`); + } catch (error) { + alert(`Could not import question set: ${error.message}`); + } + }); + } + + if (questionSetSelector) { + questionSetSelector.addEventListener('change', function() { + if (this.value) selectQuestionSet(this.value); + }); + } + if (answerKeyButton) answerKeyButton.style.display = 'inline-block'; function loadInterviewTemplate(templateKey) { const template = window.InterviewTemplates?.[templateKey]; @@ -596,7 +742,8 @@ setTimeout(loadOverviewWhenReady, 50); return; } - loadInterviewTemplate('dotnet-interview-plan'); + const firstQuestion = questionSets[selectedQuestionSet]?.questions?.[0]; + if (firstQuestion) loadInterviewTemplate(firstQuestion); }; loadOverviewWhenReady(); } @@ -662,6 +809,18 @@ shareBtn.addEventListener('click', shareSession); } + // Return to the admin dashboard without ending or disconnecting the session. + const dashboardBtn = document.getElementById('dashboard-btn'); + if (dashboardBtn) { + dashboardBtn.addEventListener('click', function() { + if (!currentUser?.isAdmin) return; + const dashboard = document.getElementById('adminDashboardModal'); + const returnButton = document.getElementById('returnToInterviewBtn'); + if (returnButton) returnButton.style.display = 'inline-block'; + if (dashboard) dashboard.style.display = 'flex'; + }); + } + // Run button const runBtn = document.getElementById('run-btn'); if (runBtn) { From 6980ed4bd0ead227b9b025322fc5944fc1d91838 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:46:31 -0500 Subject: [PATCH 23/48] Use doubles in premium warm-up --- api/interview/templates-data.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/api/interview/templates-data.js b/api/interview/templates-data.js index 3e5a1a9..63ed5db 100644 --- a/api/interview/templates-data.js +++ b/api/interview/templates-data.js @@ -23,16 +23,16 @@ using System; public class Policy { - public decimal[] MonthlyPremiums { get; set; } + public double[] MonthlyPremiums { get; set; } - public Policy(decimal[] monthlyPremiums) + public Policy(double[] monthlyPremiums) { MonthlyPremiums = monthlyPremiums; } - public decimal CalculateDiscountedPremiumTotal() + public double CalculateDiscountedPremiumTotal() { - decimal total = 0m; + double total = 0; // Write your loop and filter here. @@ -45,7 +45,7 @@ public class Program public static void Main() { var policy = new Policy( - new decimal[] { 100.00m, 120.00m, 0.00m, -20.00m }); + new double[] { 100.00, 120.00, 0.00, -20.00 }); Console.WriteLine( policy.CalculateDiscountedPremiumTotal()); // Expected: 198.00 @@ -53,19 +53,19 @@ public class Program }`, answerKey: `PLAIN-ENGLISH ANSWER Loop through the policy's MonthlyPremiums. Skip zero and negative values. For -each valid premium, keep 90% of it by multiplying by 0.90m, then add that -discounted value to the total. The decimal type is normally used for money. +each valid premium, keep 90% of it by multiplying by 0.90, then add that +discounted value to the total. ONE GOOD SOLUTION -public decimal CalculateDiscountedPremiumTotal() +public double CalculateDiscountedPremiumTotal() { - decimal total = 0m; + double total = 0; - foreach (decimal premium in MonthlyPremiums) + foreach (double premium in MonthlyPremiums) { if (premium > 0) { - total += premium * 0.90m; + total += premium * 0.90; } } @@ -83,7 +83,7 @@ HOW TO GRADE (0–3) ALSO CORRECT WITH LINQ return MonthlyPremiums .Where(premium => premium > 0) - .Sum(premium => premium * 0.90m); + .Sum(premium => premium * 0.90); Do not require LINQ, rounding, null handling, or exactly 12 array entries. The point is to warm up with an object, an array, a filter, and simple arithmetic From 3651c0b406cf2750679e2ab8c50dcc4ac2aa8e48 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:48:16 -0500 Subject: [PATCH 24/48] Simplify policy card CSS exercise --- api/interview/templates-data.js | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/api/interview/templates-data.js b/api/interview/templates-data.js index 63ed5db..9d7b09b 100644 --- a/api/interview/templates-data.js +++ b/api/interview/templates-data.js @@ -771,11 +771,11 @@ it communicates the context, desired outcome, constraints, and verification.` language: 'html', content: ` @@ -804,18 +810,35 @@ Update only the CSS to: `, answerKey: `PLAIN-ENGLISH ANSWER width sets the card's width. Padding adds space between the content and the -border. The media query changes the button only on small screens. +border. The h2 and p selectors style those element types inside the card. The +media query changes the button only on small screens. ONE GOOD SOLUTION .policy-card { width: 400px; padding: 16px; border: 1px solid gray; - border-radius: 8px; +} + +.policy-card h2 { + font-family: Arial, sans-serif; + font-size: 24px; + font-weight: bold; +} + +.policy-card p { + font-family: Arial, sans-serif; + font-weight: normal; } .details-button { width: auto; + padding: 10px 16px; + border: none; + background-color: blue; + color: white; + font-family: Arial, sans-serif; + font-weight: bold; } @media (max-width: 480px) { @@ -825,14 +848,16 @@ ONE GOOD SOLUTION } HOW TO GRADE (0-3) -3 β€” Adds the 400px width, padding, border, rounded corners, and the mobile - button rule. +3 β€” Adds the card layout, requested heading and paragraph typography, button + styling, and the mobile button rule. 2 β€” Completes most changes but misses one detail or makes a minor syntax error. -1 β€” Can add basic padding or border but cannot make the card responsive. +1 β€” Can add one or two basic properties but needs substantial help with the + selectors or remaining styles. 0 β€” Does not know which CSS properties control these visible changes. ACCEPTABLE VARIATIONS - Colors such as #999, #ccc, or another reasonable gray are fine. +- A reasonable blue such as #0066cc, royalblue, or Bootstrap blue is fine. - A close breakpoint such as 500px is fine if the candidate explains it. - Equivalent selectors and reasonable pixel/rem values are fine. From f562a4e196bdad1bb8058f7332e83c1f2a1113c9 Mon Sep 17 00:00:00 2001 From: Phil G Date: Tue, 28 Jul 2026 14:51:09 -0500 Subject: [PATCH 26/48] Clarify mobile CSS media query --- api/interview/templates-data.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/api/interview/templates-data.js b/api/interview/templates-data.js index 85b81bd..72f3e01 100644 --- a/api/interview/templates-data.js +++ b/api/interview/templates-data.js @@ -782,7 +782,9 @@ Update only the CSS to: 5. Make the paragraphs Arial with normal font weight. 6. Give the button a blue background, white text, 10px vertical and 16px horizontal padding, no border, and bold Arial text. -7. On screens 480px wide or smaller, make the button fill the card width. +7. Use an @media rule so that, when the screen is 480px wide or smaller, the + policy card becomes 200px wide. +Bonus: In that same @media rule, make the button fill the card width. -->