diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index bb017b87..8366cd21 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -8136,6 +8136,715 @@ for (const account of [false, true]) { } } +lobbyTest( + "saved session setups restore session controls without touching the problem or context", + async (page) => { + await lobby(page); + await page.click("details.interview-context summary"); + await page.fill("#profile-role", "Platform engineer"); + await page.click('[data-duration="30"]'); + await page.click('[data-loop="coding_only"]'); + await page.locator("#enable-example-drawing").check(); + await page.locator("#enable-candidate-led").check(); + await page.locator('[name="editor-option"][value="highlight"]').uncheck(); + await page.locator('[name="editor-option"][value="autoClose"]').uncheck(); + await page.click('[data-mode="whiteboard"]'); + + await page.click("#open-session-presets"); + await page.click("#save-session-preset"); + await page.fill("#session-preset-name", "Quick whiteboard"); + await page + .locator("#session-preset-form") + .getByRole("button", { + name: "Save", + exact: true, + }) + .click(); + await page + .locator("#session-preset-status") + .getByText("Saved Quick whiteboard.") + .waitFor(); + assert.deepEqual( + await page.evaluate(() => + JSON.parse(localStorage.getItem("codetrial.sessionPresets")), + ), + [ + { + name: "Quick whiteboard", + settings: { + durationMin: 30, + interviewLoop: "coding_only", + interviewMode: "whiteboard", + exampleDrawing: true, + candidateLed: true, + editorOptions: { + highlight: false, + execution: true, + autoIndent: true, + autoClose: false, + monospace: true, + }, + }, + }, + ], + ); + await page.evaluate(() => { + const presets = JSON.parse( + localStorage.getItem("codetrial.sessionPresets"), + ); + delete presets[0].settings.exampleDrawing; + delete presets[0].settings.candidateLed; + delete presets[0].settings.editorOptions.execution; + localStorage.setItem("codetrial.sessionPresets", JSON.stringify(presets)); + }); + await page.click("#close-session-presets"); + + await page.click('[data-duration="60"]'); + await page.click('[data-loop="coding_behavioral"]'); + await page.click('[data-mode="coding"]'); + await page.locator("#enable-example-drawing").uncheck(); + await page.locator("#enable-candidate-led").uncheck(); + await page.locator('[name="editor-option"][value="highlight"]').check(); + await page.locator('[name="editor-option"][value="execution"]').check(); + await page.locator('[name="editor-option"][value="autoClose"]').check(); + await page.reload({ waitUntil: "domcontentloaded" }); + await awaitReady(page); + await page.locator("#enable-example-drawing").check(); + await page.locator("#enable-candidate-led").check(); + await openTopicFilter(page); + await page.selectOption("#problem-topic", "Array"); + const beforeSwitch = await snapshot(page); + await page.click("details.interview-context summary"); + await page.fill("#profile-role", "Platform engineer"); + await page.click("#open-session-presets"); + await page.selectOption("#session-preset", "Quick whiteboard"); + + const afterSwitch = await snapshot(page); + assert.equal(afterSwitch.card, beforeSwitch.card); + assert.deepEqual(afterSwitch.levels, beforeSwitch.levels); + assert.equal(await page.locator("#problem-topic").inputValue(), "Array"); + assert.equal(afterSwitch.duration, "30"); + assert.equal(await page.inputValue("#profile-role"), "Platform engineer"); + 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", + ); + assert.equal(await page.isChecked("#enable-example-drawing"), true); + assert.equal(await page.isChecked("#enable-candidate-led"), true); + assert.equal( + await page.isChecked('[name="editor-option"][value="highlight"]'), + false, + ); + assert.equal( + await page.isChecked('[name="editor-option"][value="execution"]'), + true, + ); + assert.equal( + await page.isChecked('[name="editor-option"][value="autoClose"]'), + false, + ); + assert.equal( + await page.locator("#session-preset-status").textContent(), + "Applied Quick whiteboard.", + ); + }, +); + +lobbyTest( + "saved setups ignore unavailable fields without duplicating the record", + async (page) => { + await page.addInitScript(() => + localStorage.setItem( + "codetrial.sessionPresets", + JSON.stringify([ + null, + { name: "", settings: {} }, + { + name: "Legacy setup", + revision: 2, + settings: { + durationMin: [30], + interviewLoop: "coding_only", + interviewMode: ["whiteboard"], + futureControl: "keep me", + editorOptions: { highlight: false }, + }, + }, + ]), + ), + ); + await lobby(page); + await page.click('[data-duration="60"]'); + await page.click("#open-session-presets"); + assert.equal(await page.locator("#session-preset option").count(), 2); + await page.selectOption("#session-preset", "Legacy setup"); + assert.equal( + await page.locator('[data-duration="60"]').getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page + .locator('[data-loop="coding_only"]') + .getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page.locator('[data-mode="coding"]').getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page.isChecked('[name="editor-option"][value="highlight"]'), + false, + ); + + await page.click("#save-session-preset"); + assert.equal(await page.inputValue("#session-preset-name"), "Legacy setup"); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await page + .locator("#session-preset-status") + .getByText("Saved Legacy setup.") + .waitFor(); + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem("codetrial.sessionPresets")), + ); + assert.equal(stored.length, 3); + assert.equal(stored[0], null); + assert.deepEqual(stored[1], { name: "", settings: {} }); + assert.equal(stored[2].name, "Legacy setup"); + assert.equal(stored[2].settings.durationMin, 60); + assert.equal(stored[2].settings.interviewMode, "coding"); + }, +); + +lobbyTest( + "an invalid setup candidate is not written to browser storage", + async (page) => { + await lobby(page); + await page.click("#open-session-presets"); + await page.click("#save-session-preset"); + await page + .locator("#session-preset-name") + .evaluate((input) => input.removeAttribute("maxlength")); + await page.fill("#session-preset-name", "x".repeat(41)); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + + await page + .locator("#session-preset-status") + .getByText("This browser could not save the setup.") + .waitFor(); + assert.equal( + await page.evaluate(() => + localStorage.getItem("codetrial.sessionPresets"), + ), + null, + ); + }, +); + +lobbyTest( + "a saved setup can be selected again after the lobby settings change", + async (page) => { + await lobby(page); + await page.click("#open-session-presets"); + await page.click("#save-session-preset"); + await page.fill("#session-preset-name", "Quick coding"); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await page.click("#close-session-presets"); + await page.click('[data-duration="30"]'); + + await page.click("#open-session-presets"); + assert.equal(await page.inputValue("#session-preset"), ""); + assert.equal( + await page.locator("#delete-session-preset").isDisabled(), + true, + ); + assert.equal( + await page + .locator("#delete-session-preset") + .evaluate((button) => getComputedStyle(button).opacity), + "0.6", + ); + await page.selectOption("#session-preset", "Quick coding"); + + assert.equal( + await page.locator('[data-duration="45"]').getAttribute("aria-pressed"), + "true", + ); + assert.equal( + await page.locator("#session-preset-status").textContent(), + "Applied Quick coding.", + ); + }, +); + +lobbyTest( + "saved session setups merge changes from another tab", + async (page) => { + const second = await page.context().newPage(); + await lobby(page); + await lobby(second); + + const save = async (target, name) => { + await target.click("#open-session-presets"); + await target.click("#save-session-preset"); + await target.fill("#session-preset-name", name); + await target + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await target + .locator("#session-preset-status") + .getByText(`Saved ${name}.`) + .waitFor(); + await target.click("#close-session-presets"); + }; + + await save(page, "First tab"); + await save(second, "Second tab"); + assert.deepEqual( + await page.evaluate(() => + JSON.parse(localStorage.getItem("codetrial.sessionPresets")).map( + ({ name }) => name, + ), + ), + ["First tab", "Second tab"], + ); + await page.click("#open-session-presets"); + await page.selectOption("#session-preset", "First tab"); + await page.click("#delete-session-preset"); + await page + .locator("#session-preset-status") + .getByText("Deleted First tab.") + .waitFor(); + await second.waitForFunction( + () => document.querySelectorAll("#session-preset option").length === 2, + ); + assert.deepEqual( + await second.evaluate(() => + JSON.parse(localStorage.getItem("codetrial.sessionPresets")).map( + ({ name }) => name, + ), + ), + ["Second tab"], + ); + }, +); + +lobbyTest( + "simultaneous session setup saves do not overwrite another tab", + async (page) => { + const second = await page.context().newPage(); + await lobby(page); + await lobby(second); + + for (const [target, name] of [ + [page, "First tab"], + [second, "Second tab"], + ]) { + await target.click("#open-session-presets"); + await target.click("#save-session-preset"); + await target.fill("#session-preset-name", name); + } + const arm = (target, own, other) => + target.evaluate( + ({ own, other }) => { + const originalGet = Storage.prototype.getItem; + const originalSet = Storage.prototype.setItem; + let armed = true; + Storage.prototype.getItem = function (key) { + if (key !== "codetrial.sessionPresets" || !armed) + return originalGet.call(this, key); + armed = false; + const snapshot = originalGet.call(this, key); + originalSet.call(this, `preset-read-${own}`, "ready"); + const deadline = performance.now() + 5_000; + while ( + originalGet.call(this, `preset-read-${other}`) !== "ready" && + performance.now() < deadline + ); + return snapshot; + }; + }, + { own, other }, + ); + await Promise.all([ + arm(page, "first", "second"), + arm(second, "second", "first"), + ]); + + const submit = (target) => + target + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await Promise.all([submit(page), submit(second)]); + await second + .locator("#session-preset-status") + .getByText("Saved Second tab.") + .waitFor(); + + assert.deepEqual( + await second.evaluate(() => + JSON.parse(localStorage.getItem("codetrial.sessionPresets")) + .map(({ name }) => name) + .sort(), + ), + ["First tab", "Second tab"], + ); + }, +); + +lobbyTest( + "saved setups do not require IndexedDB without Web Locks", + async (page) => { + await page.addInitScript(() => { + Object.defineProperty(Navigator.prototype, "locks", { + configurable: true, + get: () => undefined, + }); + IDBFactory.prototype.open = () => { + throw new DOMException("blocked", "SecurityError"); + }; + }); + await lobby(page); + await page.click("#open-session-presets"); + await page.click("#save-session-preset"); + await page.fill("#session-preset-name", "Unlocked setup"); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await page + .locator("#session-preset-status") + .getByText("Saved Unlocked setup.") + .waitFor(); + }, +); + +lobbyTest( + "a server limit below the offered lengths does not corrupt a saved setup", + async (page) => { + session.maxDurationMin = 10; + await lobby(page); + await page.click("#open-session-presets"); + await page.click("#save-session-preset"); + await page.fill("#session-preset-name", "Limited setup"); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await page + .locator("#session-preset-status") + .getByText("Saved Limited setup.") + .waitFor(); + assert.ok( + [30, 45, 60].includes( + await page.evaluate( + () => + JSON.parse(localStorage.getItem("codetrial.sessionPresets"))[0] + .settings.durationMin, + ), + ), + ); + + await page.reload({ waitUntil: "domcontentloaded" }); + await awaitReady(page); + await page.click("#open-session-presets"); + assert.deepEqual(await page.locator("#session-preset").inputValue(), ""); + assert.deepEqual( + await page.locator("#session-preset option").allTextContents(), + ["Choose a saved setup", "Limited setup"], + ); + await page.selectOption("#session-preset", "Limited setup"); + assert.equal((await snapshot(page)).duration, null); + assert.match( + await page.locator("#duration-note").textContent(), + /10 minutes/, + ); + }, +); + +lobbyTest( + "malformed saved setup storage stays unchanged when saving fails", + async (page) => { + await lobby(page); + for (const value of ["{", JSON.stringify({ presets: [] })]) { + await page.evaluate( + ([key, stored]) => localStorage.setItem(key, stored), + ["codetrial.sessionPresets", value], + ); + await page.reload({ waitUntil: "domcontentloaded" }); + await awaitReady(page); + await page.click("#open-session-presets"); + assert.equal(await page.locator("#session-preset").isHidden(), true); + assert.equal( + await page.locator("#save-session-preset").isEnabled(), + true, + ); + await page.click("#save-session-preset"); + await page.fill("#session-preset-name", "Do not overwrite"); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await page + .locator("#session-preset-status") + .getByText("This browser could not save the setup.") + .waitFor(); + assert.equal( + await page.evaluate(() => + localStorage.getItem("codetrial.sessionPresets"), + ), + value, + ); + await page.click("#close-session-presets"); + } + }, +); + +lobbyTest("last-used settings reject non-scalar choices", async (page) => { + await page.addInitScript(() => + localStorage.setItem( + "codetrial.interviewSettings", + JSON.stringify({ + durationMin: "30", + interviewLoop: ["coding_only"], + interviewMode: ["whiteboard"], + }), + ), + ); + await lobby(page); + assert.equal( + await page.locator('[data-duration="45"]').getAttribute("aria-pressed"), + "true", + ); + 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( + "saved session setups survive unavailable browser storage", + async (page) => { + await page.addInitScript(() => + localStorage.setItem( + "codetrial.sessionPresets", + JSON.stringify([ + { + name: "Kept setup", + settings: { + durationMin: 45, + interviewLoop: "coding_behavioral", + interviewMode: "coding", + exampleDrawing: false, + candidateLed: false, + editorOptions: {}, + }, + }, + ]), + ), + ); + await lobby(page); + await page.click("#open-session-presets"); + await page.evaluate(() => { + window.presetOriginalGetItem = Storage.prototype.getItem; + window.presetOriginalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value) { + if (key === "codetrial.sessionPresets") + throw new DOMException("blocked", "QuotaExceededError"); + return window.presetOriginalSetItem.call(this, key, value); + }; + }); + await page.click("#save-session-preset"); + await page.fill("#session-preset-name", "Blocked save"); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await page + .locator("#session-preset-status") + .getByText("This browser could not save the setup.") + .waitFor(); + assert.deepEqual( + await page.locator("#session-preset option").allTextContents(), + ["Choose a saved setup", "Kept setup"], + ); + assert.equal( + await page.locator("#session-preset-status").textContent(), + "This browser could not save the setup.", + ); + + await page.evaluate(() => { + Storage.prototype.getItem = window.presetOriginalGetItem; + Storage.prototype.setItem = window.presetOriginalSetItem; + }); + await page.selectOption("#session-preset", "Kept setup"); + await page.evaluate(() => { + Storage.prototype.setItem = function (key, value) { + if (key === "codetrial.sessionPresets") + throw new DOMException("blocked", "QuotaExceededError"); + return window.presetOriginalSetItem.call(this, key, value); + }; + }); + await page.click("#delete-session-preset"); + await page + .locator("#session-preset-status") + .getByText("This browser could not delete the setup.") + .waitFor(); + assert.deepEqual( + await page.locator("#session-preset option").allTextContents(), + ["Choose a saved setup", "Kept setup"], + ); + assert.equal( + await page.locator("#session-preset-status").textContent(), + "This browser could not delete the setup.", + ); + }, +); + +lobbyTest( + "a rejected Web Lock reports failed setup saves and deletes", + async (page) => { + await page.addInitScript(() => { + localStorage.setItem( + "codetrial.sessionPresets", + JSON.stringify([ + { + name: "Kept setup", + settings: { + durationMin: 45, + interviewLoop: "coding_behavioral", + interviewMode: "coding", + editorOptions: {}, + }, + }, + ]), + ); + Object.defineProperty(Navigator.prototype, "locks", { + configurable: true, + get: () => ({ + request: () => + Promise.reject(new DOMException("blocked", "SecurityError")), + }), + }); + }); + await lobby(page); + await page.click("#open-session-presets"); + await page.click("#save-session-preset"); + await page.fill("#session-preset-name", "Blocked save"); + await page + .locator("#session-preset-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + await page + .locator("#session-preset-status") + .getByText("This browser could not save the setup.") + .waitFor(); + + await page.selectOption("#session-preset", "Kept setup"); + await page.click("#delete-session-preset"); + await page + .locator("#session-preset-status") + .getByText("This browser could not delete the setup.") + .waitFor(); + assert.deepEqual( + await page.locator("#session-preset option").allTextContents(), + ["Choose a saved setup", "Kept setup"], + ); + }, +); + +lobbyTest( + "saved session setup dialog reports invalid names and preserves keyboard focus", + async (page) => { + await lobby(page); + assert.notEqual( + await page + .locator("#session-preset-status") + .evaluate((node) => getComputedStyle(node).display), + "none", + ); + await page.locator("#open-session-presets").press("Enter"); + assert.equal(await isFocused(page.locator("#save-session-preset")), true); + await page.locator("#save-session-preset").press("Enter"); + assert.equal(await isFocused(page.locator("#session-preset-name")), true); + await page.fill("#session-preset-name", " "); + await page.locator("#session-preset-name").press("Enter"); + assert.equal( + await page + .locator("#session-preset-name") + .evaluate((input) => input.validationMessage), + "Enter a preset name.", + ); + assert.equal( + await page.locator("#session-preset-status").textContent(), + "Enter a preset name.", + ); + await page.fill("#session-preset-name", "Half entered"); + await page.keyboard.press("Escape"); + await page.locator("#session-preset-form").waitFor({ state: "hidden" }); + await page.locator("#open-session-presets").press("Enter"); + assert.equal(await page.locator("#session-preset-form").isHidden(), true); + assert.equal(await page.inputValue("#session-preset-name"), ""); + assert.equal( + await page.locator("#session-preset-status").textContent(), + "", + ); + await page.locator("#save-session-preset").press("Enter"); + await page.fill("#session-preset-name", "Keyboard setup"); + assert.equal( + await page.locator("#session-preset-status").textContent(), + "", + ); + await page.locator("#session-preset-name").press("Enter"); + await page + .locator("#session-preset-status") + .getByText("Saved Keyboard setup.") + .waitFor(); + assert.equal(await isFocused(page.locator("#session-preset")), true); + await page.locator("#save-session-preset").press("Enter"); + assert.equal(await isFocused(page.locator("#session-preset-name")), true); + await page.locator("#cancel-session-preset").press("Enter"); + assert.equal(await isFocused(page.locator("#save-session-preset")), true); + await page.locator("#close-session-presets").press("Enter"); + await page.locator("#open-session-presets").press("Enter"); + assert.equal(await isFocused(page.locator("#session-preset")), true); + await page.locator("#session-preset").press("ArrowDown"); + await page.locator("#delete-session-preset").press("Enter"); + await page + .locator("#session-preset-status") + .getByText("Deleted Keyboard setup.") + .waitFor(); + assert.equal(await isFocused(page.locator("#save-session-preset")), true); + await page.locator("#close-session-presets").press("Enter"); + assert.equal(await isFocused(page.locator("#open-session-presets")), true); + }, +); + lobbyTest( "retry restores the saved loop over the current choice and explains a capped duration", async (page) => { diff --git a/web/app.js b/web/app.js index 9772a767..a58e1fa5 100644 --- a/web/app.js +++ b/web/app.js @@ -74,6 +74,8 @@ document.querySelector("#change-keys").hidden = globalThis.CODETRIAL_SETUP_ENABLED !== true; const SETTINGS_KEY = "codetrial.interviewSettings"; +const SESSION_PRESETS_KEY = "codetrial.sessionPresets"; +const SESSION_PRESETS_LOCK = "codetrial-session-presets"; const requestTimeoutMs = 10_000; const selection = document.querySelector("#selection-mode"); @@ -101,6 +103,8 @@ let keptDraw = null; let manualDuration = false; // Preserve the explicit preference independently of the effective server limit. let chosenDuration; +// Preserve the requested offered length when the server temporarily shortens it. +let requestedDuration; let manualDifficulty = false; let historyReady = false; let historyLoad = 0; @@ -143,6 +147,16 @@ const nodes = { editorOptions: document.querySelector(".editor-options-row"), exampleDrawing: document.querySelector("#enable-example-drawing"), candidateLed: document.querySelector("#enable-candidate-led"), + presetSelect: document.querySelector("#session-preset"), + presetDialog: document.querySelector("#session-presets-dialog"), + presetOpen: document.querySelector("#open-session-presets"), + presetClose: document.querySelector("#close-session-presets"), + presetSave: document.querySelector("#save-session-preset"), + presetDelete: document.querySelector("#delete-session-preset"), + presetForm: document.querySelector("#session-preset-form"), + presetName: document.querySelector("#session-preset-name"), + presetCancel: document.querySelector("#cancel-session-preset"), + presetStatus: document.querySelector("#session-preset-status"), accountStatus: document.querySelector("#account-status"), githubLogin: document.querySelector("#github-login"), loginLink: document.querySelector("#login-link"), @@ -324,6 +338,320 @@ function selectedEditorOptions() { ); } +function hasLobbyChoice(selector, dataName, value) { + if (typeof value !== "string" && typeof value !== "number") return false; + return [...document.querySelectorAll(selector)].some( + (button) => button.dataset[dataName] === String(value), + ); +} + +function normalizePreset(raw) { + if (!raw || typeof raw !== "object" || typeof raw.name !== "string") + return null; + const name = raw.name.trim().replace(/\s+/g, " "); + const settings = raw.settings; + if ( + !name || + name.length > 40 || + !settings || + typeof settings !== "object" || + Array.isArray(settings) + ) + return null; + const rawEditorOptions = + settings.editorOptions && + typeof settings.editorOptions === "object" && + !Array.isArray(settings.editorOptions) + ? settings.editorOptions + : {}; + const editorOptions = {}; + for (const input of editorControls) { + if (typeof rawEditorOptions[input.value] === "boolean") + editorOptions[input.value] = rawEditorOptions[input.value]; + } + const normalizedSettings = { editorOptions }; + if ( + Number.isInteger(settings.durationMin) && + hasLobbyChoice("[data-duration]", "duration", settings.durationMin) + ) + normalizedSettings.durationMin = settings.durationMin; + if ( + typeof settings.interviewLoop === "string" && + hasLobbyChoice("[data-loop]", "loop", settings.interviewLoop) + ) + normalizedSettings.interviewLoop = settings.interviewLoop; + if ( + typeof settings.interviewMode === "string" && + hasLobbyChoice("[data-mode]", "mode", settings.interviewMode) + ) + normalizedSettings.interviewMode = settings.interviewMode; + if (typeof settings.exampleDrawing === "boolean") + normalizedSettings.exampleDrawing = settings.exampleDrawing; + if (typeof settings.candidateLed === "boolean") + normalizedSettings.candidateLed = settings.candidateLed; + return { name, settings: normalizedSettings }; +} + +function readStoredSessionPresets() { + const storage = storageArea("localStorage"); + if (!storage) return null; + let raw; + try { + raw = storage.getItem(SESSION_PRESETS_KEY); + } catch { + return null; + } + if (raw === null) return []; + let stored; + try { + stored = JSON.parse(raw); + } catch { + return null; + } + if (!Array.isArray(stored)) return null; + return stored; +} + +function visibleSessionPresets(stored) { + const names = new Set(); + return stored.map(normalizePreset).filter((preset) => { + const key = preset?.name.toLocaleLowerCase(); + if (!preset || names.has(key)) return false; + names.add(key); + return true; + }); +} + +function readSessionPresets() { + const stored = readStoredSessionPresets(); + if (stored === null) return null; + return Array.isArray(stored) ? visibleSessionPresets(stored) : []; +} + +let sessionPresets = readSessionPresets() ?? []; +let presetMutationPending = false; +let lastSessionPreset = ""; + +function storeSessionPresets(presets) { + const value = JSON.stringify(presets); + writeStored(SESSION_PRESETS_KEY, value); + return readStored(SESSION_PRESETS_KEY) === value; +} + +function withSessionPresetsLock(callback) { + if (navigator.locks?.request) + return navigator.locks.request(SESSION_PRESETS_LOCK, callback); + return callback(); +} + +function savedPreset(name, settings) { + return normalizePreset({ name, settings }); +} + +function renderSessionPresets(selected = "") { + const option = (label, value = label) => { + const item = document.createElement("option"); + item.textContent = label; + item.value = value; + return item; + }; + const placeholder = option( + sessionPresets.length ? "Choose a saved setup" : "", + "", + ); + nodes.presetSelect.replaceChildren( + placeholder, + ...sessionPresets.map((preset) => option(preset.name)), + ); + nodes.presetSelect.value = sessionPresets.some( + (preset) => preset.name === selected, + ) + ? selected + : ""; + nodes.presetSelect.hidden = sessionPresets.length === 0; + nodes.presetDelete.hidden = sessionPresets.length === 0; + syncPresetControls(); +} + +function syncPresetControls() { + nodes.presetOpen.disabled = starting; + nodes.presetSelect.disabled = + starting || presetMutationPending || sessionPresets.length === 0; + nodes.presetSave.disabled = starting || presetMutationPending; + nodes.presetDelete.disabled = + starting || presetMutationPending || !nodes.presetSelect.value; +} + +function currentPreset() { + return { + durationMin: requestedDuration, + interviewLoop, + interviewMode: mode, + exampleDrawing: nodes.exampleDrawing.checked, + candidateLed: nodes.candidateLed.checked, + editorOptions: selectedEditorOptions(), + }; +} + +function applyPreset(settings) { + if (typeof settings.exampleDrawing === "boolean") + nodes.exampleDrawing.checked = settings.exampleDrawing; + if (typeof settings.candidateLed === "boolean") + nodes.candidateLed.checked = settings.candidateLed; + for (const input of editorControls) + if (typeof settings.editorOptions[input.value] === "boolean") + input.checked = settings.editorOptions[input.value]; + saveEditorOptions(selectedEditorOptions()); + if (Number.isInteger(settings.durationMin)) + setDuration(settings.durationMin, true); + if (typeof settings.interviewLoop === "string") + setInterviewLoop(settings.interviewLoop); + if (typeof settings.interviewMode === "string") + setInterviewMode(settings.interviewMode); + rememberSettings(); +} + +nodes.presetOpen.addEventListener("click", () => { + lastSessionPreset = nodes.presetSelect.value || lastSessionPreset; + nodes.presetSelect.value = ""; + syncPresetControls(); + nodes.presetDialog.showModal(); + (sessionPresets.length ? nodes.presetSelect : nodes.presetSave).focus(); +}); + +nodes.presetClose.addEventListener("click", () => { + nodes.presetDialog.close(); +}); + +nodes.presetDialog.addEventListener("close", () => { + nodes.presetForm.hidden = true; + nodes.presetName.value = ""; + nodes.presetName.setCustomValidity(""); + nodes.presetStatus.textContent = ""; +}); + +nodes.presetSelect.addEventListener("change", () => { + const preset = sessionPresets.find( + (candidate) => candidate.name === nodes.presetSelect.value, + ); + if (!preset) return syncPresetControls(); + lastSessionPreset = preset.name; + applyPreset(preset.settings); + nodes.presetStatus.textContent = `Applied ${preset.name}.`; + syncPresetControls(); +}); + +nodes.presetSave.addEventListener("click", () => { + nodes.presetForm.hidden = false; + nodes.presetName.value = nodes.presetSelect.value || lastSessionPreset; + nodes.presetName.setCustomValidity(""); + nodes.presetName.focus(); +}); + +nodes.presetCancel.addEventListener("click", () => { + nodes.presetForm.hidden = true; + nodes.presetName.value = ""; + nodes.presetName.setCustomValidity(""); + nodes.presetSave.focus(); +}); + +nodes.presetForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const name = nodes.presetName.value.trim().replace(/\s+/g, " "); + if (!name) { + nodes.presetName.setCustomValidity("Enter a preset name."); + nodes.presetName.reportValidity(); + nodes.presetStatus.textContent = "Enter a preset name."; + return nodes.presetName.focus(); + } + const settings = currentPreset(); + const selected = nodes.presetSelect.value; + presetMutationPending = true; + nodes.presetStatus.textContent = `Saving ${name}.`; + syncPresetControls(); + let saved = false; + try { + saved = await withSessionPresetsLock(() => { + const stored = readStoredSessionPresets(); + if (!Array.isArray(stored)) return false; + const key = name.toLocaleLowerCase(); + const index = stored.findIndex( + (preset) => normalizePreset(preset)?.name.toLocaleLowerCase() === key, + ); + const next = [...stored]; + const candidate = savedPreset(name, settings); + if (!candidate) return false; + if (index < 0) next.push(candidate); + else next[index] = candidate; + if (!storeSessionPresets(next)) { + sessionPresets = visibleSessionPresets(stored); + return false; + } + sessionPresets = visibleSessionPresets(next); + return true; + }); + } catch { + // The shared failure path below restores the controls and reports the error. + } finally { + presetMutationPending = false; + } + if (!saved) { + nodes.presetStatus.textContent = "This browser could not save the setup."; + renderSessionPresets(selected); + } else { + lastSessionPreset = name; + renderSessionPresets(name); + nodes.presetForm.hidden = true; + nodes.presetStatus.textContent = `Saved ${name}.`; + } + if (saved) nodes.presetSelect.focus(); +}); + +nodes.presetName.addEventListener("input", () => { + nodes.presetName.setCustomValidity(""); + if (nodes.presetStatus.textContent === "Enter a preset name.") + nodes.presetStatus.textContent = ""; +}); + +nodes.presetDelete.addEventListener("click", async () => { + const name = nodes.presetSelect.value; + presetMutationPending = true; + nodes.presetStatus.textContent = `Deleting ${name}.`; + syncPresetControls(); + let deleted = false; + try { + deleted = await withSessionPresetsLock(() => { + const stored = readStoredSessionPresets(); + if (!Array.isArray(stored)) return false; + const index = stored.findIndex( + (preset) => normalizePreset(preset)?.name === name, + ); + if (index < 0) return false; + const next = [...stored]; + next.splice(index, 1); + if (!storeSessionPresets(next)) { + sessionPresets = visibleSessionPresets(stored); + return false; + } + sessionPresets = visibleSessionPresets(next); + return true; + }); + } catch { + // The shared failure path below restores the controls and reports the error. + } finally { + presetMutationPending = false; + } + if (!deleted) { + nodes.presetStatus.textContent = "This browser could not delete the setup."; + renderSessionPresets(name); + } else { + if (lastSessionPreset === name) lastSessionPreset = ""; + renderSessionPresets(); + nodes.presetStatus.textContent = `Deleted ${name}.`; + } + if (deleted) nodes.presetSave.focus(); +}); + // A picked card is a choice about this one interview, not about the filter the // checkboxes carry, so it leaves them alone. Clicking the currently chosen card // again toggles it off, releasing the manual selection and falling back to the @@ -390,11 +718,18 @@ for (const card of cards) { } window.addEventListener("storage", (event) => { - if ( - (event.key !== PRACTICE_LIST_KEY && event.key !== null) || - event.storageArea !== storageArea("localStorage") - ) - return; + if (event.storageArea !== storageArea("localStorage")) return; + if (event.key === SESSION_PRESETS_KEY || event.key === null) { + const stored = readSessionPresets(); + if (stored !== null) { + const selected = nodes.presetSelect.value; + sessionPresets = stored; + if (!stored.some((preset) => preset.name === lastSessionPreset)) + lastSessionPreset = ""; + renderSessionPresets(selected); + } + } + if (event.key !== PRACTICE_LIST_KEY && event.key !== null) return; // Notifications can queue behind a newer write, including one by this tab. if (!refreshPracticeList()) return; practiceListSaved = true; @@ -721,6 +1056,7 @@ let signInFirst = false; let starting = false; restoreSettings(); +renderSessionPresets(); renderPracticeList(); selection.addEventListener("change", syncSelectionMode); @@ -997,6 +1333,7 @@ function syncLobbyControls() { // a press would do nothing. nodes.sequelSuggestionPick.disabled = !historyReady || starting || accountBusy || deletingReports; + syncPresetControls(); const deleteBlocked = reportDeletesBlocked(); nodes.deleteReports.disabled = deleteBlocked; for (const remove of nodes.attemptHistory.querySelectorAll( @@ -1800,6 +2137,7 @@ function restoreSettings() { function setDuration(minutes, chosen = false) { if (manualDuration && !chosen) return; manualDuration ||= chosen; + requestedDuration = minutes; if (chosen) chosenDuration = minutes; duration = underCeiling(minutes); select( diff --git a/web/index.html b/web/index.html index d5a043f8..8dc54dd4 100644 --- a/web/index.html +++ b/web/index.html @@ -1759,6 +1759,13 @@

Choose a problem

Set up your session

Choose the length, format and editor you want.

+

Session length

@@ -1892,6 +1899,73 @@

Interview format

Monospace font + +
+

Saved setups

+ +
+

+ Save the session settings in this browser. Problems and optional + context are not included. +

+
+ + + +
+ +

+
:not(header) { margin: 1.25rem 0 0; } +.session-presets { + width: min(32rem, calc(100% - 2rem)); + padding: 1.25rem; + border: 1px solid var(--hairline); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-card); +} + +.session-presets::backdrop { + background: color-mix(in srgb, var(--ink) 42%, transparent); +} + +.session-presets-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.session-presets-heading h3 { + margin: 0; +} + +.session-presets > p { + margin-top: 0.5rem; +} + +.session-presets-trigger { + position: absolute; + top: 0.25rem; + right: 0; + margin: 0; +} + +.session-panel .setup-heading { + position: relative; +} + +.session-panel .setup-heading > div { + padding-right: 4.5rem; +} + +.session-preset-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.session-presets > .session-preset-row { + margin-top: 0.75rem; +} + +.lobby #session-preset, +.lobby #session-preset-name { + flex: 1 1 10rem; + min-width: 0; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font: inherit; +} + +.session-preset-form { + margin-top: 0.75rem; +} + +.session-preset-form > label { + display: block; + margin-bottom: 0.35rem; + color: var(--sub); + font-size: 0.875rem; + font-weight: 600; +} + +.session-preset-button { + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--sub); + cursor: pointer; + font: inherit; + font-size: 0.875rem; + font-weight: 600; +} + +.session-preset-button:not(:disabled):hover { + border-color: var(--accent); +} + +.session-preset-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + /* Chips: a checkbox or a choice button. Selected ones are tinted, so the one solid purple control on the page is Start. */ .lobby .difficulty-row label,