From 99c3564fcf2c4cc287da65ea40683ea50640ce88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 09:43:10 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20phase=204-5=20=E2=80=94=20UX=20escalati?= =?UTF-8?q?on,=20a11y,=20responsive=20layout,=20store=20fixes,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite level 49 variance with AVG(x^2) - AVG(x)^2 identity (no more correlated subqueries); normalize seed-scaffold whitespace - Split levels.ts into per-epoch modules under src/data/levels/ - Fix three store bugs found while writing its tests: XP could be farmed by replaying completed levels, completing the final level advanced currentLevel past MAX_LEVEL, and goToPreviousLevel never consumed history (back button bounced between two levels) - Escalating disclosure: after 3 failed attempts reveal the expected result's shape (columns + row count), never its values - A11y: dialog roles/aria-modal/focus and Escape handling on the level-up modal and level navigator, aria-labels on icon buttons, aria-pressed on the schema/data tabs - Responsive: editor and schema panels stack full-width below lg - Docs: MIT LICENSE, CONTRIBUTING.md with level-authoring guide, README links, plan status update https://claude.ai/code/session_01RQVRtRAigoNCzJc37A5Ses --- CONTRIBUTING.md | 64 ++ IMPROVEMENT_PLAN.md | 19 +- LICENSE | 21 + README.md | 6 +- src/components/FlockStatus.tsx | 2 + src/components/GameProvider.tsx | 7 +- src/components/LevelNavigator.tsx | 15 + src/components/LevelUpModal.tsx | 6 + src/components/SQLPanel.tsx | 24 +- src/data/levels/advanced.ts | 316 ++++++++ src/data/{levels.ts => levels/expert.ts} | 965 +---------------------- src/data/levels/foundational.ts | 288 +++++++ src/data/levels/index.ts | 12 + src/data/levels/intermediate.ts | 376 +++++++++ src/lib/validator.ts | Bin 5566 -> 6106 bytes src/store/useGameStore.ts | 24 +- tests/store.test.ts | 98 +++ 17 files changed, 1286 insertions(+), 957 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 src/data/levels/advanced.ts rename src/data/{levels.ts => levels/expert.ts} (54%) create mode 100644 src/data/levels/foundational.ts create mode 100644 src/data/levels/index.ts create mode 100644 src/data/levels/intermediate.ts create mode 100644 tests/store.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b10438d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing to SQLwak + +Thanks for helping make SQLwak better! The most valuable contributions are new +levels, clearer task descriptions, and bug reports about queries that were +graded incorrectly. + +## Development + +```bash +npm install +npm run dev # http://localhost:3000 +``` + +Before opening a PR, make sure all four CI gates pass locally: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +## How validation works + +A level is solved when the user's query returns the same result as the level's +`solutionQuery`, both executed against the seeded in-memory SQLite database +(`src/lib/seed.ts`). The comparison (`src/lib/validator.ts`): + +- only permits a single read-only `SELECT`/`WITH` statement; +- requires row order **only** when the solution query has a top-level + `ORDER BY` (a per-level `orderMatters` field can override this); +- matches column names case-insensitively, strings exactly, and numbers with a + relative tolerance. + +## Authoring a level + +Levels live in `src/data/levels/` (one file per epoch, combined in +`index.ts`). When adding one: + +1. **Append to the correct epoch file** with the next sequential `id`. Epochs + must stay contiguous — the test suite enforces this. +2. **Write the description as a business request**, ending with an explicit + contract: which columns to return (use backticked names matching the + solution's aliases), and — if order matters — the exact ordering, phrased + like "ordered by `balance` descending". +3. **Only add `ORDER BY` to the solution when the description asks for it.** + If the description doesn't mention ordering, leave the solution unordered; + the validator then accepts any row order. +4. **`seedQuery`** is a scaffold with blanks, not a near-answer. Keep clause + keywords on their own lines with a single trailing space where the user + should type. +5. **`hint`** should name the technique (e.g. "use `LAG() OVER`"), not the + answer. +6. **Run `npm test`.** The suite executes every solution against the real + seed and fails if a solution errors, returns zero rows, or if a seed + scaffold already passes validation. + +If a level needs new data, extend `src/lib/seed.ts` — and check existing +levels still pass, since they share the seed. + +## Code style + +Match the surrounding code. UI work should respect the design principles in +`PRODUCT.md` (terminal-first, WCAG AA contrast, reduced-motion alternatives). diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md index b5ef000..855a291 100644 --- a/IMPROVEMENT_PLAN.md +++ b/IMPROVEMENT_PLAN.md @@ -25,9 +25,22 @@ Implemented in this PR: unused types, unused `p5` dependency, no-op callbacks, and the `sqlawk` name typo removed (3.3); seed extracted to `src/lib/seed.ts` for Node test reuse. -Still open: level 49 simplification and seed-blank audit (1.6), store tests (2.4), -`levels.ts` split and build-time expected results (3.4), Pillar 4 UX/a11y work, and Pillar 5 -docs (LICENSE choice is the owner's call). +Implemented in the follow-up PR: + +- **Remainder of 1.6:** level 49 rewritten with the `AVG(x²) − AVG(x)²` variance identity + (no more correlated subqueries); seed-scaffold whitespace normalized. +- **2.4:** store tests for XP accrual, streak, navigation history, and reset — which surfaced + and fixed three real bugs (XP farming by replaying levels, `currentLevel` advancing past the + final level, and back-navigation never consuming history). +- **3.4 (partial):** `levels.ts` split into per-epoch modules under `src/data/levels/`. +- **Pillar 4:** escalating disclosure (expected result *shape* revealed after 3 failed + attempts), dialog semantics/focus/Escape handling for the modal and level navigator, + aria-labels on icon buttons, and a stacking responsive layout below the `lg` breakpoint. +- **Pillar 5:** MIT LICENSE, CONTRIBUTING.md with a level-authoring guide. + +Still open: build-time expected results (3.4), the expected-vs-actual value diff (4.1 — the +shape reveal shipped; a value-level diff is a deliberate product decision), and a deeper +mobile/touch pass (4.4). --- diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..779160e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 martinl5 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bdbda46..a136ef3 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,11 @@ npm run build # Production build ## Contributing -Issues and PRs welcome! Ideas for new levels, challenge themes, or schema additions are especially appreciated. +Issues and PRs welcome! Ideas for new levels, challenge themes, or schema additions are especially appreciated — see [CONTRIBUTING.md](CONTRIBUTING.md) for the level-authoring guide. + +## License + +[MIT](LICENSE) --- diff --git a/src/components/FlockStatus.tsx b/src/components/FlockStatus.tsx index 4dbacda..afbd46c 100644 --- a/src/components/FlockStatus.tsx +++ b/src/components/FlockStatus.tsx @@ -32,6 +32,7 @@ export default function FlockStatus({ onOpenLevelNavigator }: FlockStatusProps) className="p-1.5 transition-colors hover:opacity-70" style={{ color: 'var(--lcb-muted)', background: 'rgba(255,255,255,0.04)', border: '1px solid var(--lcb-border)', borderRadius: 4 }} title="Browse levels" + aria-label="Browse levels" > @@ -47,6 +48,7 @@ export default function FlockStatus({ onOpenLevelNavigator }: FlockStatusProps) cursor: canGoBack ? 'pointer' : 'not-allowed', }} title="Previous level" + aria-label="Go to previous level" > diff --git a/src/components/GameProvider.tsx b/src/components/GameProvider.tsx index 515d802..494ad4d 100644 --- a/src/components/GameProvider.tsx +++ b/src/components/GameProvider.tsx @@ -218,14 +218,14 @@ export default function GameProvider() { )} {/* UI overlay */} -
+
{/* Left: SQL Editor */} -
+
{/* Right: Schema / Data + status */} -
+
{/* Tab bar */}
setActiveRightTab(tab)} + aria-pressed={activeRightTab === tab} className="flex-1 py-2 text-xs tracking-widest uppercase transition-colors" style={{ fontFamily: 'var(--font-ibm-plex-mono)', diff --git a/src/components/LevelNavigator.tsx b/src/components/LevelNavigator.tsx index 662c754..332db29 100644 --- a/src/components/LevelNavigator.tsx +++ b/src/components/LevelNavigator.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import { useGameStore } from '@/store/useGameStore'; import { levels } from '@/data/levels'; import { Check, X } from 'lucide-react'; @@ -21,6 +22,13 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps) const handleSelect = (id: number) => { setCurrentLevel(id); onClose(); }; + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, onClose]); + if (!isOpen) return null; const epochs = ['Foundational', 'Intermediate', 'Advanced', 'Expert'] as const; @@ -36,6 +44,9 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps) {/* Drawer */}
@@ -54,6 +65,8 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps)
{/* ── Quick snippets ───────────────────────────────────────────────── */} diff --git a/src/data/levels/advanced.ts b/src/data/levels/advanced.ts new file mode 100644 index 0000000..43f2e03 --- /dev/null +++ b/src/data/levels/advanced.ts @@ -0,0 +1,316 @@ +import type { Level } from '@/types'; + +export const advancedLevels: Level[] = [ + { + id: 31, + title: 'Running Cumulative Deposits', + description: `Treasury wants to see the cumulative total of account balances ordered from highest to lowest — a running total window function. + +Return \`account_id\`, \`balance\`, and \`running_total\` using SUM() OVER, ordered by balance descending.`, + hint: 'Use SUM(balance) OVER (ORDER BY balance DESC) as running_total.', + seedQuery: `SELECT account_id, + balance, + SUM(balance) OVER (ORDER BY ) AS running_total + FROM accounts + ORDER BY balance DESC`, + solutionQuery: `SELECT account_id, + balance, + SUM(balance) OVER (ORDER BY balance DESC) AS running_total + FROM accounts + ORDER BY balance DESC`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 32, + title: 'Rank Customers Within Segment', + description: `Private Banking wants to rank customers by balance within each segment to identify the top-ranked client per tier. + +Return \`customer_name\`, \`segment\`, \`balance\`, and \`rank_in_segment\` using RANK() OVER PARTITION BY segment, ordered by segment then rank.`, + hint: 'Use RANK() OVER (PARTITION BY c.segment ORDER BY a.balance DESC).', + seedQuery: `SELECT + FROM accounts a + JOIN customers c ON + ORDER BY `, + solutionQuery: `SELECT c.customer_name, + c.segment, + a.balance, + RANK() OVER ( + PARTITION BY c.segment + ORDER BY a.balance DESC + ) AS rank_in_segment + FROM accounts a + JOIN customers c ON a.customer_id = c.customer_id + ORDER BY c.segment, rank_in_segment`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 33, + title: 'Month-over-Month Transaction Growth', + description: `Finance tracks monthly transaction growth using LAG() to compare each month's volume to the prior month. + +Return \`month\`, \`total_amount\`, \`prev_month_amount\`, and \`growth_pct\` (rounded to 2dp), ordered by month.`, + hint: 'Wrap a GROUP BY subquery, then apply LAG(total_amount) OVER (ORDER BY month) in the outer query.', + seedQuery: `SELECT month, + total_amount, + LAG(total_amount) OVER (ORDER BY month) AS prev_month_amount, + AS growth_pct + FROM ( + SELECT AS month, + AS total_amount + FROM transactions + GROUP BY + ) + ORDER BY month`, + solutionQuery: `SELECT month, + total_amount, + LAG(total_amount) OVER (ORDER BY month) AS prev_month_amount, + ROUND( + (total_amount - LAG(total_amount) OVER (ORDER BY month)) + / LAG(total_amount) OVER (ORDER BY month) * 100 + , 2) AS growth_pct + FROM ( + SELECT strftime('%Y-%m', transaction_date) AS month, + ROUND(SUM(amount), 2) AS total_amount + FROM transactions + GROUP BY strftime('%Y-%m', transaction_date) + ) + ORDER BY month`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 34, + title: 'High-Balance Accounts CTE', + description: `Using a CTE, first identify all active accounts with a balance above SGD 50,000, then summarise them by customer segment. + +Return \`segment\`, \`account_count\`, and \`avg_balance\` (2dp), ordered by avg_balance descending.`, + hint: 'Define a WITH high_balance AS (...) CTE that filters balance > 50000 and joins customers, then aggregate in the outer query.', + seedQuery: `WITH high_balance AS ( + SELECT + FROM accounts a + JOIN customers c ON + WHERE +) +SELECT + FROM high_balance + GROUP BY + ORDER BY `, + solutionQuery: `WITH high_balance AS ( + SELECT a.account_id, a.balance, c.segment + FROM accounts a + JOIN customers c ON a.customer_id = c.customer_id + WHERE a.balance > 50000 + AND a.status = 'Active' +) +SELECT segment, + COUNT(*) AS account_count, + ROUND(AVG(balance), 2) AS avg_balance + FROM high_balance + GROUP BY segment + ORDER BY avg_balance DESC`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 35, + title: 'At-Risk Loan Customers CTE', + description: `Using a CTE, isolate loans that are in Default or carry a risk grade of D, then return the full customer and loan detail for immediate escalation. + +Return \`customer_name\`, \`segment\`, \`principal_amount\`, \`interest_rate\`, \`risk_grade\`, and \`status\`, ordered by principal_amount descending.`, + hint: 'Build a WITH at_risk AS (...) CTE joining loans and customers, filtering by status = Default OR risk_grade = D.', + seedQuery: `WITH at_risk AS ( + SELECT + FROM loans l + JOIN customers c ON + WHERE +) +SELECT + FROM at_risk + ORDER BY `, + solutionQuery: `WITH at_risk AS ( + SELECT l.loan_id, l.principal_amount, l.interest_rate, l.risk_grade, l.status, + c.customer_name, c.segment + FROM loans l + JOIN customers c ON l.customer_id = c.customer_id + WHERE l.status = 'Default' + OR l.risk_grade = 'D' +) +SELECT customer_name, + segment, + principal_amount, + interest_rate, + risk_grade, + status + FROM at_risk + ORDER BY principal_amount DESC`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 36, + title: 'Balance Quartiles with NTILE', + description: `Wealth segmentation: bucket all active account holders into four equal quartiles by balance using NTILE(4), so the top 25% can be targeted for Private Banking upgrade. + +Return \`customer_name\`, \`balance\`, and \`quartile\`, ordered by balance descending.`, + hint: 'Use NTILE(4) OVER (ORDER BY a.balance DESC) as quartile.', + seedQuery: `SELECT + FROM accounts a + JOIN customers c ON + WHERE + ORDER BY `, + solutionQuery: `SELECT c.customer_name, + a.balance, + NTILE(4) OVER (ORDER BY a.balance DESC) AS quartile + FROM accounts a + JOIN customers c ON a.customer_id = c.customer_id + WHERE a.status = 'Active' + ORDER BY a.balance DESC`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 37, + title: 'First Transaction per Account', + description: `Onboarding analytics: find each account's very first transaction using ROW_NUMBER() partitioned by account. + +Return \`account_id\`, \`transaction_date\`, \`amount\`, and \`merchant_category\` for only the first transaction per account, ordered by account_id.`, + hint: 'Assign ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY transaction_date), then filter WHERE rn = 1 in an outer query.', + seedQuery: `SELECT account_id, + transaction_date, + amount, + merchant_category + FROM ( + SELECT + ROW_NUMBER() OVER ( + PARTITION BY + ORDER BY + ) AS rn + FROM transactions + ) + WHERE rn = 1 + ORDER BY account_id`, + solutionQuery: `SELECT account_id, + transaction_date, + amount, + merchant_category + FROM ( + SELECT account_id, + transaction_date, + amount, + merchant_category, + ROW_NUMBER() OVER ( + PARTITION BY account_id + ORDER BY transaction_date + ) AS rn + FROM transactions + ) + WHERE rn = 1 + ORDER BY account_id`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 38, + title: 'Account Age in Days', + description: `Retention analysis: calculate how many days each active account has been open as of 30 Jun 2024, to identify long-tenured customers for loyalty rewards. + +Return \`account_id\`, \`customer_name\`, \`opened_date\`, and \`days_open\`, ordered by days_open descending.`, + hint: 'Use CAST(julianday(\'2024-06-30\') - julianday(opened_date) AS INTEGER) AS days_open.', + seedQuery: `SELECT + FROM accounts a + JOIN customers c ON + ORDER BY `, + solutionQuery: `SELECT a.account_id, + c.customer_name, + a.opened_date, + CAST(julianday('2024-06-30') - julianday(a.opened_date) AS INTEGER) AS days_open + FROM accounts a + JOIN customers c ON a.customer_id = c.customer_id + ORDER BY days_open DESC`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 39, + title: 'Transactions Above Personal Average', + description: `Fraud pre-screening: flag transactions where the amount exceeds that customer's own average transaction value — an early signal of unusual activity. + +Return \`transaction_id\`, \`customer_name\`, \`amount\`, and \`merchant_category\` for such transactions, ordered by amount descending. Limit 20 rows.`, + hint: 'Use a correlated subquery in the WHERE clause: WHERE t.amount > (SELECT AVG(...) WHERE account belongs to same customer).', + seedQuery: `SELECT + FROM transactions t + JOIN accounts a ON + JOIN customers c ON + WHERE t.amount > ( + SELECT + FROM transactions t2 + JOIN accounts a2 ON + WHERE + ) + ORDER BY + LIMIT 20`, + solutionQuery: `SELECT t.transaction_id, + c.customer_name, + t.amount, + t.merchant_category + FROM transactions t + JOIN accounts a ON t.account_id = a.account_id + JOIN customers c ON a.customer_id = c.customer_id + WHERE t.amount > ( + SELECT AVG(t2.amount) + FROM transactions t2 + JOIN accounts a2 ON t2.account_id = a2.account_id + WHERE a2.customer_id = a.customer_id + ) + ORDER BY t.amount DESC + LIMIT 20`, + epoch: 'Advanced', + difficulty: 3, + }, + + { + id: 40, + title: 'Investment Cross-Sell Targets', + description: `Cross-sell opportunity: identify customers who hold an active account but have never opened an Investment-type product — prime targets for the LCB CPF / Wealth Management pitch. + +Return \`customer_name\`, \`segment\`, and \`credit_score\`, ordered by credit_score descending.`, + hint: 'Use NOT IN with a subquery that finds customer_ids who have an Investment product in their accounts.', + seedQuery: `SELECT + FROM customers c + WHERE c.customer_id NOT IN ( + SELECT + FROM accounts a + JOIN products p ON + WHERE + ) + ORDER BY `, + solutionQuery: `SELECT c.customer_name, + c.segment, + c.credit_score + FROM customers c + WHERE c.customer_id NOT IN ( + SELECT DISTINCT a.customer_id + FROM accounts a + JOIN products p ON a.product_id = p.product_id + WHERE p.product_type = 'Investment' + ) + ORDER BY c.credit_score DESC`, + epoch: 'Advanced', + difficulty: 3, + }, + + // ============================================================ + // EXPERT (Levels 41–50) + // Recursive CTEs, Advanced Windows, Multi-CTE Analysis + // ============================================================ +]; diff --git a/src/data/levels.ts b/src/data/levels/expert.ts similarity index 54% rename from src/data/levels.ts rename to src/data/levels/expert.ts index bc9603e..f4f15fd 100644 --- a/src/data/levels.ts +++ b/src/data/levels/expert.ts @@ -1,941 +1,6 @@ import type { Level } from '@/types'; -export const levels: Level[] = [ - // ============================================================ - // FOUNDATIONAL (Levels 1–15) - // Basic SQL: SELECT, WHERE, COUNT, SUM, AVG, LIKE, ORDER BY - // ============================================================ - { - id: 1, - title: 'Priority Banking Customers', - description: `The Relationship Management team needs a list of all LCB Priority segment customers to prepare for the quarterly review. - -Return the \`customer_name\` and \`segment\` for every customer in the \`Priority\` segment.`, - hint: 'SELECT from customers WHERE segment equals the Priority segment.', - seedQuery: `SELECT - FROM customers - WHERE `, - solutionQuery: `SELECT customer_name, - segment - FROM customers - WHERE segment = 'Priority'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 2, - title: 'Total Customer Count', - description: `Compliance requires the total number of unique customers registered in the LCB system for the MAS regulatory report. - -Return a single value aliased as \`total_customers\`.`, - hint: 'Use COUNT(DISTINCT customer_id) on the customers table.', - seedQuery: `SELECT - FROM customers`, - solutionQuery: `SELECT COUNT(DISTINCT customer_id) AS total_customers - FROM customers`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 3, - title: 'Central Region Branches', - description: `The Operations team is planning a Central region audit. List all LCB branches located in the \`Central\` region. - -Return \`branch_name\` and \`branch_type\`.`, - hint: 'Filter the branches table WHERE region equals Central.', - seedQuery: `SELECT - FROM branches - WHERE `, - solutionQuery: `SELECT branch_name, - branch_type - FROM branches - WHERE region = 'Central'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 4, - title: 'CNY Period Transactions', - description: `Risk Management wants to analyse spending patterns during the Chinese New Year festive period in February 2024. - -Return \`transaction_id\`, \`transaction_date\`, and \`merchant_category\` for all transactions between 1 Feb and 29 Feb 2024.`, - hint: 'Use BETWEEN on transaction_date in the transactions table.', - seedQuery: `SELECT - FROM transactions - WHERE `, - solutionQuery: `SELECT transaction_id, - transaction_date, - merchant_category - FROM transactions - WHERE transaction_date BETWEEN '2024-02-01' AND '2024-02-29'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 5, - title: 'PayNow Transfers', - description: `The Digital Banking team needs a list of all transactions conducted through the \`PayNow\` channel to measure its adoption. - -Return \`transaction_id\`, \`amount\`, and \`transaction_date\`.`, - hint: 'Filter transactions WHERE channel equals PayNow.', - seedQuery: `SELECT - FROM transactions - WHERE `, - solutionQuery: `SELECT transaction_id, - amount, - transaction_date - FROM transactions - WHERE channel = 'PayNow'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 6, - title: 'Total Salary Credits', - description: `Treasury wants the aggregate value of all salary credits flowing into LCB accounts — a key indicator of customer payroll dependency. - -Return a single value aliased as \`total_salary_credits\` for all transactions where \`merchant_category\` is \`Salary Credit\`.`, - hint: 'SUM the amount column filtered by merchant_category = Salary Credit.', - seedQuery: `SELECT - FROM transactions - WHERE `, - solutionQuery: `SELECT SUM(amount) AS total_salary_credits - FROM transactions - WHERE merchant_category = 'Salary Credit'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 7, - title: 'Average Customer Credit Score', - description: `The Credit Risk team needs the mean credit score across all LCB customers to benchmark against MAS guidelines. - -Return a single value aliased as \`avg_credit_score\`.`, - hint: 'Use AVG(credit_score) on the customers table.', - seedQuery: `SELECT - FROM customers`, - solutionQuery: `SELECT AVG(credit_score) AS avg_credit_score - FROM customers`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 8, - title: 'Dormant Accounts', - description: `Operations must identify dormant accounts for the annual account dormancy review process. - -Return \`account_id\`, \`customer_id\`, and \`balance\` for all accounts with a status of \`Dormant\`.`, - hint: 'Filter the accounts table WHERE status equals Dormant.', - seedQuery: `SELECT - FROM accounts - WHERE `, - solutionQuery: `SELECT account_id, - customer_id, - balance - FROM accounts - WHERE status = 'Dormant'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 9, - title: 'CPF-Linked Products', - description: `Wealth Management wants to list all products connected to the CPF (Central Provident Fund) investment scheme. - -Return \`product_name\`, \`product_type\`, and \`interest_rate\` for all products whose name contains the text \`CPF\`.`, - hint: 'Use a LIKE filter with % wildcards on product_name.', - seedQuery: `SELECT - FROM products - WHERE `, - solutionQuery: `SELECT product_name, - product_type, - interest_rate - FROM products - WHERE product_name LIKE '%CPF%'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 10, - title: 'Home Loan Products', - description: `The Mortgage desk needs a catalogue of all LCB Home Loan products to include in the Q2 product brochure. - -Return \`product_name\`, \`product_type\`, and \`interest_rate\` for all products whose name contains \`Home Loan\`.`, - hint: 'Filter products with a LIKE pattern matching Home Loan in the product_name.', - seedQuery: `SELECT - FROM products - WHERE `, - solutionQuery: `SELECT product_name, - product_type, - interest_rate - FROM products - WHERE product_name LIKE '%Home Loan%'`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 11, - title: 'Accounts Without a Loan', - description: `Cross-sell targeting: find accounts whose customers do NOT currently hold any loan with LCB. These are candidates for the new home loan campaign. - -Return \`account_id\`, \`balance\`, and \`status\` from accounts where the customer has no matching row in loans.`, - hint: 'Use a LEFT JOIN between accounts and loans on customer_id, then filter WHERE the loan side IS NULL.', - seedQuery: `SELECT - FROM accounts a - LEFT JOIN loans l ON - WHERE `, - solutionQuery: `SELECT a.account_id, - a.balance, - a.status - FROM accounts a - LEFT JOIN loans l ON a.customer_id = l.customer_id - WHERE l.loan_id IS NULL`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 12, - title: 'Balances Ranked High to Low', - description: `Treasury wants a full ranking of all accounts by their current balance to identify the highest-value accounts. - -Return \`account_id\`, \`customer_id\`, and \`balance\`, ordered from highest to lowest balance.`, - hint: 'SELECT from accounts and use ORDER BY balance DESC.', - seedQuery: `SELECT - FROM accounts - ORDER BY `, - solutionQuery: `SELECT account_id, - customer_id, - balance - FROM accounts - ORDER BY balance DESC`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 13, - title: 'Top 5 Wealthiest Accounts', - description: `Private Banking is preparing personalised relationship outreach for the five highest-value account holders. - -Return the top 5 accounts by \`balance\`, showing \`account_id\`, \`customer_id\`, and \`balance\`.`, - hint: 'Order by balance DESC and use LIMIT 5.', - seedQuery: `SELECT - FROM accounts - ORDER BY - LIMIT `, - solutionQuery: `SELECT account_id, - customer_id, - balance - FROM accounts - ORDER BY balance DESC - LIMIT 5`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 14, - title: 'Transaction Count by Channel', - description: `The Digital team needs a breakdown of transaction volume by payment channel to inform the upcoming channel investment review. - -Return \`channel\` and the count as \`transaction_count\`, ordered by count descending.`, - hint: 'GROUP BY channel and COUNT(*), then ORDER BY the count DESC.', - seedQuery: `SELECT - FROM transactions - GROUP BY - ORDER BY `, - solutionQuery: `SELECT channel, - COUNT(*) AS transaction_count - FROM transactions - GROUP BY channel - ORDER BY transaction_count DESC`, - epoch: 'Foundational', - difficulty: 1, - }, - { - id: 15, - title: 'Customer Names on Accounts', - description: `Relationship Managers need a combined view of customer names alongside their account balances — your first JOIN query at LCB. - -Return \`customer_name\`, \`account_id\`, and \`balance\` by joining \`accounts\` with \`customers\`, ordered by balance descending.`, - hint: 'JOIN accounts and customers on customer_id. Use table aliases a and c.', - seedQuery: `SELECT - FROM accounts a - JOIN customers c ON - ORDER BY `, - solutionQuery: `SELECT c.customer_name, - a.account_id, - a.balance - FROM accounts a - JOIN customers c ON a.customer_id = c.customer_id - ORDER BY a.balance DESC`, - epoch: 'Foundational', - difficulty: 1, - }, - - // ============================================================ - // INTERMEDIATE (Levels 16–30) - // GROUP BY, HAVING, Subqueries, UNION, CASE WHEN - // ============================================================ - { - id: 16, - title: 'Total Balance per Branch', - description: `Branch Directors want to know the total deposits held at each LCB branch to inform their performance scorecards. - -Return \`branch_name\`, the number of accounts as \`account_count\`, and \`SUM(balance)\` as \`total_balance\`, ordered by total_balance descending.`, - hint: 'LEFT JOIN branches to accounts on branch_id, then GROUP BY branch_id and branch_name.', - seedQuery: `SELECT - FROM branches b - LEFT JOIN accounts a ON - GROUP BY - ORDER BY `, - solutionQuery: `SELECT b.branch_name, - COUNT(a.account_id) AS account_count, - SUM(a.balance) AS total_balance - FROM branches b - LEFT JOIN accounts a ON b.branch_id = a.branch_id - GROUP BY b.branch_id, b.branch_name - ORDER BY total_balance DESC`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 17, - title: 'Average Balance by Segment', - description: `Segment analytics: calculate the mean account balance for each customer segment (Mass, Priority, Private, SME). - -Return \`segment\` and \`avg_balance\` (rounded to 2dp), ordered by avg_balance descending.`, - hint: 'JOIN accounts to customers, GROUP BY c.segment, and use AVG(a.balance).', - seedQuery: `SELECT - FROM accounts a - JOIN customers c ON - GROUP BY - ORDER BY `, - solutionQuery: `SELECT c.segment, - ROUND(AVG(a.balance), 2) AS avg_balance - FROM accounts a - JOIN customers c ON a.customer_id = c.customer_id - GROUP BY c.segment - ORDER BY avg_balance DESC`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 18, - title: 'High-Volume Segments', - description: `Segments with more than 10 accounts are considered high-volume and receive dedicated service teams. Identify those segments now. - -Return \`segment\` and \`account_count\` where the count exceeds 10, ordered by account_count descending.`, - hint: 'GROUP BY segment with a HAVING COUNT(*) > 10 clause.', - seedQuery: `SELECT - FROM accounts a - JOIN customers c ON - GROUP BY -HAVING - ORDER BY `, - solutionQuery: `SELECT c.segment, - COUNT(a.account_id) AS account_count - FROM accounts a - JOIN customers c ON a.customer_id = c.customer_id - GROUP BY c.segment -HAVING COUNT(a.account_id) > 10 - ORDER BY account_count DESC`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 19, - title: 'Loan Exposure by Risk Grade', - description: `Credit Risk needs the total loan principal broken down by risk grade (A–E) to calculate the portfolio's weighted risk score. - -Return \`risk_grade\`, \`loan_count\`, and \`total_principal\` (rounded to 0dp), ordered by risk_grade.`, - hint: 'GROUP BY risk_grade on the loans table using COUNT and SUM.', - seedQuery: `SELECT - FROM loans - GROUP BY - ORDER BY `, - solutionQuery: `SELECT risk_grade, - COUNT(*) AS loan_count, - ROUND(SUM(principal_amount), 0) AS total_principal - FROM loans - GROUP BY risk_grade - ORDER BY risk_grade`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 20, - title: 'Customers with Multiple Accounts', - description: `Multi-banking customers (those holding more than one LCB account) are valuable — they have deeper engagement and lower churn. - -Return \`customer_name\` and \`account_count\` for customers with more than 1 account, ordered by account_count descending.`, - hint: 'JOIN customers to accounts, GROUP BY customer, HAVING COUNT > 1.', - seedQuery: `SELECT - FROM customers c - JOIN accounts a ON - GROUP BY -HAVING - ORDER BY `, - solutionQuery: `SELECT c.customer_name, - COUNT(a.account_id) AS account_count - FROM customers c - JOIN accounts a ON c.customer_id = a.customer_id - GROUP BY c.customer_id, c.customer_name -HAVING COUNT(a.account_id) > 1 - ORDER BY account_count DESC`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 21, - title: 'Above-Average Balance Accounts', - description: `VIP alert: identify all accounts holding more than the average balance across the entire portfolio — prime targets for wealth upsell. - -Return \`account_id\`, \`customer_id\`, and \`balance\`, ordered by balance descending.`, - hint: 'Use a subquery: WHERE balance > (SELECT AVG(balance) FROM accounts).', - seedQuery: `SELECT - FROM accounts - WHERE balance > ( ) - ORDER BY `, - solutionQuery: `SELECT account_id, - customer_id, - balance - FROM accounts - WHERE balance > (SELECT AVG(balance) FROM accounts) - ORDER BY balance DESC`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 22, - title: 'Defaulted Loan Customers', - description: `The Collections team needs the names and details of customers with a loan status of \`Default\` for immediate follow-up. - -Return \`customer_name\`, \`segment\`, \`principal_amount\`, and \`risk_grade\` for defaulted loans.`, - hint: 'JOIN customers to loans on customer_id, filtering WHERE l.status = Default.', - seedQuery: `SELECT - FROM customers c - JOIN loans l ON - WHERE `, - solutionQuery: `SELECT c.customer_name, - c.segment, - l.principal_amount, - l.risk_grade - FROM customers c - JOIN loans l ON c.customer_id = l.customer_id - WHERE l.status = 'Default'`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 23, - title: 'Monthly Transaction Totals', - description: `Finance wants a month-by-month summary of total transaction volume for the H1 2024 performance report. - -Return \`month\` (as YYYY-MM), \`total_amount\`, and \`transaction_count\`, ordered by month.`, - hint: 'Use strftime(\'%Y-%m\', transaction_date) and GROUP BY the result.', - seedQuery: `SELECT - FROM transactions - GROUP BY - ORDER BY `, - solutionQuery: `SELECT strftime('%Y-%m', transaction_date) AS month, - ROUND(SUM(amount), 2) AS total_amount, - COUNT(*) AS transaction_count - FROM transactions - GROUP BY strftime('%Y-%m', transaction_date) - ORDER BY month`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 24, - title: 'Savings and Investment Products', - description: `The Product team needs a combined list of all Savings and Investment products in one result set using a UNION. - -Return \`product_name\` and \`product_type\` for products of type \`Savings\` UNION those of type \`Investment\`, ordered by product_type then product_name.`, - hint: 'Write two SELECT statements connected by UNION and ORDER BY at the end.', - seedQuery: `SELECT - FROM products - WHERE -UNION -SELECT - FROM products - WHERE - ORDER BY `, - solutionQuery: `SELECT product_name, product_type - FROM products - WHERE product_type = 'Savings' -UNION -SELECT product_name, product_type - FROM products - WHERE product_type = 'Investment' - ORDER BY product_type, product_name`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 25, - title: 'Active vs Dormant Account Count', - description: `Operations needs a simple breakdown of how many accounts are Active versus Dormant for the monthly MAS reporting pack. - -Return \`status\` and \`account_count\` for Active and Dormant accounts only.`, - hint: 'Filter WHERE status IN (Active, Dormant), then GROUP BY status.', - seedQuery: `SELECT - FROM accounts - WHERE status IN ( ) - GROUP BY `, - solutionQuery: `SELECT status, - COUNT(*) AS account_count - FROM accounts - WHERE status IN ('Active', 'Dormant') - GROUP BY status`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 26, - title: 'Full Transaction History with Customer Names', - description: `Customer Service needs a complete view linking each transaction to the customer who made it — spanning three tables. - -Return \`customer_name\`, \`merchant_category\`, \`amount\`, and \`transaction_date\`, ordered by transaction_date descending. Limit to 20 rows.`, - hint: 'JOIN transactions → accounts → customers using account_id and customer_id.', - seedQuery: `SELECT - FROM transactions t - JOIN accounts a ON - JOIN customers c ON - ORDER BY - LIMIT 20`, - solutionQuery: `SELECT c.customer_name, - t.merchant_category, - t.amount, - t.transaction_date - FROM transactions t - JOIN accounts a ON t.account_id = a.account_id - JOIN customers c ON a.customer_id = c.customer_id - ORDER BY t.transaction_date DESC - LIMIT 20`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 27, - title: 'Branch Transaction Volume', - description: `Branch performance report: for each LCB branch, calculate how many transactions passed through its accounts and the total SGD volume. - -Return \`branch_name\`, \`tx_count\`, and \`total_volume\` (rounded to 2dp), ordered by total_volume descending.`, - hint: 'Chain JOINs: branches → accounts → transactions, then GROUP BY branch.', - seedQuery: `SELECT - FROM branches b - JOIN accounts a ON - JOIN transactions t ON - GROUP BY - ORDER BY `, - solutionQuery: `SELECT b.branch_name, - COUNT(t.transaction_id) AS tx_count, - ROUND(SUM(t.amount), 2) AS total_volume - FROM branches b - JOIN accounts a ON b.branch_id = a.branch_id - JOIN transactions t ON a.account_id = t.account_id - GROUP BY b.branch_id, b.branch_name - ORDER BY total_volume DESC`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 28, - title: 'Credit Score Banding', - description: `Credit Analytics wants customers categorised into standard MAS credit bands using a CASE expression. - -Return \`customer_name\`, \`credit_score\`, and a computed \`credit_band\` column: -- ≥800 → Excellent -- ≥740 → Very Good -- ≥670 → Good -- ≥580 → Fair -- else → Poor - -Order by credit_score descending.`, - hint: 'Use CASE WHEN ... THEN ... ELSE ... END as credit_band.', - seedQuery: `SELECT customer_name, - credit_score, - CASE - WHEN THEN '' - WHEN THEN '' - WHEN THEN '' - WHEN THEN '' - ELSE '' - END AS credit_band - FROM customers - ORDER BY credit_score DESC`, - solutionQuery: `SELECT customer_name, - credit_score, - CASE - WHEN credit_score >= 800 THEN 'Excellent' - WHEN credit_score >= 740 THEN 'Very Good' - WHEN credit_score >= 670 THEN 'Good' - WHEN credit_score >= 580 THEN 'Fair' - ELSE 'Poor' - END AS credit_band - FROM customers - ORDER BY credit_score DESC`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 29, - title: 'Top Debit Spend by Category', - description: `Marketing Analytics needs to know where customers are spending the most on debit transactions per merchant category, to target rewards promotions. - -Return \`customer_name\`, \`merchant_category\`, and \`total_spend\` for all Debit transactions, grouped by customer and category, ordered by total_spend descending. Limit 10 rows.`, - hint: 'Three-table JOIN (transactions → accounts → customers) filtered by transaction_type = Debit, then GROUP BY customer and category.', - seedQuery: `SELECT - FROM transactions t - JOIN accounts a ON - JOIN customers c ON - WHERE - GROUP BY - ORDER BY - LIMIT 10`, - solutionQuery: `SELECT c.customer_name, - t.merchant_category, - ROUND(SUM(t.amount), 2) AS total_spend - FROM transactions t - JOIN accounts a ON t.account_id = a.account_id - JOIN customers c ON a.customer_id = c.customer_id - WHERE t.transaction_type = 'Debit' - GROUP BY c.customer_id, c.customer_name, t.merchant_category - ORDER BY total_spend DESC - LIMIT 10`, - epoch: 'Intermediate', - difficulty: 2, - }, - { - id: 30, - title: 'Loan Portfolio Summary', - description: `The Board risk deck requires a loan portfolio summary grouped by both status and risk grade, showing loan count, average interest rate, and total exposure. - -Return \`status\`, \`risk_grade\`, \`loan_count\`, \`avg_rate\` (2dp), and \`total_exposure\` (rounded), ordered by status then risk_grade.`, - hint: 'GROUP BY two columns (status and risk_grade) simultaneously.', - seedQuery: `SELECT - FROM loans - GROUP BY - ORDER BY `, - solutionQuery: `SELECT status, - risk_grade, - COUNT(*) AS loan_count, - ROUND(AVG(interest_rate), 2) AS avg_rate, - ROUND(SUM(principal_amount), 0) AS total_exposure - FROM loans - GROUP BY status, risk_grade - ORDER BY status, risk_grade`, - epoch: 'Intermediate', - difficulty: 2, - }, - - // ============================================================ - // ADVANCED (Levels 31–40) - // CTEs, Window Functions, Date Arithmetic - // ============================================================ - { - id: 31, - title: 'Running Cumulative Deposits', - description: `Treasury wants to see the cumulative total of account balances ordered from highest to lowest — a running total window function. - -Return \`account_id\`, \`balance\`, and \`running_total\` using SUM() OVER, ordered by balance descending.`, - hint: 'Use SUM(balance) OVER (ORDER BY balance DESC) as running_total.', - seedQuery: `SELECT account_id, - balance, - SUM(balance) OVER (ORDER BY ) AS running_total - FROM accounts - ORDER BY balance DESC`, - solutionQuery: `SELECT account_id, - balance, - SUM(balance) OVER (ORDER BY balance DESC) AS running_total - FROM accounts - ORDER BY balance DESC`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 32, - title: 'Rank Customers Within Segment', - description: `Private Banking wants to rank customers by balance within each segment to identify the top-ranked client per tier. - -Return \`customer_name\`, \`segment\`, \`balance\`, and \`rank_in_segment\` using RANK() OVER PARTITION BY segment, ordered by segment then rank.`, - hint: 'Use RANK() OVER (PARTITION BY c.segment ORDER BY a.balance DESC).', - seedQuery: `SELECT - FROM accounts a - JOIN customers c ON - ORDER BY `, - solutionQuery: `SELECT c.customer_name, - c.segment, - a.balance, - RANK() OVER ( - PARTITION BY c.segment - ORDER BY a.balance DESC - ) AS rank_in_segment - FROM accounts a - JOIN customers c ON a.customer_id = c.customer_id - ORDER BY c.segment, rank_in_segment`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 33, - title: 'Month-over-Month Transaction Growth', - description: `Finance tracks monthly transaction growth using LAG() to compare each month's volume to the prior month. - -Return \`month\`, \`total_amount\`, \`prev_month_amount\`, and \`growth_pct\` (rounded to 2dp), ordered by month.`, - hint: 'Wrap a GROUP BY subquery, then apply LAG(total_amount) OVER (ORDER BY month) in the outer query.', - seedQuery: `SELECT month, - total_amount, - LAG(total_amount) OVER (ORDER BY month) AS prev_month_amount, - AS growth_pct - FROM ( - SELECT AS month, - AS total_amount - FROM transactions - GROUP BY - ) - ORDER BY month`, - solutionQuery: `SELECT month, - total_amount, - LAG(total_amount) OVER (ORDER BY month) AS prev_month_amount, - ROUND( - (total_amount - LAG(total_amount) OVER (ORDER BY month)) - / LAG(total_amount) OVER (ORDER BY month) * 100 - , 2) AS growth_pct - FROM ( - SELECT strftime('%Y-%m', transaction_date) AS month, - ROUND(SUM(amount), 2) AS total_amount - FROM transactions - GROUP BY strftime('%Y-%m', transaction_date) - ) - ORDER BY month`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 34, - title: 'High-Balance Accounts CTE', - description: `Using a CTE, first identify all active accounts with a balance above SGD 50,000, then summarise them by customer segment. - -Return \`segment\`, \`account_count\`, and \`avg_balance\` (2dp), ordered by avg_balance descending.`, - hint: 'Define a WITH high_balance AS (...) CTE that filters balance > 50000 and joins customers, then aggregate in the outer query.', - seedQuery: `WITH high_balance AS ( - SELECT - FROM accounts a - JOIN customers c ON - WHERE -) -SELECT - FROM high_balance - GROUP BY - ORDER BY `, - solutionQuery: `WITH high_balance AS ( - SELECT a.account_id, a.balance, c.segment - FROM accounts a - JOIN customers c ON a.customer_id = c.customer_id - WHERE a.balance > 50000 - AND a.status = 'Active' -) -SELECT segment, - COUNT(*) AS account_count, - ROUND(AVG(balance), 2) AS avg_balance - FROM high_balance - GROUP BY segment - ORDER BY avg_balance DESC`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 35, - title: 'At-Risk Loan Customers CTE', - description: `Using a CTE, isolate loans that are in Default or carry a risk grade of D, then return the full customer and loan detail for immediate escalation. - -Return \`customer_name\`, \`segment\`, \`principal_amount\`, \`interest_rate\`, \`risk_grade\`, and \`status\`, ordered by principal_amount descending.`, - hint: 'Build a WITH at_risk AS (...) CTE joining loans and customers, filtering by status = Default OR risk_grade = D.', - seedQuery: `WITH at_risk AS ( - SELECT - FROM loans l - JOIN customers c ON - WHERE -) -SELECT - FROM at_risk - ORDER BY `, - solutionQuery: `WITH at_risk AS ( - SELECT l.loan_id, l.principal_amount, l.interest_rate, l.risk_grade, l.status, - c.customer_name, c.segment - FROM loans l - JOIN customers c ON l.customer_id = c.customer_id - WHERE l.status = 'Default' - OR l.risk_grade = 'D' -) -SELECT customer_name, - segment, - principal_amount, - interest_rate, - risk_grade, - status - FROM at_risk - ORDER BY principal_amount DESC`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 36, - title: 'Balance Quartiles with NTILE', - description: `Wealth segmentation: bucket all active account holders into four equal quartiles by balance using NTILE(4), so the top 25% can be targeted for Private Banking upgrade. - -Return \`customer_name\`, \`balance\`, and \`quartile\`, ordered by balance descending.`, - hint: 'Use NTILE(4) OVER (ORDER BY a.balance DESC) as quartile.', - seedQuery: `SELECT - FROM accounts a - JOIN customers c ON - WHERE - ORDER BY `, - solutionQuery: `SELECT c.customer_name, - a.balance, - NTILE(4) OVER (ORDER BY a.balance DESC) AS quartile - FROM accounts a - JOIN customers c ON a.customer_id = c.customer_id - WHERE a.status = 'Active' - ORDER BY a.balance DESC`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 37, - title: 'First Transaction per Account', - description: `Onboarding analytics: find each account's very first transaction using ROW_NUMBER() partitioned by account. - -Return \`account_id\`, \`transaction_date\`, \`amount\`, and \`merchant_category\` for only the first transaction per account, ordered by account_id.`, - hint: 'Assign ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY transaction_date), then filter WHERE rn = 1 in an outer query.', - seedQuery: `SELECT account_id, - transaction_date, - amount, - merchant_category - FROM ( - SELECT - ROW_NUMBER() OVER ( - PARTITION BY - ORDER BY - ) AS rn - FROM transactions - ) - WHERE rn = 1 - ORDER BY account_id`, - solutionQuery: `SELECT account_id, - transaction_date, - amount, - merchant_category - FROM ( - SELECT account_id, - transaction_date, - amount, - merchant_category, - ROW_NUMBER() OVER ( - PARTITION BY account_id - ORDER BY transaction_date - ) AS rn - FROM transactions - ) - WHERE rn = 1 - ORDER BY account_id`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 38, - title: 'Account Age in Days', - description: `Retention analysis: calculate how many days each active account has been open as of 30 Jun 2024, to identify long-tenured customers for loyalty rewards. - -Return \`account_id\`, \`customer_name\`, \`opened_date\`, and \`days_open\`, ordered by days_open descending.`, - hint: 'Use CAST(julianday(\'2024-06-30\') - julianday(opened_date) AS INTEGER) AS days_open.', - seedQuery: `SELECT - FROM accounts a - JOIN customers c ON - ORDER BY `, - solutionQuery: `SELECT a.account_id, - c.customer_name, - a.opened_date, - CAST(julianday('2024-06-30') - julianday(a.opened_date) AS INTEGER) AS days_open - FROM accounts a - JOIN customers c ON a.customer_id = c.customer_id - ORDER BY days_open DESC`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 39, - title: 'Transactions Above Personal Average', - description: `Fraud pre-screening: flag transactions where the amount exceeds that customer's own average transaction value — an early signal of unusual activity. - -Return \`transaction_id\`, \`customer_name\`, \`amount\`, and \`merchant_category\` for such transactions, ordered by amount descending. Limit 20 rows.`, - hint: 'Use a correlated subquery in the WHERE clause: WHERE t.amount > (SELECT AVG(...) WHERE account belongs to same customer).', - seedQuery: `SELECT - FROM transactions t - JOIN accounts a ON - JOIN customers c ON - WHERE t.amount > ( - SELECT - FROM transactions t2 - JOIN accounts a2 ON - WHERE - ) - ORDER BY - LIMIT 20`, - solutionQuery: `SELECT t.transaction_id, - c.customer_name, - t.amount, - t.merchant_category - FROM transactions t - JOIN accounts a ON t.account_id = a.account_id - JOIN customers c ON a.customer_id = c.customer_id - WHERE t.amount > ( - SELECT AVG(t2.amount) - FROM transactions t2 - JOIN accounts a2 ON t2.account_id = a2.account_id - WHERE a2.customer_id = a.customer_id - ) - ORDER BY t.amount DESC - LIMIT 20`, - epoch: 'Advanced', - difficulty: 3, - }, - { - id: 40, - title: 'Investment Cross-Sell Targets', - description: `Cross-sell opportunity: identify customers who hold an active account but have never opened an Investment-type product — prime targets for the LCB CPF / Wealth Management pitch. - -Return \`customer_name\`, \`segment\`, and \`credit_score\`, ordered by credit_score descending.`, - hint: 'Use NOT IN with a subquery that finds customer_ids who have an Investment product in their accounts.', - seedQuery: `SELECT - FROM customers c - WHERE c.customer_id NOT IN ( - SELECT - FROM accounts a - JOIN products p ON - WHERE - ) - ORDER BY `, - solutionQuery: `SELECT c.customer_name, - c.segment, - c.credit_score - FROM customers c - WHERE c.customer_id NOT IN ( - SELECT DISTINCT a.customer_id - FROM accounts a - JOIN products p ON a.product_id = p.product_id - WHERE p.product_type = 'Investment' - ) - ORDER BY c.credit_score DESC`, - epoch: 'Advanced', - difficulty: 3, - }, - - // ============================================================ - // EXPERT (Levels 41–50) - // Recursive CTEs, Advanced Windows, Multi-CTE Analysis - // ============================================================ +export const expertLevels: Level[] = [ { id: 41, title: 'Customer Lifetime Value Estimate', @@ -985,6 +50,7 @@ SELECT c.customer_name, epoch: 'Expert', difficulty: 4, }, + { id: 42, title: 'Transaction Spike Detection', @@ -1023,6 +89,7 @@ SELECT c.customer_name, epoch: 'Expert', difficulty: 4, }, + { id: 43, title: 'Rolling 30-Day Debit Spend', @@ -1052,6 +119,7 @@ Aggregate daily debit totals, then compute a 30-day rolling sum using a window f epoch: 'Expert', difficulty: 4, }, + { id: 44, title: 'Loan Amortisation Schedule (Recursive CTE)', @@ -1084,6 +152,7 @@ SELECT month_num, epoch: 'Expert', difficulty: 4, }, + { id: 45, title: 'Customer Acquisition Cohort Analysis', @@ -1117,6 +186,7 @@ SELECT c.cohort_year, epoch: 'Expert', difficulty: 4, }, + { id: 46, title: 'Portfolio Risk Concentration', @@ -1160,6 +230,7 @@ SELECT g.risk_grade, epoch: 'Expert', difficulty: 4, }, + { id: 47, title: 'Customer Churn Risk Signals', @@ -1215,6 +286,7 @@ SELECT c.customer_name, epoch: 'Expert', difficulty: 4, }, + { id: 48, title: 'Total Interest Income Projection', @@ -1244,6 +316,7 @@ Return \`loan_id\`, \`customer_name\`, \`principal_amount\`, \`interest_rate\`, epoch: 'Expert', difficulty: 4, }, + { id: 49, title: 'Transaction Anomaly Z-Score', @@ -1252,7 +325,7 @@ Return \`loan_id\`, \`customer_name\`, \`principal_amount\`, \`interest_rate\`, Use a CTE for account-level statistics, then calculate Z-score in the outer query. Return \`transaction_id\`, \`account_id\`, \`amount\`, \`merchant_category\`, \`account_avg\`, and \`z_score\` (2dp), ordered by z_score descending. Limit 15.`, - hint: 'CTE: compute AVG and SUM of squared deviations per account_id. Outer query: CASE WHEN variance > 0 THEN (amount - avg) / SQRT(variance) ELSE 0 END.', + hint: 'CTE: compute AVG(amount) and variance as AVG(amount * amount) - AVG(amount) * AVG(amount) per account_id. Outer query: CASE WHEN variance > 0 THEN (amount - avg) / SQRT(variance) ELSE 0 END.', seedQuery: `WITH account_stats AS ( SELECT account_id, AVG(amount) AS avg_amt, @@ -1269,9 +342,7 @@ SELECT solutionQuery: `WITH account_stats AS ( SELECT account_id, AVG(amount) AS avg_amt, - SUM((amount - (SELECT AVG(a2.amount) FROM transactions a2 WHERE a2.account_id = transactions.account_id)) - * (amount - (SELECT AVG(a2.amount) FROM transactions a2 WHERE a2.account_id = transactions.account_id))) - / COUNT(*) AS variance + AVG(amount * amount) - AVG(amount) * AVG(amount) AS variance FROM transactions GROUP BY account_id ) @@ -1293,6 +364,7 @@ SELECT t.transaction_id, epoch: 'Expert', difficulty: 4, }, + { id: 50, title: 'LCB Executive Dashboard', @@ -1333,6 +405,7 @@ Return these columns: // MARITIME TRADE FINANCE (Levels 51–55) // New tables: vessels, cargo_shipments, trade_finance_facilities // ============================================================ + { id: 51, title: 'LCB Fleet Registry', @@ -1352,6 +425,7 @@ Return \`vessel_name\`, \`vessel_type\`, \`flag_state\`, and \`dwt_tonnes\` from epoch: 'Expert', difficulty: 2, }, + { id: 52, title: 'Delayed Cargo Alert', @@ -1376,6 +450,7 @@ Return \`shipment_id\`, \`vessel_name\`, \`origin_port\`, \`destination_port\`, epoch: 'Expert', difficulty: 2, }, + { id: 53, title: 'Trade Finance Utilisation Rate', @@ -1419,6 +494,7 @@ SELECT c.customer_name, epoch: 'Expert', difficulty: 3, }, + { id: 54, title: 'Vessel Cargo Revenue Ranking', @@ -1461,6 +537,7 @@ SELECT vessel_name, epoch: 'Expert', difficulty: 3, }, + { id: 55, title: 'Port Throughput Analysis', @@ -1512,6 +589,7 @@ SELECT port, // SENIOR DS PATTERNS (Levels 56–57) // Moving average with explicit window frame; top-N per group // ============================================================ + { id: 56, title: 'Voyage Revenue 7-Day Moving Average', @@ -1550,6 +628,7 @@ SELECT departure_date, epoch: 'Expert', difficulty: 4, }, + { id: 57, title: 'Top-2 Voyages per Cargo Type', @@ -1605,6 +684,7 @@ SELECT cargo_type, // SENIOR DS PATTERNS (Levels 58–59) // A/B test conversion analysis; LAG-based MoM revenue growth // ============================================================ + { id: 58, title: 'Digital Onboarding A/B Test: Conversion Analysis', @@ -1647,6 +727,7 @@ ORDER BY c.ab_test_group`, epoch: 'Expert', difficulty: 4, }, + { id: 59, title: 'Month-over-Month Cargo Revenue Growth (LAG)', @@ -1701,6 +782,7 @@ SELECT month, // SENIOR DS PATTERNS (Levels 60–61) // LEAD look-ahead; window-based median (ROW_NUMBER + COUNT) // ============================================================ + { id: 60, title: 'Vessel Maintenance Window Planner (LEAD)', @@ -1753,6 +835,7 @@ SELECT vessel_name, epoch: 'Expert', difficulty: 4, }, + { id: 61, title: 'Segment Balance Median (Window-Based)', @@ -1798,6 +881,7 @@ SELECT segment, // SENIOR DS PATTERNS (Levels 62–63) // Conditional aggregation pivot; rolling population std-dev // ============================================================ + { id: 62, title: 'Monthly Payment Channel Mix (Pivot)', @@ -1830,6 +914,7 @@ This is the canonical SQL pivot pattern — used in virtually every product-anal epoch: 'Expert', difficulty: 4, }, + { id: 63, title: 'Cargo Revenue Rolling 3-Month Volatility', diff --git a/src/data/levels/foundational.ts b/src/data/levels/foundational.ts new file mode 100644 index 0000000..6e0bbf7 --- /dev/null +++ b/src/data/levels/foundational.ts @@ -0,0 +1,288 @@ +import type { Level } from '@/types'; + +export const foundationalLevels: Level[] = [ + { + id: 1, + title: 'Priority Banking Customers', + description: `The Relationship Management team needs a list of all LCB Priority segment customers to prepare for the quarterly review. + +Return the \`customer_name\` and \`segment\` for every customer in the \`Priority\` segment.`, + hint: 'SELECT from customers WHERE segment equals the Priority segment.', + seedQuery: `SELECT + FROM customers + WHERE `, + solutionQuery: `SELECT customer_name, + segment + FROM customers + WHERE segment = 'Priority'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 2, + title: 'Total Customer Count', + description: `Compliance requires the total number of unique customers registered in the LCB system for the MAS regulatory report. + +Return a single value aliased as \`total_customers\`.`, + hint: 'Use COUNT(DISTINCT customer_id) on the customers table.', + seedQuery: `SELECT + FROM customers`, + solutionQuery: `SELECT COUNT(DISTINCT customer_id) AS total_customers + FROM customers`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 3, + title: 'Central Region Branches', + description: `The Operations team is planning a Central region audit. List all LCB branches located in the \`Central\` region. + +Return \`branch_name\` and \`branch_type\`.`, + hint: 'Filter the branches table WHERE region equals Central.', + seedQuery: `SELECT + FROM branches + WHERE `, + solutionQuery: `SELECT branch_name, + branch_type + FROM branches + WHERE region = 'Central'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 4, + title: 'CNY Period Transactions', + description: `Risk Management wants to analyse spending patterns during the Chinese New Year festive period in February 2024. + +Return \`transaction_id\`, \`transaction_date\`, and \`merchant_category\` for all transactions between 1 Feb and 29 Feb 2024.`, + hint: 'Use BETWEEN on transaction_date in the transactions table.', + seedQuery: `SELECT + FROM transactions + WHERE `, + solutionQuery: `SELECT transaction_id, + transaction_date, + merchant_category + FROM transactions + WHERE transaction_date BETWEEN '2024-02-01' AND '2024-02-29'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 5, + title: 'PayNow Transfers', + description: `The Digital Banking team needs a list of all transactions conducted through the \`PayNow\` channel to measure its adoption. + +Return \`transaction_id\`, \`amount\`, and \`transaction_date\`.`, + hint: 'Filter transactions WHERE channel equals PayNow.', + seedQuery: `SELECT + FROM transactions + WHERE `, + solutionQuery: `SELECT transaction_id, + amount, + transaction_date + FROM transactions + WHERE channel = 'PayNow'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 6, + title: 'Total Salary Credits', + description: `Treasury wants the aggregate value of all salary credits flowing into LCB accounts — a key indicator of customer payroll dependency. + +Return a single value aliased as \`total_salary_credits\` for all transactions where \`merchant_category\` is \`Salary Credit\`.`, + hint: 'SUM the amount column filtered by merchant_category = Salary Credit.', + seedQuery: `SELECT + FROM transactions + WHERE `, + solutionQuery: `SELECT SUM(amount) AS total_salary_credits + FROM transactions + WHERE merchant_category = 'Salary Credit'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 7, + title: 'Average Customer Credit Score', + description: `The Credit Risk team needs the mean credit score across all LCB customers to benchmark against MAS guidelines. + +Return a single value aliased as \`avg_credit_score\`.`, + hint: 'Use AVG(credit_score) on the customers table.', + seedQuery: `SELECT + FROM customers`, + solutionQuery: `SELECT AVG(credit_score) AS avg_credit_score + FROM customers`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 8, + title: 'Dormant Accounts', + description: `Operations must identify dormant accounts for the annual account dormancy review process. + +Return \`account_id\`, \`customer_id\`, and \`balance\` for all accounts with a status of \`Dormant\`.`, + hint: 'Filter the accounts table WHERE status equals Dormant.', + seedQuery: `SELECT + FROM accounts + WHERE `, + solutionQuery: `SELECT account_id, + customer_id, + balance + FROM accounts + WHERE status = 'Dormant'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 9, + title: 'CPF-Linked Products', + description: `Wealth Management wants to list all products connected to the CPF (Central Provident Fund) investment scheme. + +Return \`product_name\`, \`product_type\`, and \`interest_rate\` for all products whose name contains the text \`CPF\`.`, + hint: 'Use a LIKE filter with % wildcards on product_name.', + seedQuery: `SELECT + FROM products + WHERE `, + solutionQuery: `SELECT product_name, + product_type, + interest_rate + FROM products + WHERE product_name LIKE '%CPF%'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 10, + title: 'Home Loan Products', + description: `The Mortgage desk needs a catalogue of all LCB Home Loan products to include in the Q2 product brochure. + +Return \`product_name\`, \`product_type\`, and \`interest_rate\` for all products whose name contains \`Home Loan\`.`, + hint: 'Filter products with a LIKE pattern matching Home Loan in the product_name.', + seedQuery: `SELECT + FROM products + WHERE `, + solutionQuery: `SELECT product_name, + product_type, + interest_rate + FROM products + WHERE product_name LIKE '%Home Loan%'`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 11, + title: 'Accounts Without a Loan', + description: `Cross-sell targeting: find accounts whose customers do NOT currently hold any loan with LCB. These are candidates for the new home loan campaign. + +Return \`account_id\`, \`balance\`, and \`status\` from accounts where the customer has no matching row in loans.`, + hint: 'Use a LEFT JOIN between accounts and loans on customer_id, then filter WHERE the loan side IS NULL.', + seedQuery: `SELECT + FROM accounts a + LEFT JOIN loans l ON + WHERE `, + solutionQuery: `SELECT a.account_id, + a.balance, + a.status + FROM accounts a + LEFT JOIN loans l ON a.customer_id = l.customer_id + WHERE l.loan_id IS NULL`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 12, + title: 'Balances Ranked High to Low', + description: `Treasury wants a full ranking of all accounts by their current balance to identify the highest-value accounts. + +Return \`account_id\`, \`customer_id\`, and \`balance\`, ordered from highest to lowest balance.`, + hint: 'SELECT from accounts and use ORDER BY balance DESC.', + seedQuery: `SELECT + FROM accounts + ORDER BY `, + solutionQuery: `SELECT account_id, + customer_id, + balance + FROM accounts + ORDER BY balance DESC`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 13, + title: 'Top 5 Wealthiest Accounts', + description: `Private Banking is preparing personalised relationship outreach for the five highest-value account holders. + +Return the top 5 accounts by \`balance\`, showing \`account_id\`, \`customer_id\`, and \`balance\`.`, + hint: 'Order by balance DESC and use LIMIT 5.', + seedQuery: `SELECT + FROM accounts + ORDER BY + LIMIT `, + solutionQuery: `SELECT account_id, + customer_id, + balance + FROM accounts + ORDER BY balance DESC + LIMIT 5`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 14, + title: 'Transaction Count by Channel', + description: `The Digital team needs a breakdown of transaction volume by payment channel to inform the upcoming channel investment review. + +Return \`channel\` and the count as \`transaction_count\`, ordered by count descending.`, + hint: 'GROUP BY channel and COUNT(*), then ORDER BY the count DESC.', + seedQuery: `SELECT + FROM transactions + GROUP BY + ORDER BY `, + solutionQuery: `SELECT channel, + COUNT(*) AS transaction_count + FROM transactions + GROUP BY channel + ORDER BY transaction_count DESC`, + epoch: 'Foundational', + difficulty: 1, + }, + + { + id: 15, + title: 'Customer Names on Accounts', + description: `Relationship Managers need a combined view of customer names alongside their account balances — your first JOIN query at LCB. + +Return \`customer_name\`, \`account_id\`, and \`balance\` by joining \`accounts\` with \`customers\`, ordered by balance descending.`, + hint: 'JOIN accounts and customers on customer_id. Use table aliases a and c.', + seedQuery: `SELECT + FROM accounts a + JOIN customers c ON + ORDER BY `, + solutionQuery: `SELECT c.customer_name, + a.account_id, + a.balance + FROM accounts a + JOIN customers c ON a.customer_id = c.customer_id + ORDER BY a.balance DESC`, + epoch: 'Foundational', + difficulty: 1, + }, + + // ============================================================ + // INTERMEDIATE (Levels 16–30) + // GROUP BY, HAVING, Subqueries, UNION, CASE WHEN + // ============================================================ +]; diff --git a/src/data/levels/index.ts b/src/data/levels/index.ts new file mode 100644 index 0000000..11e1be8 --- /dev/null +++ b/src/data/levels/index.ts @@ -0,0 +1,12 @@ +import type { Level } from '@/types'; +import { foundationalLevels } from './foundational'; +import { intermediateLevels } from './intermediate'; +import { advancedLevels } from './advanced'; +import { expertLevels } from './expert'; + +export const levels: Level[] = [ + ...foundationalLevels, + ...intermediateLevels, + ...advancedLevels, + ...expertLevels, +]; diff --git a/src/data/levels/intermediate.ts b/src/data/levels/intermediate.ts new file mode 100644 index 0000000..c3241a4 --- /dev/null +++ b/src/data/levels/intermediate.ts @@ -0,0 +1,376 @@ +import type { Level } from '@/types'; + +export const intermediateLevels: Level[] = [ + { + id: 16, + title: 'Total Balance per Branch', + description: `Branch Directors want to know the total deposits held at each LCB branch to inform their performance scorecards. + +Return \`branch_name\`, the number of accounts as \`account_count\`, and \`SUM(balance)\` as \`total_balance\`, ordered by total_balance descending.`, + hint: 'LEFT JOIN branches to accounts on branch_id, then GROUP BY branch_id and branch_name.', + seedQuery: `SELECT + FROM branches b + LEFT JOIN accounts a ON + GROUP BY + ORDER BY `, + solutionQuery: `SELECT b.branch_name, + COUNT(a.account_id) AS account_count, + SUM(a.balance) AS total_balance + FROM branches b + LEFT JOIN accounts a ON b.branch_id = a.branch_id + GROUP BY b.branch_id, b.branch_name + ORDER BY total_balance DESC`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 17, + title: 'Average Balance by Segment', + description: `Segment analytics: calculate the mean account balance for each customer segment (Mass, Priority, Private, SME). + +Return \`segment\` and \`avg_balance\` (rounded to 2dp), ordered by avg_balance descending.`, + hint: 'JOIN accounts to customers, GROUP BY c.segment, and use AVG(a.balance).', + seedQuery: `SELECT + FROM accounts a + JOIN customers c ON + GROUP BY + ORDER BY `, + solutionQuery: `SELECT c.segment, + ROUND(AVG(a.balance), 2) AS avg_balance + FROM accounts a + JOIN customers c ON a.customer_id = c.customer_id + GROUP BY c.segment + ORDER BY avg_balance DESC`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 18, + title: 'High-Volume Segments', + description: `Segments with more than 10 accounts are considered high-volume and receive dedicated service teams. Identify those segments now. + +Return \`segment\` and \`account_count\` where the count exceeds 10, ordered by account_count descending.`, + hint: 'GROUP BY segment with a HAVING COUNT(*) > 10 clause.', + seedQuery: `SELECT + FROM accounts a + JOIN customers c ON + GROUP BY +HAVING + ORDER BY `, + solutionQuery: `SELECT c.segment, + COUNT(a.account_id) AS account_count + FROM accounts a + JOIN customers c ON a.customer_id = c.customer_id + GROUP BY c.segment +HAVING COUNT(a.account_id) > 10 + ORDER BY account_count DESC`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 19, + title: 'Loan Exposure by Risk Grade', + description: `Credit Risk needs the total loan principal broken down by risk grade (A–E) to calculate the portfolio's weighted risk score. + +Return \`risk_grade\`, \`loan_count\`, and \`total_principal\` (rounded to 0dp), ordered by risk_grade.`, + hint: 'GROUP BY risk_grade on the loans table using COUNT and SUM.', + seedQuery: `SELECT + FROM loans + GROUP BY + ORDER BY `, + solutionQuery: `SELECT risk_grade, + COUNT(*) AS loan_count, + ROUND(SUM(principal_amount), 0) AS total_principal + FROM loans + GROUP BY risk_grade + ORDER BY risk_grade`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 20, + title: 'Customers with Multiple Accounts', + description: `Multi-banking customers (those holding more than one LCB account) are valuable — they have deeper engagement and lower churn. + +Return \`customer_name\` and \`account_count\` for customers with more than 1 account, ordered by account_count descending.`, + hint: 'JOIN customers to accounts, GROUP BY customer, HAVING COUNT > 1.', + seedQuery: `SELECT + FROM customers c + JOIN accounts a ON + GROUP BY +HAVING + ORDER BY `, + solutionQuery: `SELECT c.customer_name, + COUNT(a.account_id) AS account_count + FROM customers c + JOIN accounts a ON c.customer_id = a.customer_id + GROUP BY c.customer_id, c.customer_name +HAVING COUNT(a.account_id) > 1 + ORDER BY account_count DESC`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 21, + title: 'Above-Average Balance Accounts', + description: `VIP alert: identify all accounts holding more than the average balance across the entire portfolio — prime targets for wealth upsell. + +Return \`account_id\`, \`customer_id\`, and \`balance\`, ordered by balance descending.`, + hint: 'Use a subquery: WHERE balance > (SELECT AVG(balance) FROM accounts).', + seedQuery: `SELECT + FROM accounts + WHERE balance > ( ) + ORDER BY `, + solutionQuery: `SELECT account_id, + customer_id, + balance + FROM accounts + WHERE balance > (SELECT AVG(balance) FROM accounts) + ORDER BY balance DESC`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 22, + title: 'Defaulted Loan Customers', + description: `The Collections team needs the names and details of customers with a loan status of \`Default\` for immediate follow-up. + +Return \`customer_name\`, \`segment\`, \`principal_amount\`, and \`risk_grade\` for defaulted loans.`, + hint: 'JOIN customers to loans on customer_id, filtering WHERE l.status = Default.', + seedQuery: `SELECT + FROM customers c + JOIN loans l ON + WHERE `, + solutionQuery: `SELECT c.customer_name, + c.segment, + l.principal_amount, + l.risk_grade + FROM customers c + JOIN loans l ON c.customer_id = l.customer_id + WHERE l.status = 'Default'`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 23, + title: 'Monthly Transaction Totals', + description: `Finance wants a month-by-month summary of total transaction volume for the H1 2024 performance report. + +Return \`month\` (as YYYY-MM), \`total_amount\`, and \`transaction_count\`, ordered by month.`, + hint: 'Use strftime(\'%Y-%m\', transaction_date) and GROUP BY the result.', + seedQuery: `SELECT + FROM transactions + GROUP BY + ORDER BY `, + solutionQuery: `SELECT strftime('%Y-%m', transaction_date) AS month, + ROUND(SUM(amount), 2) AS total_amount, + COUNT(*) AS transaction_count + FROM transactions + GROUP BY strftime('%Y-%m', transaction_date) + ORDER BY month`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 24, + title: 'Savings and Investment Products', + description: `The Product team needs a combined list of all Savings and Investment products in one result set using a UNION. + +Return \`product_name\` and \`product_type\` for products of type \`Savings\` UNION those of type \`Investment\`, ordered by product_type then product_name.`, + hint: 'Write two SELECT statements connected by UNION and ORDER BY at the end.', + seedQuery: `SELECT + FROM products + WHERE +UNION +SELECT + FROM products + WHERE + ORDER BY `, + solutionQuery: `SELECT product_name, product_type + FROM products + WHERE product_type = 'Savings' +UNION +SELECT product_name, product_type + FROM products + WHERE product_type = 'Investment' + ORDER BY product_type, product_name`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 25, + title: 'Active vs Dormant Account Count', + description: `Operations needs a simple breakdown of how many accounts are Active versus Dormant for the monthly MAS reporting pack. + +Return \`status\` and \`account_count\` for Active and Dormant accounts only.`, + hint: 'Filter WHERE status IN (Active, Dormant), then GROUP BY status.', + seedQuery: `SELECT + FROM accounts + WHERE status IN ( ) + GROUP BY `, + solutionQuery: `SELECT status, + COUNT(*) AS account_count + FROM accounts + WHERE status IN ('Active', 'Dormant') + GROUP BY status`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 26, + title: 'Full Transaction History with Customer Names', + description: `Customer Service needs a complete view linking each transaction to the customer who made it — spanning three tables. + +Return \`customer_name\`, \`merchant_category\`, \`amount\`, and \`transaction_date\`, ordered by transaction_date descending. Limit to 20 rows.`, + hint: 'JOIN transactions → accounts → customers using account_id and customer_id.', + seedQuery: `SELECT + FROM transactions t + JOIN accounts a ON + JOIN customers c ON + ORDER BY + LIMIT 20`, + solutionQuery: `SELECT c.customer_name, + t.merchant_category, + t.amount, + t.transaction_date + FROM transactions t + JOIN accounts a ON t.account_id = a.account_id + JOIN customers c ON a.customer_id = c.customer_id + ORDER BY t.transaction_date DESC + LIMIT 20`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 27, + title: 'Branch Transaction Volume', + description: `Branch performance report: for each LCB branch, calculate how many transactions passed through its accounts and the total SGD volume. + +Return \`branch_name\`, \`tx_count\`, and \`total_volume\` (rounded to 2dp), ordered by total_volume descending.`, + hint: 'Chain JOINs: branches → accounts → transactions, then GROUP BY branch.', + seedQuery: `SELECT + FROM branches b + JOIN accounts a ON + JOIN transactions t ON + GROUP BY + ORDER BY `, + solutionQuery: `SELECT b.branch_name, + COUNT(t.transaction_id) AS tx_count, + ROUND(SUM(t.amount), 2) AS total_volume + FROM branches b + JOIN accounts a ON b.branch_id = a.branch_id + JOIN transactions t ON a.account_id = t.account_id + GROUP BY b.branch_id, b.branch_name + ORDER BY total_volume DESC`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 28, + title: 'Credit Score Banding', + description: `Credit Analytics wants customers categorised into standard MAS credit bands using a CASE expression. + +Return \`customer_name\`, \`credit_score\`, and a computed \`credit_band\` column: +- ≥800 → Excellent +- ≥740 → Very Good +- ≥670 → Good +- ≥580 → Fair +- else → Poor + +Order by credit_score descending.`, + hint: 'Use CASE WHEN ... THEN ... ELSE ... END as credit_band.', + seedQuery: `SELECT customer_name, + credit_score, + CASE + WHEN THEN '' + WHEN THEN '' + WHEN THEN '' + WHEN THEN '' + ELSE '' + END AS credit_band + FROM customers + ORDER BY credit_score DESC`, + solutionQuery: `SELECT customer_name, + credit_score, + CASE + WHEN credit_score >= 800 THEN 'Excellent' + WHEN credit_score >= 740 THEN 'Very Good' + WHEN credit_score >= 670 THEN 'Good' + WHEN credit_score >= 580 THEN 'Fair' + ELSE 'Poor' + END AS credit_band + FROM customers + ORDER BY credit_score DESC`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 29, + title: 'Top Debit Spend by Category', + description: `Marketing Analytics needs to know where customers are spending the most on debit transactions per merchant category, to target rewards promotions. + +Return \`customer_name\`, \`merchant_category\`, and \`total_spend\` for all Debit transactions, grouped by customer and category, ordered by total_spend descending. Limit 10 rows.`, + hint: 'Three-table JOIN (transactions → accounts → customers) filtered by transaction_type = Debit, then GROUP BY customer and category.', + seedQuery: `SELECT + FROM transactions t + JOIN accounts a ON + JOIN customers c ON + WHERE + GROUP BY + ORDER BY + LIMIT 10`, + solutionQuery: `SELECT c.customer_name, + t.merchant_category, + ROUND(SUM(t.amount), 2) AS total_spend + FROM transactions t + JOIN accounts a ON t.account_id = a.account_id + JOIN customers c ON a.customer_id = c.customer_id + WHERE t.transaction_type = 'Debit' + GROUP BY c.customer_id, c.customer_name, t.merchant_category + ORDER BY total_spend DESC + LIMIT 10`, + epoch: 'Intermediate', + difficulty: 2, + }, + + { + id: 30, + title: 'Loan Portfolio Summary', + description: `The Board risk deck requires a loan portfolio summary grouped by both status and risk grade, showing loan count, average interest rate, and total exposure. + +Return \`status\`, \`risk_grade\`, \`loan_count\`, \`avg_rate\` (2dp), and \`total_exposure\` (rounded), ordered by status then risk_grade.`, + hint: 'GROUP BY two columns (status and risk_grade) simultaneously.', + seedQuery: `SELECT + FROM loans + GROUP BY + ORDER BY `, + solutionQuery: `SELECT status, + risk_grade, + COUNT(*) AS loan_count, + ROUND(AVG(interest_rate), 2) AS avg_rate, + ROUND(SUM(principal_amount), 0) AS total_exposure + FROM loans + GROUP BY status, risk_grade + ORDER BY status, risk_grade`, + epoch: 'Intermediate', + difficulty: 2, + }, + + // ============================================================ + // ADVANCED (Levels 31–40) + // CTEs, Window Functions, Date Arithmetic + // ============================================================ +]; diff --git a/src/lib/validator.ts b/src/lib/validator.ts index 16403249922666e9d0fe1945532245a7c701074f..ce3dd3f156a2d06a233e639cf6f99f4d6df6338e 100644 GIT binary patch delta 402 zcmYk1u};G<6h$SX4upjb##^aU8;aVEA)OhR84-hVUz$j+UD?kms6_A&;Wv|F)2H{|-FYrn1d~BPQAuj(K-VjhOmkRrUNJ_}D8JMWLWe)I zhBRJtg3MTulR^nD^diO)@;6LOQFoW>#4 zSVrcx()( completeLevel: (level) => set((state) => { - const xpGain = xpFor(level); + // XP is awarded once per level — replaying a completed level + // shouldn't farm XP. Streak counts every successful solve. + const isFirstCompletion = !state.completedLevels.includes(level); + const nextLevel = Math.min(level + 1, MAX_LEVEL); return { - completedLevels: state.completedLevels.includes(level) - ? state.completedLevels - : [...state.completedLevels, level], - currentLevel: level + 1, - levelHistory: [...state.levelHistory, level + 1], + completedLevels: isFirstCompletion + ? [...state.completedLevels, level] + : state.completedLevels, + currentLevel: nextLevel, + levelHistory: state.levelHistory.includes(nextLevel) + ? state.levelHistory + : [...state.levelHistory, nextLevel], hasAttemptedCurrent: false, - totalXp: state.totalXp + xpGain, + totalXp: isFirstCompletion ? state.totalXp + xpFor(level) : state.totalXp, currentStreak: state.currentStreak + 1, }; }), @@ -130,8 +135,9 @@ export const useGameStore = create()( const newHistory = [...state.levelHistory]; newHistory.pop(); // Remove current level const previousLevel = newHistory[newHistory.length - 1]; - set({ + set({ currentLevel: previousLevel, + levelHistory: newHistory, hasAttemptedCurrent: false, }); } diff --git a/tests/store.test.ts b/tests/store.test.ts new file mode 100644 index 0000000..c3e205f --- /dev/null +++ b/tests/store.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +// The store persists via localStorage; give Node a minimal implementation +// before the store module is imported. +const backing = new Map(); +globalThis.localStorage = { + getItem: (k: string) => backing.get(k) ?? null, + setItem: (k: string, v: string) => void backing.set(k, v), + removeItem: (k: string) => void backing.delete(k), + clear: () => backing.clear(), + key: (i: number) => [...backing.keys()][i] ?? null, + get length() { + return backing.size; + }, +} as Storage; + +const { useGameStore } = await import('@/store/useGameStore'); +const { MAX_LEVEL, xpFor } = await import('@/lib/progression'); + +beforeEach(() => { + useGameStore.getState().resetGame(); +}); + +describe('completeLevel', () => { + it('records completion, awards epoch XP, and advances to the next level', () => { + useGameStore.getState().completeLevel(1); + const s = useGameStore.getState(); + expect(s.completedLevels).toEqual([1]); + expect(s.totalXp).toBe(xpFor(1)); + expect(s.currentLevel).toBe(2); + expect(s.currentStreak).toBe(1); + }); + + it('awards more XP for higher epochs', () => { + expect(xpFor(41)).toBeGreaterThan(xpFor(1)); + useGameStore.getState().completeLevel(41); + expect(useGameStore.getState().totalXp).toBe(xpFor(41)); + }); + + it('does not award XP again when a level is replayed', () => { + useGameStore.getState().completeLevel(1); + useGameStore.getState().completeLevel(1); + const s = useGameStore.getState(); + expect(s.completedLevels).toEqual([1]); + expect(s.totalXp).toBe(xpFor(1)); + // ...but the solve streak still counts both solves + expect(s.currentStreak).toBe(2); + }); + + it('does not advance past the final level', () => { + useGameStore.getState().completeLevel(MAX_LEVEL); + expect(useGameStore.getState().currentLevel).toBe(MAX_LEVEL); + }); + + it('does not duplicate levels in history', () => { + useGameStore.getState().setCurrentLevel(2); + useGameStore.getState().completeLevel(1); + const history = useGameStore.getState().levelHistory; + expect(new Set(history).size).toBe(history.length); + }); +}); + +describe('navigation', () => { + it('setCurrentLevel resets the attempt flag and tracks history', () => { + useGameStore.getState().setHasAttemptedCurrent(true); + useGameStore.getState().setCurrentLevel(5); + const s = useGameStore.getState(); + expect(s.currentLevel).toBe(5); + expect(s.hasAttemptedCurrent).toBe(false); + expect(s.levelHistory).toContain(5); + }); + + it('goToPreviousLevel walks back through history and is a no-op at the start', () => { + useGameStore.getState().setCurrentLevel(3); + useGameStore.getState().setCurrentLevel(7); + useGameStore.getState().goToPreviousLevel(); + expect(useGameStore.getState().currentLevel).toBe(3); + + useGameStore.getState().goToPreviousLevel(); + expect(useGameStore.getState().currentLevel).toBe(1); + + useGameStore.getState().goToPreviousLevel(); // history exhausted — no-op + expect(useGameStore.getState().currentLevel).toBe(1); + }); +}); + +describe('resetGame', () => { + it('returns progression to the initial state', () => { + useGameStore.getState().completeLevel(1); + useGameStore.getState().completeLevel(2); + useGameStore.getState().resetGame(); + const s = useGameStore.getState(); + expect(s.currentLevel).toBe(1); + expect(s.completedLevels).toEqual([]); + expect(s.totalXp).toBe(0); + expect(s.currentStreak).toBe(0); + }); +});