From ed5b88b7e186ae40c2e1bf0ffc8e86feb199ceb5 Mon Sep 17 00:00:00 2001 From: yuuka5555 Date: Sat, 10 Oct 2026 03:19:31 +0800 Subject: [PATCH] Restore interview settings when retrying Try Again restored only the problem, requiring candidates to choose the duration and interview modes again. Restore the attempt's saved duration, interview loop, and editor or whiteboard mode. Remember lobby preferences across reloads. Keep the explicitly chosen duration separate from the effective server limit so changing modes under a temporary cap does not erase the original preference. Explain adjustments when the server cannot offer the requested duration. Treat legacy attempts without duration metadata as suggestions. Keep any existing explicit duration choice and do not persist a suggested length or prevent later difficulty-based suggestions. Ignore retry, loop, and duration clicks during startup so stored preferences stay consistent with the interview being opened. Add regression coverage for retries, persistence, caps, and pending starts. Wait for editor initialization before clearing starter code in browser tests, preventing a late problem response from replacing the cleared buffer. Add delayed-response coverage for this initialization race. --- tests/browser/account.test.js | 5 +- tests/browser/editor-pipeline.test.js | 61 +++- tests/browser/interview-lifecycle.test.js | 5 + tests/browser/lobby.test.js | 423 +++++++++++++++++++++- tests/browser/progress.test.js | 40 ++ web/app.js | 134 +++++-- web/interview.js | 1 + web/progress.js | 14 +- 8 files changed, 643 insertions(+), 40 deletions(-) diff --git a/tests/browser/account.test.js b/tests/browser/account.test.js index 4b2c3f6d..f89a7f0d 100644 --- a/tests/browser/account.test.js +++ b/tests/browser/account.test.js @@ -218,7 +218,10 @@ test("the lobby offers an editor or a whiteboard and carries the choice into the interview, "JSON.stringify({ problemId: problem.page, durationMin, interviewId, interviewLoop, interviewMode: mode, interviewProfile, ...(interviewGrounding", ); - assertIncludesCompact(interview, "durationMin, interviewLoop, report, };"); + assertIncludesCompact( + interview, + "durationMin, interviewLoop, interviewMode: mode, report, };", + ); }); test("interview loop is explicit, budgeted, gated, and carried into artifacts", () => { diff --git a/tests/browser/editor-pipeline.test.js b/tests/browser/editor-pipeline.test.js index 6fd5a7ef..a1032797 100644 --- a/tests/browser/editor-pipeline.test.js +++ b/tests/browser/editor-pipeline.test.js @@ -27,8 +27,9 @@ after(async () => { }); /// A page on the interview screen with an empty editor, no room joined. The -/// editor is enabled as soon as bindEvents runs; nothing under test needs a -/// live session, so this never waits on one. +/// pause control is revealed by bindEvents after the starter buffer is seeded. +/// The textarea starts enabled in HTML, so it cannot signal readiness. Nothing +/// under test needs a live session, so this never waits on one. async function editorPage(t, { params = {}, beforeLoad } = {}) { if (!browser) { t.skip("playwright chromium unavailable"); @@ -50,7 +51,7 @@ async function editorPage(t, { params = {}, beforeLoad } = {}) { await page.goto(`${base}/interview.html?${query}`, { waitUntil: "domcontentloaded", }); - await page.waitForSelector("#editor:not([disabled])"); + await page.waitForSelector("#pause:not([hidden])"); // The media preflight overlay sits over the editor until a candidate (or a // stubbed device) clears it, and it blocks Playwright's own click on // whatever is under it. A keystroke only needs the element focused, not @@ -843,3 +844,57 @@ test("a proportional font keeps the textarea and highlight metrics aligned", asy } } }); + +test("editor setup waits for delayed problem initialization before clearing the starter", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + let releaseProblem; + const problemReady = new Promise((resolve) => { + releaseProblem = resolve; + }); + let problemRequested; + const requested = new Promise((resolve) => { + problemRequested = resolve; + }); + let loadingPage; + let setupFinished = false; + const setup = editorPage(t, { + beforeLoad: async (page) => { + loadingPage = page; + await page.route( + "**/problems/chargeback-pair-match.json", + async (route) => { + problemRequested(); + await problemReady; + await route.continue(); + }, + ); + }, + }).then((ctx) => { + setupFinished = true; + return ctx; + }); + try { + await requested; + await loadingPage.waitForSelector("#editor:not([disabled])"); + assert.equal(await loadingPage.locator("#pause").isVisible(), false); + // Recreate the old helper's sequence before the module can initialize. + await loadingPage.evaluate(() => document.querySelector("#editor").focus()); + await loadingPage.keyboard.press("ControlOrMeta+A"); + await loadingPage.keyboard.press("Delete"); + assert.equal((await caretState(loadingPage)).value, ""); + assert.equal( + setupFinished, + false, + "setup must wait for the starter buffer", + ); + releaseProblem(); + const { page, errors } = await setup; + await page.waitForSelector("#pause:not([hidden])"); + assert.equal((await caretState(page)).value, ""); + assert.deepEqual(errors, []); + } finally { + releaseProblem(); + await setup.catch(() => {}); + await loadingPage?.close(); + } +}); diff --git a/tests/browser/interview-lifecycle.test.js b/tests/browser/interview-lifecycle.test.js index 1f193deb..d73b6907 100644 --- a/tests/browser/interview-lifecycle.test.js +++ b/tests/browser/interview-lifecycle.test.js @@ -253,6 +253,7 @@ test("only the newest overlapping report save can release the warning", async () "whiteboard", "durationMin", "interviewLoop", + "mode", "saveReportHistory", "reportIsPersisted", "updateBeforeUnloadGuard", @@ -264,6 +265,7 @@ test("only the newest overlapping report save can release the warning", async () false, 45, "coding_only", + "whiteboard", saveReportHistory, reportIsPersisted, updateBeforeUnloadGuard, @@ -272,6 +274,9 @@ test("only the newest overlapping report save can release the warning", async () const provisional = saveHistory({ summary: "provisional" }); const final = saveHistory({ summary: "final" }); assert.equal(pending.length, 2); + assert.equal(pending[0].entry.interviewMode, "whiteboard"); + assert.equal(pending[0].entry.interviewLoop, "coding_only"); + assert.equal(pending[0].entry.durationMin, 45); assert.equal(state.reportPersisted, false); // The older provisional save finishes successfully after the final save has diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index a9f00ed7..a39aea69 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -1645,7 +1645,7 @@ lobbyTest( ); lobbyTest( - "a random draw hides an out-of-filter retry and updates its suggested duration", + "a random draw hides an out-of-filter legacy retry and updates its suggested duration", async (page) => { reports = [savedAttempt(EASY[0])]; reports[0].payload.date = new Date().toISOString(); @@ -3321,12 +3321,9 @@ lobbyTest( ); lobbyTest( - "a cap under every length on offer leaves the row alone", + "a cap under every length keeps the row usable and explains the effective limit", async (page) => { - // A deployment that records less than the shortest interview it offers is - // misconfigured, and `recording_config` refuses that pairing at startup. If - // one reaches the browser anyway, a row with every button dead is a lobby - // nobody can start; the server's own floor decides instead. + // Keep the page usable even when the server limit fits no duration button. session = { signedIn: true, user: { login: "candidate" }, @@ -3336,7 +3333,8 @@ lobbyTest( const state = await lobby(page); assert.deepEqual(state.durationsOff, []); - assert.equal(state.durationNote, ""); + assert.equal(state.duration, null); + assert.match(state.durationNote, /limited to 15 minutes/); }, ); @@ -4648,3 +4646,414 @@ lobbyTest( assert.doesNotMatch(drawn.note, /Review due/); }, ); + +for (const account of [false, true]) { + for (const interviewMode of ["coding", "whiteboard"]) { + lobbyTest( + `retry restores ${interviewMode} settings from ${account ? "account" : "device"} history`, + async (page) => { + const attempt = { + ...savedAttempt(EASY[0]).payload, + durationMin: 60, + interviewLoop: "coding_only", + interviewMode, + }; + if (account) reports = [{ payload: attempt }]; + else { + session = { signedIn: false }; + await page.addInitScript( + (saved) => + localStorage.setItem( + "codetrial_history", + JSON.stringify([saved]), + ), + attempt, + ); + } + await lobby(page); + await page.click('[data-duration="30"]'); + await page + .getByRole("button", { name: "Try again", exact: true }) + .click(); + assert.equal((await snapshot(page)).duration, "60"); + assert.equal( + await page + .locator('[data-loop="coding_only"]') + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page + .locator(`[data-mode="${interviewMode}"]`) + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page.locator("#mode-note").isVisible(), + interviewMode === "whiteboard", + ); + assert.equal( + await page + .locator(".editor-options-row") + .evaluate((fieldset) => fieldset.disabled), + interviewMode === "whiteboard", + ); + await page.click("#start"); + await page.waitForURL("**/interview?**"); + const query = new URL(page.url()).searchParams; + assert.equal(query.get("problem"), EASY[0]); + assert.equal(query.get("duration"), "60"); + assert.equal(query.get("loop"), "coding_only"); + assert.equal(query.get("mode"), interviewMode); + }, + ); + } +} + +lobbyTest( + "retry restores the saved loop over the current choice and explains a capped duration", + async (page) => { + session.maxDurationMin = 45; + reports = [ + { + payload: { + ...savedAttempt(EASY[0]).payload, + durationMin: 60, + interviewLoop: "coding_behavioral", + interviewMode: "coding", + }, + }, + ]; + await lobby(page); + await page.click('[data-loop="coding_only"]'); + await page.click('[data-mode="whiteboard"]'); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + const state = await snapshot(page); + assert.equal(state.duration, "45"); + assert.match(state.note, /60 minutes; adjusted to 45 minutes/); + assert.equal( + await page + .locator('[data-loop="coding_behavioral"]') + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page.locator('[data-mode="coding"]').getAttribute("aria-pressed"), + "true", + ); + }, +); + +lobbyTest( + "lobby settings survive reload and a later recording cap", + async (page) => { + await lobby(page); + await page.click('[data-duration="60"]'); + await page.click('[data-loop="coding_only"]'); + await page.click('[data-mode="whiteboard"]'); + await lobby(page); + assert.equal((await snapshot(page)).duration, "60"); + assert.equal( + await page + .locator('[data-loop="coding_only"]') + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page + .locator('[data-mode="whiteboard"]') + .getAttribute("aria-pressed"), + "true", + ); + session.maxDurationMin = 45; + await lobby(page); + const state = await snapshot(page); + assert.equal(state.duration, "45"); + assert.match(state.durationNote, /at most 45 minutes/); + assert.equal( + await page + .locator('[data-loop="coding_only"]') + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page + .locator('[data-mode="whiteboard"]') + .getAttribute("aria-pressed"), + "true", + ); + }, +); + +for (const stored of [ + '{"durationMin":-1,"interviewLoop":"invalid","interviewMode":"invalid"}', + "not json", +]) { + lobbyTest( + `invalid preferences do not stop the lobby: ${stored}`, + async (page) => { + await page.addInitScript( + (value) => localStorage.setItem("codetrial.interviewSettings", value), + stored, + ); + const state = await lobby(page); + assert.equal(state.startDisabled, false); + assert.equal( + await page + .locator('[data-loop="coding_behavioral"]') + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page.locator('[data-mode="coding"]').getAttribute("aria-pressed"), + "true", + ); + }, + ); +} + +lobbyTest( + "blocked preference storage still allows settings to be changed and retried", + async (page) => { + reports = [ + { + payload: { + ...savedAttempt(EASY[0]).payload, + durationMin: 60, + interviewLoop: "coding_only", + interviewMode: "whiteboard", + }, + }, + ]; + await page.addInitScript(() => { + const get = Storage.prototype.getItem; + const set = Storage.prototype.setItem; + Storage.prototype.getItem = function (key) { + if (key === "codetrial.interviewSettings") throw new Error("blocked"); + return get.call(this, key); + }; + Storage.prototype.setItem = function (key, value) { + if (key === "codetrial.interviewSettings") throw new Error("blocked"); + return set.call(this, key, value); + }; + }); + await lobby(page); + await page.click('[data-duration="30"]'); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + assert.equal((await snapshot(page)).duration, "60"); + assert.equal( + await page + .locator('[data-mode="whiteboard"]') + .getAttribute("aria-pressed"), + "true", + ); + }, +); + +for (const selector of [ + '[data-loop="coding_only"]', + '[data-mode="whiteboard"]', +]) { + lobbyTest( + `changing ${selector} does not persist a suggested duration`, + async (page) => { + await lobby(page); + await page.evaluate( + (id) => document.querySelector(`[data-problem="${id}"]`).click(), + EASY[0], + ); + assert.equal((await snapshot(page)).duration, "30"); + await page.click(selector); + const settings = await page.evaluate(() => + JSON.parse(localStorage.getItem("codetrial.interviewSettings")), + ); + assert.equal(Object.hasOwn(settings, "durationMin"), false); + await lobby(page); + await page.evaluate( + (id) => document.querySelector(`[data-problem="${id}"]`).click(), + MEDIUM[0], + ); + assert.equal((await snapshot(page)).duration, "45"); + assert.equal( + await page.locator(selector).getAttribute("aria-pressed"), + "true", + ); + }, + ); +} + +lobbyTest( + "retrying a legacy attempt preserves an earlier explicit duration", + async (page) => { + reports = [ + { + payload: { + ...savedAttempt(MEDIUM[0]).payload, + date: "2026-01-02T00:00:00Z", + durationMin: 60, + }, + }, + savedAttempt(EASY[0]), + ]; + await lobby(page); + const retries = page.getByRole("button", { + name: "Try again", + exact: true, + }); + await retries.nth(0).click(); + assert.equal((await snapshot(page)).duration, "60"); + await retries.nth(1).click(); + assert.equal((await snapshot(page)).duration, "60"); + assert.equal( + await page.evaluate( + () => + JSON.parse(localStorage.getItem("codetrial.interviewSettings")) + .durationMin, + ), + 60, + ); + }, +); + +lobbyTest( + "retry, loop, and duration clicks cannot change settings while a start is pending", + async (page) => { + session = { signedIn: false, loginRequired: true }; + await page.addInitScript( + (attempt) => + localStorage.setItem("codetrial_history", JSON.stringify([attempt])), + { + ...savedAttempt(EASY[0]).payload, + durationMin: 60, + interviewLoop: "coding_only", + interviewMode: "whiteboard", + }, + ); + let release; + holdLogin = new Promise((resolve) => (release = resolve)); + try { + await lobby(page); + await page.fill("#github-login", "candidate"); + const pending = page.waitForRequest((request) => + request.url().endsWith("/api/login"), + ); + const before = await snapshot(page); + const settings = await page.evaluate(() => + localStorage.getItem("codetrial.interviewSettings"), + ); + await page.click("#start"); + await pending; + const startingState = await snapshot(page); + await page.click('[data-loop="coding_only"]'); + await page.click('[data-duration="60"]'); + await page + .getByRole("button", { name: "Try again", exact: true }) + .click(); + assert.deepEqual(await snapshot(page), startingState); + assert.equal( + await page.locator('[data-mode="coding"]').getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page + .locator('[data-loop="coding_behavioral"]') + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page.evaluate(() => + localStorage.getItem("codetrial.interviewSettings"), + ), + settings, + ); + release(); + await page.waitForURL("**/interview?**"); + const query = new URL(page.url()).searchParams; + assert.equal(query.get("problem"), before.card); + assert.equal(query.get("mode"), "coding"); + assert.equal(query.get("loop"), "coding_behavioral"); + assert.equal(query.get("duration"), before.duration); + } finally { + release(); + } + }, +); + +lobbyTest( + "retry uses the server limit when no offered duration fits", + async (page) => { + session.maxDurationMin = 10; + reports = [ + { payload: { ...savedAttempt(EASY[0]).payload, durationMin: 15 } }, + ]; + await lobby(page); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + const state = await snapshot(page); + assert.equal(state.duration, null); + assert.match(state.durationNote, /limited to 10 minutes/); + assert.match(state.note, /15 minutes; adjusted to 10 minutes/); + await page.click("#start"); + await page.waitForURL("**/interview?**"); + assert.equal(new URL(page.url()).searchParams.get("duration"), "10"); + }, +); + +for (const selector of [ + '[data-loop="coding_only"]', + '[data-mode="whiteboard"]', +]) { + lobbyTest( + `changing ${selector} under a cap preserves the chosen duration`, + async (page) => { + await lobby(page); + await page.click('[data-duration="60"]'); + session.maxDurationMin = 45; + await restore(page); + await awaitReady(page); + assert.equal((await snapshot(page)).duration, "45"); + await page.click(selector); + assert.equal( + await page.evaluate( + () => + JSON.parse(localStorage.getItem("codetrial.interviewSettings")) + .durationMin, + ), + 60, + ); + await lobby(page); + assert.equal((await snapshot(page)).duration, "45"); + await page.click(selector); + session.maxDurationMin = 90; + await restore(page); + await awaitReady(page); + assert.equal((await snapshot(page)).duration, "60"); + await lobby(page); + assert.equal((await snapshot(page)).duration, "60"); + }, + ); +} + +lobbyTest( + "a legacy retry leaves its duration suggested across reloads", + async (page) => { + reports = [savedAttempt(EASY[0])]; + await lobby(page); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + assert.equal((await snapshot(page)).duration, "30"); + const settings = await page.evaluate(() => + JSON.parse(localStorage.getItem("codetrial.interviewSettings")), + ); + assert.equal(Object.hasOwn(settings, "durationMin"), false); + await page.evaluate( + (id) => document.querySelector(`[data-problem="${id}"]`).click(), + MEDIUM[0], + ); + assert.equal((await snapshot(page)).duration, "45"); + await lobby(page); + await page.evaluate( + (id) => document.querySelector(`[data-problem="${id}"]`).click(), + MEDIUM[0], + ); + assert.equal((await snapshot(page)).duration, "45"); + }, +); diff --git a/tests/browser/progress.test.js b/tests/browser/progress.test.js index 3e61d019..d5f2cf5b 100644 --- a/tests/browser/progress.test.js +++ b/tests/browser/progress.test.js @@ -755,3 +755,43 @@ test("progress retains whether a verdict was actually stored for export", () => } } }); + +for (const account of [false, true]) { + test(`retry settings survive ${account ? "account" : "device"} history normalization`, () => { + const saved = { + date: "2026-01-01", + durationMin: 60, + interviewLoop: "coding_only", + interviewMode: "whiteboard", + report: {}, + }; + const attempt = normalizeProgressEntry( + account ? { payload: saved } : saved, + ); + assert.equal(attempt.durationMin, 60); + assert.equal(attempt.interviewLoop, "coding_only"); + assert.equal(attempt.interviewMode, "whiteboard"); + assert.equal(saved.interviewMode, "whiteboard"); + }); +} + +test("retry mode metadata falls back to the original report and defaults safely", () => { + const older = normalizeProgressEntry({ + report: { interviewLoop: "coding_only", interviewMode: "whiteboard" }, + }); + assert.equal(older.interviewLoop, "coding_only"); + assert.equal(older.interviewMode, "whiteboard"); + const legacy = normalizeProgressEntry({ report: {} }); + assert.equal(legacy.durationMin, null); + assert.equal(legacy.interviewLoop, "coding_behavioral"); + assert.equal(legacy.interviewMode, "coding"); + const invalid = normalizeProgressEntry({ + interviewLoop: "invalid", + interviewMode: "invalid", + durationMin: "60", + report: {}, + }); + assert.equal(invalid.durationMin, null); + assert.equal(invalid.interviewLoop, "coding_behavioral"); + assert.equal(invalid.interviewMode, "coding"); +}); diff --git a/web/app.js b/web/app.js index eca35acf..83374f64 100644 --- a/web/app.js +++ b/web/app.js @@ -37,6 +37,9 @@ import { setEditorOptions, } from "./editor-options.js"; +import { readStored, writeStored } from "./audio-output.js"; + +const SETTINGS_KEY = "codetrial.interviewSettings"; const requestTimeoutMs = 10_000; let problem; @@ -54,6 +57,8 @@ let manualProblem = false; /// drawn from changes. let keptDraw = null; let manualDuration = false; +// Preserve the explicit preference independently of the effective server limit. +let chosenDuration; let manualDifficulty = false; let historyReady = false; let historyLoad = 0; @@ -335,15 +340,17 @@ nodes.deleteReports.addEventListener("click", deleteSavedReports); const durations = [...document.querySelectorAll("[data-duration]")]; for (const button of durations) { - button.addEventListener("click", () => - setDuration(Number(button.dataset.duration), true), - ); + button.addEventListener("click", () => { + if (starting) return; + setDuration(Number(button.dataset.duration), true); + }); } for (const button of document.querySelectorAll("[data-loop]")) { button.addEventListener("click", () => { - interviewLoop = codingLoop(button.dataset.loop); - select("[data-loop]", button); + if (starting) return; + setInterviewLoop(button.dataset.loop); + rememberSettings(); }); } @@ -352,18 +359,8 @@ for (const button of document.querySelectorAll("[data-mode]")) { // A start already on its way out has read the mode it will open, so a // click now would light a surface the interview is not going to use. if (starting) return; - mode = interviewMode(button.dataset.mode); - select("[data-mode]", button); - nodes.editorOptions.disabled = mode === "whiteboard"; - // Said once, here, because every other difference the candidate will meet - // follows from it: no editor, no test runner, and a board the interviewer - // is sent as they draw. - const note = document.querySelector("#mode-note"); - note.textContent = - mode === "whiteboard" - ? "Whiteboard: no editor and no test runner. You explain by drawing, and Jim sees the board as you go." - : ""; - note.hidden = mode !== "whiteboard"; + setInterviewMode(button.dataset.mode); + rememberSettings(); }); } @@ -378,6 +375,8 @@ let signInFirst = false; /// leaving and bought a second navigation out of one start. let starting = false; +restoreSettings(); + // The button by name, not by asking the event which element it was dispatched // to: the browser clears that property when dispatch ends, so every line after // the first `await` was writing to null. A gated candidate signed in, the @@ -1149,6 +1148,63 @@ function suggestedDuration(difficulties) { return difficulties.has("Medium") || difficulties.has("Hard") ? 45 : 30; } +function setInterviewLoop(value) { + interviewLoop = codingLoop(value); + select( + "[data-loop]", + document.querySelector(`[data-loop="${interviewLoop}"]`), + ); +} + +function setInterviewMode(value) { + mode = interviewMode(value); + select("[data-mode]", document.querySelector(`[data-mode="${mode}"]`)); + nodes.editorOptions.disabled = mode === "whiteboard"; + const note = document.querySelector("#mode-note"); + note.textContent = + mode === "whiteboard" + ? "Whiteboard: no editor and no test runner. You explain by drawing, and Jim sees the board as you go." + : ""; + note.hidden = mode !== "whiteboard"; +} + +function rememberSettings() { + writeStored( + SETTINGS_KEY, + JSON.stringify({ + durationMin: chosenDuration, + interviewLoop, + interviewMode: mode, + }), + ); +} + +function restoreSettings() { + setInterviewLoop(interviewLoop); + setInterviewMode(mode); + let settings; + try { + settings = JSON.parse(readStored(SETTINGS_KEY)); + } catch { + return; + } + if (!settings || typeof settings !== "object") return; + if ( + durations.some( + (button) => Number(button.dataset.duration) === settings.durationMin, + ) + ) { + setDuration(settings.durationMin, true); + } + if (["coding_only", "coding_behavioral"].includes(settings.interviewLoop)) { + setInterviewLoop(settings.interviewLoop); + } + if (["coding", "whiteboard"].includes(settings.interviewMode)) { + setInterviewMode(settings.interviewMode); + } + rememberSettings(); +} + /// A suggestion unless `chosen` says the candidate picked it out loud, and once /// they have, nothing suggests over it again. The latch never releases: someone /// who asks for sixty minutes keeps it even if their history later moves them @@ -1159,22 +1215,25 @@ function suggestedDuration(difficulties) { function setDuration(minutes, chosen = false) { if (manualDuration && !chosen) return; manualDuration ||= chosen; + if (chosen) chosenDuration = minutes; duration = underCeiling(minutes); select( "[data-duration]", document.querySelector(`[data-duration="${duration}"]`), ); + if (chosen) rememberSettings(); } /// Snapped to a length the row actually offers, not to the cap itself: a cap /// of forty would otherwise leave `duration` at forty with no button to show -/// for it, and the candidate reading a row where nothing is selected. +/// for it, and the candidate reading a row where nothing is selected. When no +/// offered length fits, use the ceiling and explain it in the duration note. function underCeiling(minutes) { if (minutes <= durationCeiling) return minutes; const offered = durations .map((button) => Number(button.dataset.duration)) .filter((value) => value <= durationCeiling); - return offered.length ? Math.max(...offered) : minutes; + return offered.length ? Math.max(...offered) : durationCeiling; } /// What the server said it can record, turned into a row that says so. The @@ -1187,20 +1246,21 @@ function applyDurationCeiling(minutes) { const over = durations.filter( (button) => Number(button.dataset.duration) > minutes, ); - // A cap under every length on offer is a misconfigured deployment, not a - // lobby with nothing to press. Leave the row alone and let the server's own - // floor decide, rather than handing back a page that cannot start anything. + // Keep the row usable when no offered length fits, but show and request + // the effective limit instead of promising a length the server will shorten. const capped = over.length < durations.length ? over : []; for (const button of durations) button.disabled = capped.includes(button); nodes.durationNote.textContent = capped.length ? `Interviews here are recorded for at most ${minutes} minutes, so longer ones are not offered.` - : ""; - nodes.durationNote.hidden = !capped.length; + : over.length + ? `Interviews here are limited to ${minutes} minutes; all duration choices use this limit.` + : ""; + nodes.durationNote.hidden = !over.length; // The cap outranks a length the candidate chose out loud, which nothing else // here does: it is not a second opinion about what suits them, it is what // this server can record. - if (duration > durationCeiling) { - duration = underCeiling(duration); + if (duration > durationCeiling || manualDuration) { + duration = underCeiling(manualDuration ? chosenDuration : duration); select( "[data-duration]", document.querySelector(`[data-duration="${duration}"]`), @@ -1327,6 +1387,7 @@ function renderAttemptHistory(attempts) { open.textContent = "Collapse report"; }); retry.addEventListener("click", () => { + if (starting) return; const card = cards.find( (candidate) => candidate.id === attempt.problemId, ); @@ -1334,8 +1395,25 @@ function renderAttemptHistory(attempts) { manualProblem = true; card.button.hidden = false; setProblem(card); - setDuration(suggestedDuration(new Set([card.difficulty]))); - nodes.recommendation.textContent = `Selected problem: ${title(card)}.`; + const savedDuration = attempt.durationMin; + const offered = durations.map((button) => + Number(button.dataset.duration), + ); + const minutes = + savedDuration === null + ? suggestedDuration(new Set([card.difficulty])) + : (offered + .filter((value) => value <= savedDuration) + .sort((a, b) => b - a)[0] ?? Math.min(...offered)); + setDuration(minutes, savedDuration !== null); + setInterviewLoop(attempt.interviewLoop); + setInterviewMode(attempt.interviewMode); + rememberSettings(); + const adjustment = + savedDuration !== null && duration !== savedDuration + ? ` Previous duration: ${savedDuration} minutes; adjusted to ${duration} minutes for the available lengths and current limit.` + : ""; + nodes.recommendation.textContent = `Selected problem: ${title(card)}.${adjustment}`; }); item.append(label, open, download, retry); // A row with no id has nothing both stores and the account agree on, and diff --git a/web/interview.js b/web/interview.js index 54e628a9..4f888f3f 100644 --- a/web/interview.js +++ b/web/interview.js @@ -3099,6 +3099,7 @@ async function saveHistory(report = state.report) { language: whiteboard ? "" : state.language, durationMin, interviewLoop, + interviewMode: mode, report, }; // A later save may replace an earlier report under this same id. Keep the diff --git a/web/progress.js b/web/progress.js index a887cbfa..6b53d55a 100644 --- a/web/progress.js +++ b/web/progress.js @@ -1,4 +1,10 @@ -import { FRAMEWORKS, frameworkPhases, sanitizeReport } from "./lib.js"; +import { + FRAMEWORKS, + codingLoop, + interviewMode, + frameworkPhases, + sanitizeReport, +} from "./lib.js"; import { LEVELS } from "./problem-picker.js"; export const progressPhases = frameworkPhases; @@ -85,6 +91,12 @@ export function normalizeProgressEntry(raw) { : null, language: allowedLanguages.has(entry.language) ? entry.language : null, durationMin, + interviewLoop: codingLoop( + entry.interviewLoop ?? entry.report?.interviewLoop, + ), + interviewMode: interviewMode( + entry.interviewMode ?? entry.report?.interviewMode, + ), recordedDecision: ["HIRE", "NO_HIRE"].includes(entry.report?.decision) ? entry.report.decision : null,