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,