Challenge files
+Read the prompt, inspect the SQL, then compare expected output.
++ These tabs load from the repository source of truth, so the viewer stays aligned with the validated challenge folders. +
+diff --git a/README.md b/README.md index 635f395..0e0bbca 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Short SQL case studies (SQLite/Postgres style): cleaning, aggregation, window fu | --- | --- | | Shareable project overview | [Project site](https://bmowville.github.io/sql-mini-challenges/) | | Find a challenge by topic | [Challenge finder](https://bmowville.github.io/sql-mini-challenges/#challenge-finder) | +| Read a challenge in the browser | [Challenge viewer](https://bmowville.github.io/sql-mini-challenges/challenge.html?id=038_inventory_stockout_risk) | | Try one challenge quickly | [001 Passenger survival](challenges/001_passenger_survival) | | Follow a structured path | [Learning paths](docs/learning-paths.md) | | Suggest a new challenge | [Challenge roadmap](docs/challenge-roadmap.md) | diff --git a/docs/challenge-finder.js b/docs/challenge-finder.js index ca160a7..7e91879 100644 --- a/docs/challenge-finder.js +++ b/docs/challenge-finder.js @@ -1,4 +1,4 @@ -const CHALLENGES = [ +window.SQL_CHALLENGES = [ { id: "001", title: "Passenger survival by class", folder: "001_passenger_survival", skill: "foundations", difficulty: "starter", focus: "Grouping, rates, and segmented aggregation." }, { id: "002", title: "Top customers by total spend", folder: "002_top_customers", skill: "foundations", difficulty: "starter", focus: "Ranking customer totals with window functions." }, { id: "003", title: "Customer retention", folder: "003_customer_retention", skill: "retention", difficulty: "starter", focus: "Repeat customers by month." }, @@ -41,7 +41,9 @@ const CHALLENGES = [ { id: "040", title: "Revenue leakage audit", folder: "040_revenue_leakage_audit", skill: "engineering", difficulty: "advanced", focus: "Invoice, payment, refund, and exception reconciliation." }, ]; -const SKILL_LABELS = { +const CHALLENGES = window.SQL_CHALLENGES; + +window.SQL_SKILL_LABELS = { all: "Any skill area", foundations: "Analytics foundations", retention: "Retention and cohorts", @@ -50,17 +52,25 @@ const SKILL_LABELS = { engineering: "Data engineering SQL", }; -const DIFFICULTY_LABELS = { +const SKILL_LABELS = window.SQL_SKILL_LABELS; + +window.SQL_DIFFICULTY_LABELS = { all: "Any difficulty", starter: "Starter", intermediate: "Intermediate", advanced: "Advanced", }; -function getChallengeUrl(challenge) { +const DIFFICULTY_LABELS = window.SQL_DIFFICULTY_LABELS; + +function getChallengeGitHubUrl(challenge) { return `https://github.com/Bmowville/sql-mini-challenges/tree/main/challenges/${challenge.folder}`; } +function getChallengeViewerUrl(challenge) { + return `challenge.html?id=${encodeURIComponent(challenge.folder)}`; +} + function getMatches(skill, difficulty) { return CHALLENGES.filter((challenge) => { const skillMatches = skill === "all" || challenge.skill === skill; @@ -82,7 +92,7 @@ function renderResults(results, summary, container) {
${challenge.focus}
` diff --git a/docs/challenge-viewer.js b/docs/challenge-viewer.js new file mode 100644 index 0000000..4d14e50 --- /dev/null +++ b/docs/challenge-viewer.js @@ -0,0 +1,254 @@ +const FILES = [ + { key: "readme", label: "README.md", file: "README.md", format: "markdown" }, + { key: "schema", label: "schema.sql", file: "schema.sql", format: "code" }, + { key: "solution", label: "solution.sql", file: "solution.sql", format: "code" }, + { key: "expected", label: "expected.json", file: "expected.json", format: "code" }, +]; + +const RAW_BASE = "https://raw.githubusercontent.com/Bmowville/sql-mini-challenges/main/challenges"; +const GITHUB_BASE = "https://github.com/Bmowville/sql-mini-challenges/tree/main/challenges"; + +function escapeHtml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderInlineMarkdown(value) { + return escapeHtml(value) + .replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1') + .replace(/`([^`]+)`/g, "$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1");
+}
+
+function closeList(html, listType) {
+ if (listType) {
+ html.push(`${listType}>`);
+ }
+ return null;
+}
+
+function renderMarkdown(markdown) {
+ const html = [];
+ const codeLines = [];
+ let inCode = false;
+ let listType = null;
+
+ for (const line of markdown.split("\n")) {
+ if (line.startsWith("```")) {
+ if (inCode) {
+ html.push(`${escapeHtml(codeLines.join("\n"))}`);
+ codeLines.length = 0;
+ inCode = false;
+ } else {
+ listType = closeList(html, listType);
+ inCode = true;
+ }
+ continue;
+ }
+
+ if (inCode) {
+ codeLines.push(line);
+ continue;
+ }
+
+ if (line.trim() === "") {
+ listType = closeList(html, listType);
+ continue;
+ }
+
+ if (line.startsWith("# ")) {
+ listType = closeList(html, listType);
+ html.push(`${renderInlineMarkdown(line)}
`); + } + + closeList(html, listType); + if (inCode) { + html.push(`${escapeHtml(codeLines.join("\n"))}`);
+ }
+
+ return html.join("\n");
+}
+
+function getRequestedChallenge() {
+ const params = new URLSearchParams(window.location.search);
+ const requested = (params.get("id") || params.get("challenge") || "").trim();
+ if (!requested) return window.SQL_CHALLENGES[0];
+
+ return window.SQL_CHALLENGES.find(
+ (challenge) => challenge.folder === requested || challenge.id === requested || challenge.folder.startsWith(`${requested}_`)
+ );
+}
+
+function initChallengeSelect(root, selectedChallenge) {
+ const select = root.querySelector("[data-challenge-select]");
+ select.innerHTML = window.SQL_CHALLENGES
+ .map((challenge) => ``)
+ .join("");
+ select.value = selectedChallenge.folder;
+ select.addEventListener("change", () => {
+ window.location.href = `challenge.html?id=${encodeURIComponent(select.value)}`;
+ });
+}
+
+function getFileUrl(challenge, file) {
+ return `${RAW_BASE}/${challenge.folder}/${file.file}`;
+}
+
+function getRunCommand(challenge) {
+ return `cat challenges/${challenge.folder}/schema.sql challenges/${challenge.folder}/solution.sql | sqlite3 -header -column :memory:
+
+Windows CMD:
+type challenges\\${challenge.folder}\\schema.sql challenges\\${challenge.folder}\\solution.sql | sqlite3 -header -column :memory:`;
+}
+
+async function copyText(value, button) {
+ try {
+ await navigator.clipboard.writeText(value);
+ } catch {
+ const textarea = document.createElement("textarea");
+ textarea.value = value;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "absolute";
+ textarea.style.left = "-9999px";
+ document.body.append(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ textarea.remove();
+ }
+
+ const original = button.textContent;
+ button.textContent = "Copied";
+ window.setTimeout(() => {
+ button.textContent = original;
+ }, 1400);
+}
+
+async function loadFile(challenge, file, state) {
+ const url = getFileUrl(challenge, file);
+ state.status.textContent = `Loading ${file.label}...`;
+ state.label.textContent = file.label;
+ state.rawLink.href = url;
+
+ try {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const text = await response.text();
+ state.currentText = text;
+ state.content.className = `viewer-content ${file.format === "markdown" ? "viewer-markdown" : "viewer-code"}`;
+ state.content.innerHTML = file.format === "markdown"
+ ? renderMarkdown(text)
+ : `${escapeHtml(text)}`;
+ state.status.textContent = `${file.label} loaded.`;
+ } catch (error) {
+ state.currentText = "";
+ state.content.className = "viewer-content viewer-empty";
+ state.content.innerHTML = `
+ Open the challenge folder on GitHub to inspect ${file.label} directly.
+ `; + state.status.textContent = `Could not load ${file.label}.`; + } +} + +function selectTab(key, state) { + const file = FILES.find((item) => item.key === key) || FILES[0]; + state.tabs.forEach((tab) => { + tab.setAttribute("aria-selected", String(tab.dataset.fileTab === file.key)); + }); + loadFile(state.challenge, file, state); +} + +function renderMissingChallenge(root) { + root.querySelector("[data-viewer-title]").textContent = "Challenge not found"; + root.querySelector("[data-viewer-focus]").textContent = "Use the finder to choose a validated SQL challenge."; + root.querySelector("[data-viewer-status]").textContent = "No matching challenge id was provided."; + root.querySelector("[data-file-content]").innerHTML = ` +The viewer expects a URL like challenge.html?id=038_inventory_stockout_risk.
Challenge files
++ These tabs load from the repository source of truth, so the viewer stays aligned with the validated challenge folders. +
+