Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Short SQL case studies (SQLite/Postgres style): cleaning, aggregation, window fu
| Goal | Where to go |
| --- | --- |
| 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) |
| 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) |
Expand Down
138 changes: 138 additions & 0 deletions docs/challenge-finder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const 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." },
{ id: "004", title: "Cohort retention", folder: "004_cohort_retention", skill: "retention", difficulty: "intermediate", focus: "Cohort offsets and activity rates." },
{ id: "005", title: "Daily revenue", folder: "005_daily_revenue", skill: "revenue", difficulty: "starter", focus: "Time-based revenue aggregation." },
{ id: "006", title: "Funnel conversion rates", folder: "006_funnel_conversion", skill: "behavior", difficulty: "starter", focus: "Event funnel counts and conversion rates." },
{ id: "007", title: "Category revenue share", folder: "007_category_revenue_share", skill: "revenue", difficulty: "starter", focus: "Percent-of-total analysis by category." },
{ id: "008", title: "Daily revenue with missing dates", folder: "008_daily_revenue_date_spine", skill: "revenue", difficulty: "intermediate", focus: "Date spines and rolling windows." },
{ id: "009", title: "Top products per category", folder: "009_top_products_per_category", skill: "foundations", difficulty: "intermediate", focus: "Top-N ranking within groups." },
{ id: "010", title: "Customer LTV", folder: "010_customer_ltv", skill: "foundations", difficulty: "starter", focus: "Customer-level spend, order count, and rank." },
{ id: "011", title: "Pareto customers", folder: "011_pareto_customers", skill: "revenue", difficulty: "intermediate", focus: "Revenue concentration and cumulative share." },
{ id: "012", title: "Product return rate", folder: "012_product_return_rate", skill: "behavior", difficulty: "starter", focus: "Product quality metrics from orders and returns." },
{ id: "013", title: "Monthly revenue growth", folder: "013_monthly_revenue_growth", skill: "revenue", difficulty: "intermediate", focus: "Month-over-month revenue change." },
{ id: "014", title: "Category monthly growth", folder: "014_category_monthly_growth", skill: "revenue", difficulty: "intermediate", focus: "Month-over-month growth by category." },
{ id: "015", title: "Sessionization", folder: "015_sessionization", skill: "behavior", difficulty: "advanced", focus: "Session boundaries from event timestamps." },
{ id: "016", title: "Monthly median order value", folder: "016_monthly_median_order_value", skill: "revenue", difficulty: "advanced", focus: "Median calculations with SQLite windows." },
{ id: "017", title: "Longest purchase streak", folder: "017_longest_purchase_streak", skill: "behavior", difficulty: "advanced", focus: "Gap-and-islands logic for consecutive months." },
{ id: "018", title: "Time to repeat purchase", folder: "018_time_to_repeat", skill: "behavior", difficulty: "intermediate", focus: "First and second order timing." },
{ id: "019", title: "Repeat purchase within 30 days", folder: "019_repeat_within_30d_cohort", skill: "retention", difficulty: "intermediate", focus: "Cohort repeat rates with a time window." },
{ id: "020", title: "Retention matrix", folder: "020_retention_matrix", skill: "retention", difficulty: "advanced", focus: "Cohort month by age matrix output." },
{ id: "021", title: "Churned customers", folder: "021_churned_customers", skill: "behavior", difficulty: "starter", focus: "Inactive customers based on recent orders." },
{ id: "022", title: "Customer reactivation", folder: "022_customer_reactivation", skill: "behavior", difficulty: "intermediate", focus: "Customers returning after inactivity." },
{ id: "023", title: "Weekly active users", folder: "023_weekly_active_users", skill: "behavior", difficulty: "starter", focus: "Weekly activity counts." },
{ id: "024", title: "RFM segmentation", folder: "024_rfm_segmentation", skill: "behavior", difficulty: "advanced", focus: "Recency, frequency, and monetary quartiles." },
{ id: "025", title: "Signup to first purchase", folder: "025_signup_to_first_purchase", skill: "behavior", difficulty: "intermediate", focus: "Lag from signup to first order." },
{ id: "026", title: "Repeat purchase within 30 days", folder: "026_repeat_purchase_30d", skill: "retention", difficulty: "intermediate", focus: "Repeat purchase rates by cohort." },
{ id: "027", title: "Weekly retention", folder: "027_weekly_retention", skill: "retention", difficulty: "intermediate", focus: "Week-over-week active customer retention." },
{ id: "028", title: "Rolling 7-day active users", folder: "028_rolling_7d_active_users", skill: "retention", difficulty: "advanced", focus: "Date spines, regions, and rolling activity." },
{ id: "029", title: "Cohort weekly retention", folder: "029_cohort_weekly_retention_rolling", skill: "retention", difficulty: "advanced", focus: "Rolling retention averages by cohort." },
{ id: "030", title: "Top customers per month", folder: "030_top_customers_monthly_ties", skill: "revenue", difficulty: "advanced", focus: "Monthly ties, rank changes, and share of revenue." },
{ id: "031", title: "Longest consecutive activity streak", folder: "031_longest_activity_streak", skill: "behavior", difficulty: "advanced", focus: "Gap-and-islands streak detection." },
{ id: "032", title: "Sessionization with inactivity gap", folder: "032_sessionization_30min", skill: "behavior", difficulty: "advanced", focus: "Thirty-minute inactivity session logic." },
{ id: "033", title: "Session funnel conversion", folder: "033_session_funnel_conversion", skill: "behavior", difficulty: "advanced", focus: "Session-level view, cart, and purchase funnels." },
{ id: "034", title: "Reactivation cohorts", folder: "034_reactivation_cohorts", skill: "retention", difficulty: "advanced", focus: "Reactivation after gaps and next-month retention." },
{ id: "035", title: "Subscription renewals and winback", folder: "035_subscription_renewal_winback", skill: "engineering", difficulty: "advanced", focus: "Renewal states, missed renewals, and winbacks." },
{ id: "036", title: "SCD Type 2 customer dimension", folder: "036_scd2_customer_dimension", skill: "engineering", difficulty: "advanced", focus: "History table design and effective dating." },
{ id: "037", title: "Incremental fact upsert", folder: "037_incremental_fact_upsert", skill: "engineering", difficulty: "advanced", focus: "Staging-to-fact insert and update patterns." },
];

const SKILL_LABELS = {
all: "Any skill area",
foundations: "Analytics foundations",
retention: "Retention and cohorts",
revenue: "Revenue and growth",
behavior: "Product and customer behavior",
engineering: "Data engineering SQL",
};

const DIFFICULTY_LABELS = {
all: "Any difficulty",
starter: "Starter",
intermediate: "Intermediate",
advanced: "Advanced",
};

function getChallengeUrl(challenge) {
return `https://github.com/Bmowville/sql-mini-challenges/tree/main/challenges/${challenge.folder}`;
}

function getMatches(skill, difficulty) {
return CHALLENGES.filter((challenge) => {
const skillMatches = skill === "all" || challenge.skill === skill;
const difficultyMatches = difficulty === "all" || challenge.difficulty === difficulty;
return skillMatches && difficultyMatches;
});
}

function renderResults(results, summary, container) {
container.innerHTML = results
.map(
(challenge) => `
<article class="result-card">
<div class="result-meta">
<span>${challenge.id}</span>
<span>${DIFFICULTY_LABELS[challenge.difficulty]}</span>
</div>
<h3>${challenge.title}</h3>
<p>${challenge.focus}</p>
<div class="result-footer">
<span>${SKILL_LABELS[challenge.skill]}</span>
<a href="${getChallengeUrl(challenge)}">Open challenge</a>
</div>
</article>
`
)
.join("");
summary.textContent = `${results.length} ${results.length === 1 ? "match" : "matches"} shown.`;
}

function renderEmpty(summary, container) {
summary.textContent = "No exact match yet.";
container.innerHTML = `
<article class="result-card empty-result">
<h3>Request a related challenge</h3>
<p>The roadmap is open for new analytics patterns. Suggest a challenge with the tables, question, and expected output you want to practice.</p>
<div class="result-footer">
<span>Contributor idea</span>
<a href="https://github.com/Bmowville/sql-mini-challenges/issues/new?template=challenge_request.yml">Open request form</a>
</div>
</article>
`;
}

function initChallengeFinder() {
const finder = document.querySelector("[data-challenge-finder]");
if (!finder) return;

const skillFilter = finder.querySelector("#skill-filter");
const difficultyFilter = finder.querySelector("#difficulty-filter");
const summary = finder.querySelector("[data-finder-summary]");
const resultsContainer = finder.querySelector("[data-finder-results]");
const findButton = finder.querySelector("[data-find-challenges]");
const randomButton = finder.querySelector("[data-random-challenge]");

const updateResults = () => {
const matches = getMatches(skillFilter.value, difficultyFilter.value).slice(0, 6);
if (matches.length === 0) {
renderEmpty(summary, resultsContainer);
return;
}
renderResults(matches, summary, resultsContainer);
};

findButton.addEventListener("click", updateResults);
skillFilter.addEventListener("change", updateResults);
difficultyFilter.addEventListener("change", updateResults);
randomButton.addEventListener("click", () => {
const matches = getMatches(skillFilter.value, difficultyFilter.value);
const pool = matches.length > 0 ? matches : CHALLENGES;
const challenge = pool[Math.floor(Math.random() * pool.length)];
renderResults([challenge], summary, resultsContainer);
});

updateResults();
}

initChallengeFinder();
43 changes: 43 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
>
<title>SQL Mini Challenges</title>
<link rel="stylesheet" href="site.css">
<script src="challenge-finder.js" defer></script>
</head>
<body>
<header class="site-header">
<nav class="nav" aria-label="Primary navigation">
<a class="brand" href="https://github.com/Bmowville/sql-mini-challenges">SQL Mini Challenges</a>
<div class="nav-links">
<a href="https://github.com/Bmowville/sql-mini-challenges#challenge-index">Challenges</a>
<a href="#challenge-finder">Finder</a>
<a href="https://github.com/Bmowville/sql-mini-challenges/blob/main/docs/learning-paths.md">Learning Paths</a>
<a href="https://github.com/Bmowville/sql-mini-challenges/blob/main/docs/challenge-roadmap.md">Roadmap</a>
<a href="https://github.com/Bmowville/sql-mini-challenges/releases/latest">Latest Release</a>
Expand All @@ -33,6 +35,7 @@ <h1 id="page-title">37 SQL analytics challenges with validated answers.</h1>
</p>
<div class="actions" aria-label="Primary links">
<a class="button primary" href="https://github.com/Bmowville/sql-mini-challenges">Open Repository</a>
<a class="button" href="#challenge-finder">Find a Challenge</a>
<a class="button" href="https://github.com/Bmowville/sql-mini-challenges#challenge-index">Browse Challenges</a>
<a class="button" href="https://github.com/Bmowville/sql-mini-challenges/blob/main/docs/challenge-roadmap.md">Suggest a Challenge</a>
</div>
Expand Down Expand Up @@ -68,6 +71,46 @@ <h1 id="page-title">37 SQL analytics challenges with validated answers.</h1>
</div>
</section>

<section class="content-section finder-section" id="challenge-finder" aria-labelledby="finder-heading">
<div class="section-heading">
<p class="eyebrow">Challenge finder</p>
<h2 id="finder-heading">Pick a problem that matches the skill you want to practice.</h2>
<p class="section-copy">
Filter the challenge set by topic and difficulty, then open one of the suggested folders with its schema, solution, and expected output.
</p>
</div>
<div class="finder-panel" data-challenge-finder>
<form class="finder-controls" aria-label="Challenge filters">
<label>
<span>Skill area</span>
<select id="skill-filter" name="skill">
<option value="all">Any skill area</option>
<option value="foundations">Analytics foundations</option>
<option value="retention">Retention and cohorts</option>
<option value="revenue">Revenue and growth</option>
<option value="behavior">Product and customer behavior</option>
<option value="engineering">Data engineering SQL</option>
</select>
</label>
<label>
<span>Difficulty</span>
<select id="difficulty-filter" name="difficulty">
<option value="all">Any difficulty</option>
<option value="starter">Starter</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</label>
<div class="finder-actions">
<button class="button primary" type="button" data-find-challenges>Find Matches</button>
<button class="button" type="button" data-random-challenge>Random Challenge</button>
</div>
</form>
<div class="finder-summary" data-finder-summary aria-live="polite"></div>
<div class="result-grid" data-finder-results></div>
</div>
</section>

<section class="content-section" aria-labelledby="paths-heading">
<div class="section-heading">
<p class="eyebrow">Study paths</p>
Expand Down
112 changes: 111 additions & 1 deletion docs/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,109 @@ code {
background: var(--surface);
}

.finder-section {
scroll-margin-top: 24px;
}

.section-copy {
max-width: 720px;
color: var(--muted);
}

.finder-panel {
padding: 24px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}

.finder-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 16px;
align-items: end;
}

.finder-controls label {
display: grid;
gap: 8px;
color: var(--muted);
font-size: 0.9rem;
font-weight: 650;
}

.finder-controls select {
width: 100%;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
}

.finder-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

.finder-actions .button {
cursor: pointer;
}

.finder-summary {
margin: 22px 0 14px;
color: var(--muted);
font-size: 0.95rem;
}

.result-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}

.result-card {
display: grid;
gap: 12px;
min-height: 220px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
}

.result-meta,
.result-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 10px;
color: var(--muted);
font-size: 0.84rem;
}

.result-footer {
align-self: end;
}

.result-footer a {
color: var(--accent);
font-weight: 650;
text-decoration: none;
}

.result-footer a:hover {
color: var(--accent-strong);
}

.empty-result {
grid-column: 1 / -1;
min-height: auto;
}

.split {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
Expand Down Expand Up @@ -296,9 +399,16 @@ code {
}

.metrics,
.path-grid {
.path-grid,
.result-grid,
.finder-controls {
grid-template-columns: 1fr;
}

.finder-actions .button {
width: 100%;
justify-content: center;
}
}

@media (prefers-color-scheme: dark) {
Expand Down
Loading