diff --git a/scripts/gen-problem-cards.py b/scripts/gen-problem-cards.py index e6134eac..59fbe110 100644 --- a/scripts/gen-problem-cards.py +++ b/scripts/gen-problem-cards.py @@ -19,6 +19,7 @@ # the only one that reads them. Run it first. PAGE_MAP = ROOT / "web" / "problem-pages.json" INDEX = ROOT / "web" / "index.html" +TOPIC_MAP = ROOT / "web" / "problem-topics.json" # Matched without their indentation, which the surrounding markup owns and has # already changed once: wrapping the grid in a `
` moved it two columns # right, and the six-space literals these used to be went on matching only @@ -37,18 +38,17 @@ def parse_args() -> argparse.Namespace: def read_json(path: Path) -> object: - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) def card(problem: dict[str, object], page: str, title: str) -> str: """One lobby card, named the way the interview will name the exercise. - Keyed by page name, with the scenario title and no topic tags: the - recommendation line reads the title off this card, and "Recommended: Coin - Change", a `coin-change` key or a "Dynamic Programming" tag tells the - candidate what they are about to be asked. The published title is not in - the page at all; the source slot is filled from the page map only when the - candidate turns that on to drill one problem by name. + Keyed by page name, with the scenario title the recommendation line reads. + A `coin-change` key, published title or "Dynamic Programming" tag tells the + candidate what they are about to be asked, so none ships in the initial + page. The published title and topic map are fetched only after the candidate + opts into the corresponding picker control. """ return "\n".join( [ @@ -61,8 +61,7 @@ def card(problem: dict[str, object], page: str, title: str) -> str: ) -def generated(problems: list) -> str: - pages = read_json(PAGE_MAP) +def generated(problems: list, pages: dict) -> str: cards = "\n".join( card(problem, pages[problem["id"]]["page"], pages[problem["id"]]["title"]) for problem in problems @@ -77,9 +76,18 @@ def generated(problems: list) -> str: ) +def generated_topics(problems: list, pages: dict) -> str: + """The opt-in topic filter's page-to-topic lookup.""" + mapping = { + pages[problem["id"]]["page"]: problem.get("topics", []) + for problem in problems + } + return json.dumps(mapping, ensure_ascii=False, indent=2) + "\n" + + def main() -> int: args = parse_args() - html_text = INDEX.read_text() + html_text = INDEX.read_text(encoding="utf-8") opening = re.search( rf"^([ \t]*){re.escape(START)}[ \t]*$", html_text, flags=re.MULTILINE ) @@ -101,20 +109,31 @@ def main() -> int: return 1 problems = read_json(SOURCE) - block = textwrap.indent(generated(problems), indent) + pages = read_json(PAGE_MAP) + block = textwrap.indent(generated(problems, pages), indent) updated = html_text[: opening.start()] + block + html_text[end.end() :] + topics = generated_topics(problems, pages) + current_topics = TOPIC_MAP.read_text(encoding="utf-8") if TOPIC_MAP.exists() else "" if args.check: + stale = [] if updated != html_text: + stale.append("web/index.html problem cards") + if topics != current_topics: + stale.append("web/problem-topics.json") + if stale: print( - "web/index.html problem cards are stale; run: python3 scripts/gen-problem-cards.py", + f"{', '.join(stale)} are stale; run: python3 scripts/gen-problem-cards.py", file=sys.stderr, ) return 1 return 0 if updated != html_text: - INDEX.write_text(updated) + INDEX.write_text(updated, encoding="utf-8") print(f"updated {len(problems)} problem cards in web/index.html") + if topics != current_topics: + TOPIC_MAP.write_text(topics, encoding="utf-8") + print(f"updated {len(problems)} problem topics in web/problem-topics.json") return 0 diff --git a/tests/browser/dom.js b/tests/browser/dom.js index 1adc03ca..c875e7d2 100644 --- a/tests/browser/dom.js +++ b/tests/browser/dom.js @@ -61,6 +61,7 @@ register("./dom-hooks.js", pathToFileURL(`${import.meta.dirname}/`)); /// properties that put a string on the page without going through `textContent`. const SPOKEN_ATTRIBUTES = ["title", "ariaLabel", "alt", "placeholder", "value"]; const MARKUP = ["innerHTML", "innerText", "outerHTML"]; +const FORM_CONTROLS = new Set(["button", "input", "select", "textarea"]); class Element { #text = ""; @@ -334,7 +335,20 @@ export function installDocument(markup) { // `#start` and `button#start` render into two different places, which is a // page a browser cannot produce. const node = (element) => { - if (!nodes.has(element)) nodes.set(element, new Element(element.tag)); + if (!nodes.has(element)) { + const created = new Element(element.tag); + for (const [name, value] of Object.entries(element.attributes)) { + if (name.startsWith("data-")) { + const key = name + .slice(5) + .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + created.dataset[key] = value; + } + } + if (FORM_CONTROLS.has(element.tag) && "value" in element.attributes) + created.value = element.attributes.value; + nodes.set(element, created); + } return nodes.get(element); }; globalThis.document = { diff --git a/tests/browser/lobby-render.test.js b/tests/browser/lobby-render.test.js index 16ab4f76..db2d7b1b 100644 --- a/tests/browser/lobby-render.test.js +++ b/tests/browser/lobby-render.test.js @@ -46,7 +46,6 @@ const SIGNED_OUT = { start: "Start interview", "progress-summary": "Could not load progress saved on this device.", }; - test("the lobby page loads outside a browser and says what it was given", () => { for (const [id, expected] of Object.entries(SIGNED_OUT)) { assert.deepEqual(dom.node(id).spoken(), [expected], `#${id}`); diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 2f82c9e6..2449a798 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -221,6 +221,7 @@ const markupDuration = () => /// The page name a card ships in the URL, from the map the generator writes. const pageOf = (problemId) => JSON.parse(read("web/problem-pages.json"))[problemId].page; +const TOPICS_BY_PAGE = JSON.parse(read("web/problem-topics.json")); const hired = (problemId) => ({ problemId, @@ -331,6 +332,13 @@ const restore = (page) => ), ); +async function openTopicFilter(page) { + await page.click("details.problem-picker summary"); + await page.waitForFunction( + () => !document.querySelector("#problem-topic").disabled, + ); +} + /// What the difficulty filter did to one card. const cardInfo = (page, id) => page.evaluate((problem) => { @@ -362,6 +370,204 @@ lobbyTest( { viewport: { width: 1280, height: 800 } }, ); +lobbyTest( + "topic and difficulty filters narrow the problem cards together", + async (page) => { + const requests = []; + page.on("request", (request) => + requests.push(new URL(request.url()).pathname), + ); + await lobby(page); + assert.equal( + requests.includes("/problem-topics.json"), + false, + "the initial lobby disclosed the topic map", + ); + assert.equal( + await page.locator("[data-topics]").count(), + 0, + "a problem card disclosed its topics in the HTML", + ); + assert.equal( + await page.locator("#problem-filter-summary").textContent(), + "", + "the untouched live region announced a count", + ); + await openTopicFilter(page); + assert.equal(requests.includes("/problem-topics.json"), true); + + await page.selectOption("#problem-topic", "Array"); + const filtered = await page + .locator("[data-problem]:visible") + .evaluateAll((cards) => + cards.map((card) => ({ + id: card.dataset.problem, + difficulty: card.dataset.difficulty, + })), + ); + assert.ok(filtered.length > 0, "the topic filter hid every problem"); + assert.ok( + filtered.every( + (card) => + card.difficulty === "Medium" && + TOPICS_BY_PAGE[card.id].includes("Array"), + ), + "a visible card did not match both filters", + ); + + await page.click("#problem-filters-reset"); + assert.ok( + (await page.locator("[data-problem]:visible").count()) > filtered.length, + "reset did not restore the other Medium problems", + ); + assert.equal( + await page.locator("#problem-filter-summary").textContent(), + "", + ); + }, +); + +lobbyTest("a topic choice does not survive a lobby restore", async (page) => { + await lobby(page); + await openTopicFilter(page); + await page.selectOption("#problem-topic", "Array"); + assert.match( + await page.locator("#problem-filter-summary").textContent(), + /tagged Array/, + ); + + await restore(page); + await awaitReady(page); + assert.equal(await page.locator("#problem-topic").inputValue(), ""); + assert.equal(await page.locator("#problem-filter-summary").textContent(), ""); +}); + +lobbyTest( + "a hidden manual pick does not survive the combined filters", + async (page) => { + const hardArray = Object.entries(TOPICS_BY_PAGE).find( + ([pageName, topics]) => + topics.includes("Array") && + read("web/index.html").includes( + `data-problem="${pageName}" data-difficulty="Hard"`, + ), + )[0]; + await lobby(page); + await openTopicFilter(page); + await page.evaluate((id) => { + const card = document.querySelector(`[data-problem="${id}"]`); + card.hidden = false; + card.click(); + }, hardArray); + + await page.selectOption("#problem-topic", "Array"); + const state = await snapshot(page); + assert.notEqual(state.card, hardArray); + assert.equal((await cardInfo(page, hardArray)).hidden, true); + assert.equal((await cardInfo(page, state.card)).hidden, false); + }, +); + +lobbyTest( + "a due review outside the selected topic remains the first priority", + async (page) => { + const due = pageOf("valid-parentheses"); + assert.equal(TOPICS_BY_PAGE[due].includes("Array"), false); + reports = [savedAttempt(due)]; + await lobby(page); + await openTopicFilter(page); + await page.selectOption("#problem-topic", "Array"); + + const state = await snapshot(page); + assert.equal(state.card, due); + assert.match(state.note, /Review due after/); + assert.equal((await cardInfo(page, due)).hidden, false); + }, +); + +lobbyTest( + "a completed topic and level are described without overstating progress", + async (page) => { + const markup = read("web/index.html"); + const mediumArray = Object.entries(TOPICS_BY_PAGE) + .filter( + ([pageName, topics]) => + topics.includes("Array") && + markup.includes( + `data-problem="${pageName}" data-difficulty="Medium"`, + ), + ) + .map(([pageName]) => pageName); + reports = mediumArray.map(hired); + await lobby(page); + await setLevel(page, "Medium", true); + await setLevel(page, "Easy", false); + await setLevel(page, "Hard", false); + await openTopicFilter(page); + await page.selectOption("#problem-topic", "Array"); + + assert.match( + (await snapshot(page)).note, + /You have passed every problem matching this topic and level\./, + ); + }, +); + +lobbyTest( + "recent assessed reports produce a practice snapshot", + async (page) => { + reports = [ + { + problemId: HARD[0], + createdAt: 40, + payload: { report: { incomplete: true, decision: "HIRE" } }, + }, + { + problemId: EASY[0], + createdAt: 30, + payload: { report: { decision: "NO_HIRE" } }, + }, + { + problemId: EASY[1], + createdAt: 20, + payload: { report: { decision: "HIRE" } }, + }, + { + problemId: MEDIUM[0], + createdAt: 10, + payload: { report: { decision: "NO_HIRE" } }, + }, + ]; + await lobby(page); + + assert.equal( + await page.locator("#recent-performance-summary").textContent(), + "Last 3 assessed interviews: 1 passed, 2 missed (33%).", + ); + assert.equal(await page.locator("#recent-performance").isHidden(), false); + }, +); + +lobbyTest( + "a failed report load hides the recent practice snapshot", + async (page) => { + failing = new Set(["/api/reports"]); + await page.goto(`${base}/`, { waitUntil: "domcontentloaded" }); + await settles( + page, + () => + document.querySelector("#progress-summary").textContent === + "Could not load saved account progress.", + ); + + assert.equal(await page.locator("#recent-performance").isHidden(), true); + assert.equal( + await page.locator("#recent-performance-summary").textContent(), + "", + ); + }, +); + lobbyTest( "a candidate who touches nothing gets the server's own default length", async (page) => { diff --git a/tests/browser/practice-insights.test.js b/tests/browser/practice-insights.test.js new file mode 100644 index 00000000..b85f63d9 --- /dev/null +++ b/tests/browser/practice-insights.test.js @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + availableTopics, + filterProblems, + recentPerformance, +} from "../../web/practice-insights.js"; + +const problems = [ + { id: "a", difficulty: "Easy", topics: ["Array", "Hash Table"] }, + { id: "b", difficulty: "Medium", topics: ["Array", "Sorting"] }, + { id: "c", difficulty: "Hard", topics: ["Graph"] }, +]; + +const report = (problemId, at, decision) => ({ + problemId, + at, + report: { + decision, + topics: problems.find((problem) => problem.id === problemId)?.topics ?? [], + }, +}); + +test("topics are unique and sorted", () => { + assert.deepEqual(availableTopics(problems), [ + "Array", + "Graph", + "Hash Table", + "Sorting", + ]); +}); + +test("difficulty and topic filters combine", () => { + assert.deepEqual( + filterProblems(problems, { + difficulties: new Set(["Medium", "Hard"]), + topic: "Array", + }).map((problem) => problem.id), + ["b"], + ); +}); + +test("recent performance summarizes assessed reports newest first", () => { + const reports = [ + report("a", 10, "NO_HIRE"), + report("b", 30, "HIRE"), + report("a", 20, "NO_HIRE"), + report("c", 40, "HIRE"), + ]; + + assert.deepEqual(recentPerformance(reports, 3), { + attempts: 3, + passes: 2, + misses: 1, + passRate: 67, + streak: 2, + latestDecision: "HIRE", + weakTopics: ["Hash Table"], + }); +}); + +test("recent performance ignores unscored and undated entries", () => { + assert.equal( + recentPerformance([ + { problemId: "a", at: null, report: { decision: "NO_HIRE" } }, + { problemId: "b", at: 20, report: { decision: "PENDING" } }, + ]), + null, + ); +}); + +test("recent performance ignores incomplete reports with a decision", () => { + assert.equal( + recentPerformance([ + { + problemId: "a", + at: 20, + report: { incomplete: true, decision: "HIRE", topics: ["Array"] }, + }, + ]), + null, + ); +}); + +test("topics with more misses than passes are highlighted", () => { + const snapshot = recentPerformance([ + report("a", 30, "NO_HIRE"), + report("a", 20, "NO_HIRE"), + report("b", 10, "HIRE"), + ]); + + assert.deepEqual(snapshot.weakTopics, ["Hash Table", "Array"]); +}); diff --git a/tests/browser/problem-picker.test.js b/tests/browser/problem-picker.test.js index 64d8fee3..7cbdebb5 100644 --- a/tests/browser/problem-picker.test.js +++ b/tests/browser/problem-picker.test.js @@ -145,6 +145,22 @@ test("a due review at a level the lobby moved past is still recommended", () => assert.equal(choice.review.intervalDays, 1); }); +test("a due review outside an opt-in filter stays the first priority", () => { + const now = 10 * day; + const filtered = bank.filter((problem) => problem.id !== "passed"); + const choice = pickProblem( + filtered, + new Set(["Easy"]), + [completed("passed", now - day)], + first, + now, + undefined, + bank, + ); + assert.equal(choice.picked.id, "passed"); + assert.equal(choice.review.intervalDays, 1); +}); + test("a failed review resets the interval", () => { const now = 10 * day; const choice = pickProblem( diff --git a/web/app.js b/web/app.js index 7a90d634..1ee83e6c 100644 --- a/web/app.js +++ b/web/app.js @@ -18,7 +18,12 @@ import { } from "./progress.js"; import { reportMarkdown, reportMarkup } from "./render.js"; import { downloadMarkdown, reportFilename } from "./download.js"; -import { loadPageMap } from "./problem-data.js"; +import { loadPageMap, loadTopicMap } from "./problem-data.js"; +import { + availableTopics, + filterProblems, + recentPerformance, +} from "./practice-insights.js"; import { parseGroundingFile, retainedSelection, @@ -104,6 +109,15 @@ const nodes = { groundingClear: document.querySelector("#grounding-clear"), groundingError: document.querySelector("#grounding-error"), durationNote: document.querySelector("#duration-note"), + problemTopic: document.querySelector("#problem-topic"), + problemPicker: document.querySelector("details.problem-picker"), + problemFiltersReset: document.querySelector("#problem-filters-reset"), + problemFilterSummary: document.querySelector("#problem-filter-summary"), + recentPerformance: document.querySelector("#recent-performance"), + recentPerformanceSummary: document.querySelector( + "#recent-performance-summary", + ), + recentWeakTopics: document.querySelector("#recent-weak-topics"), }; // Every card carries the pressed state from the start, not only the one that @@ -113,6 +127,7 @@ const cards = [...document.querySelectorAll("[data-problem]")].map( (button) => ({ id: button.dataset.problem, difficulty: button.dataset.difficulty, + topics: [], button, }), ); @@ -159,6 +174,36 @@ showSources.addEventListener("change", () => { }); void applySources(); const levels = [...document.querySelectorAll('[name="difficulty"]')]; +let topicLoad; +nodes.problemTopic.disabled = true; + +// A topic names the technique behind a scenario, so the page does not ship the +// full lookup. Opening the specific-problem picker opts into fetching it; no +// later visit inherits that choice. +async function loadTopics() { + topicLoad ??= loadTopicMap() + .then((mapping) => { + for (const card of cards) card.topics = mapping?.[card.id] ?? []; + for (const topic of availableTopics(cards)) { + const option = document.createElement("option"); + option.value = topic; + option.textContent = topic; + nodes.problemTopic.append(option); + } + nodes.problemTopic.disabled = false; + return true; + }) + .catch(() => { + nodes.problemFilterSummary.textContent = + "Topic filters could not be loaded."; + return false; + }); + return topicLoad; +} + +nodes.problemPicker.addEventListener("toggle", () => { + if (nodes.problemPicker.open) void loadTopics(); +}); // A picked card is a choice about this one interview, not about the filter the // checkboxes carry, so it leaves them alone. It suggests a length to go with @@ -213,6 +258,15 @@ for (const input of levels) { }); } +nodes.problemTopic.addEventListener("change", () => { + filterSelectionChanged(); +}); + +nodes.problemFiltersReset.addEventListener("click", () => { + nodes.problemTopic.value = ""; + filterSelectionChanged(); +}); + let grounding = { requirements: [], skills: [], anchors: [] }; const groundingReads = { jd: 0, resume: 0 }; @@ -420,6 +474,8 @@ window.addEventListener("pageshow", (event) => { // late enough to re-enable a button the candidate already pressed and hand // them a second navigation. if (!event.persisted) return; + nodes.problemTopic.value = ""; + applyProblemFilters(); // The cache holds the page as it was before the candidate left, and a login // recorded on the way out is in none of it, so restoring what it holds told // somebody who had just signed in to sign in. Ask the server instead, and @@ -870,11 +926,41 @@ function applyDifficulties() { const difficulties = selectedDifficulties(); // The picker is a shortcut to one problem, not a second copy of the wall the // checkboxes just hid, so it shows what the checkboxes selected. - for (const card of cards) - card.button.hidden = !difficulties.has(card.difficulty); + applyProblemFilters(difficulties); setDuration(suggestedDuration(difficulties)); } +function topicEligibleCards() { + return filterProblems(cards, { topic: nodes.problemTopic.value }); +} + +function applyProblemFilters(difficulties = selectedDifficulties()) { + const visible = new Set( + filterProblems(cards, { + difficulties, + topic: nodes.problemTopic.value, + }), + ); + for (const card of cards) card.button.hidden = !visible.has(card); + const topic = nodes.problemTopic.value; + nodes.problemFilterSummary.textContent = topic + ? `${visible.size} problem${visible.size === 1 ? "" : "s"} tagged ${topic} shown.` + : ""; +} + +function filterSelectionChanged() { + applyProblemFilters(); + if (manualProblem && !problem?.button.hidden) return; + manualProblem = false; + roll = Math.random(); + avoidedProblem = undefined; + if (historyReady) recommend(); + else { + setProblem(null); + nodes.recommendation.textContent = ""; + } +} + /// `note` is the sentence explaining a level the reports chose, empty when the /// candidate chose it themselves and so already knows. function recommend(note = "") { @@ -883,12 +969,13 @@ function recommend(note = "") { // they had just selected. if (manualProblem) return; const choice = pickProblem( - cards, + topicEligibleCards(), selectedDifficulties(), reports, () => roll, undefined, avoidedProblem, + cards, ); // Nothing to offer is still an answer, and it has to go through `setProblem` // like every other one. Returning here left whatever was picked for the @@ -912,7 +999,7 @@ function recommend(note = "") { return; } nodes.recommendation.textContent = choice.repeat - ? `${note}Selected problem: ${title(choice.picked)}. You have passed every problem at this level.` + ? `${note}Selected problem: ${title(choice.picked)}. ${nodes.problemTopic.value ? "You have passed every problem matching this topic and level." : "You have passed every problem at this level."}` : `${note}Selected problem: ${title(choice.picked)}.`; } @@ -1058,6 +1145,7 @@ function showProgressError(message) { reports = []; progressNormalized = []; renderPracticeFocus(); + hideRecentPerformance(); nodes.historyHeader.hidden = false; nodes.history.hidden = false; nodes.progressSummary.textContent = message; @@ -1069,6 +1157,7 @@ function showProgressError(message) { function showProgress(entries, suffix) { renderPracticeFocus(); + renderRecentPerformance(); // Normalized once here, not per render: the filters below only select from // these rows, so a dropdown change has nothing to re-sanitize. progressNormalized = normalizeProgressEntries(entries); @@ -1091,6 +1180,30 @@ function showProgress(entries, suffix) { renderProgress(); } +function hideRecentPerformance() { + nodes.recentPerformance.hidden = true; + nodes.recentPerformanceSummary.textContent = ""; + nodes.recentWeakTopics.textContent = ""; +} + +function renderRecentPerformance() { + const snapshot = recentPerformance(reports); + if (!snapshot) { + hideRecentPerformance(); + return; + } + const result = `${snapshot.passes} passed, ${snapshot.misses} missed`; + const streak = + snapshot.streak > 1 + ? ` Current ${snapshot.latestDecision === "HIRE" ? "pass" : "miss"} streak: ${snapshot.streak}.` + : ""; + nodes.recentPerformanceSummary.textContent = `Last ${snapshot.attempts} assessed interview${snapshot.attempts === 1 ? "" : "s"}: ${result} (${snapshot.passRate}%).${streak}`; + nodes.recentWeakTopics.textContent = snapshot.weakTopics.length + ? `Topics to revisit: ${snapshot.weakTopics.slice(0, 5).join(", ")}.` + : "No recurring weak topic in these interviews."; + nodes.recentPerformance.hidden = false; +} + /// `attempts` is what `progressModelFrom` already filtered, oldest first. /// /// Taken rather than re-derived: normalizing again here is what let the trends diff --git a/web/index.html b/web/index.html index c8a35caf..56b935bd 100644 --- a/web/index.html +++ b/web/index.html @@ -77,6 +77,19 @@

Practice a live technical interview

on each card, for drilling one by name. The interview names it in small print either way. +
+ Filter problems + + + +

+
@@ -949,6 +962,17 @@

Practice a live technical interview

+ +