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
20 changes: 20 additions & 0 deletions evolution_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ Each entry records one autonomous improvement iteration.

---

## Iteration 12 — 2026-06-29

### [UI/UX Improvements]

- **Collapsible schema panel** (`GameProvider.tsx`): The right-hand Schema/Results column can now be collapsed to a narrow 40px rail on desktop (≥ lg breakpoint) by clicking the `›` chevron button appended to the right side of the tab bar. When collapsed, the rail shows a `‹` expand button and a vertical "SCHEMA" label so the affordance remains clear. The SQL editor column changes from a fixed `460px` to `lg:flex-1 min-w-0`, so it grows to fill the freed horizontal space — giving power users more room to compose long CTEs and window-function queries without layout thrash. A `transition-all duration-200 ease-in-out` CSS transition smooths the width change. The collapse state is session-local (`useState`), not persisted, so the panel always reopens on reload. Keyboard-accessible: both toggle buttons have `aria-label` and `title` attributes. Mobile layouts (< lg) are unaffected — the panels always stack vertically at full width. Directly implements the "collapsible schema panel" item from the UI/UX rotation.

### [Game Design Tweaks]

- **Level 72** *(Expert, difficulty 4)* — "Loan Product Acquisition Funnel": Teaches the canonical SQL product-analytics funnel pattern: a `UNION ALL` of four scalar aggregate queries (one per stage) tagged with an `ord` column, combined with a `CROSS JOIN` scalar CTE for the denominator. The four stages — All Customers (20), Loan Applicants (15), Active Loan Holders (12), Active Home Loan Holders (5) — yield conversion rates of 100 %, 75 %, 60 %, and 25 %. This exact UNION ALL + CROSS JOIN pattern is used in BigQuery, Redshift, Snowflake, and Spark SQL product-analytics pipelines at FAANG, digital banks, and MAS-regulated fintechs. Reinforces the CROSS JOIN scalar CTE idiom from Level 70 in a new product-analytics context. No new data or tables required.

- **Level 73** *(Expert, difficulty 4)* — "First Salary Credit Per Account (ROW_NUMBER Dedup)": Teaches the universal SQL deduplication and latest/earliest-record-per-entity pattern: `ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY transaction_date ASC)` in a CTE, then `WHERE rn = 1` in the outer query. The compliance framing (MAS Notice 632 income-verification) makes the business case for dedup concrete. Returns 4 rows — accounts 5, 15, 25, 35 (customers Aisha Binte Yusof, Chen Mei Ling, Nur Hidayah, Fiona Tan) with their first Salary Credit amounts ($3,750 – $8,250) and dates in Jan–Feb 2024. The ROW_NUMBER PARTITION BY pattern is the universal dedup technique across every SQL dialect and is a staple of ETL pipelines, SCD snapshots, and data-quality remediations at FAANG, hedge funds, and GIC. No new data or tables required.

### [Database & Code Optimizations]

- **`LevelUpModal` hint map** extended with entries for levels 72 (funnel UNION ALL + CROSS JOIN) and 73 (ROW_NUMBER dedup). `nextHintKey` sentinel array extended from `[…, 71]` to `[…, 71, 72, 73]`; fallback updated `?? 71` → `?? 73`. `MAX_LEVEL` in `progression.ts` auto-derives from the last level in the array (73) — no other touch points needed.
- **Left panel flex layout**: changed `lg:w-[460px] flex-shrink-0` to `lg:flex-1 min-w-0` so the SQL editor column grows naturally when the right panel is collapsed, filling available horizontal space without overflow.
- **No new DB tables, seed rows, or indexes** — both new levels run cleanly against the existing `loans`, `transactions`, `accounts`, and `customers` tables.

---

## Iteration 11 — 2026-06-22

### [UI/UX Improvements]
Expand Down
107 changes: 80 additions & 27 deletions src/components/GameProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useState, useRef, useCallback, Fragment } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import SQLPanel from './SQLPanel';
import DataPreview from './DataPreview';
import HarbourStatus from './FlockStatus';
Expand All @@ -26,6 +27,7 @@ export default function GameProvider() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [activeRightTab, setActiveRightTab] = useState<'schema' | 'data'>('schema');
const [showLevelNavigator, setShowLevelNavigator] = useState(false);
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
const rightPanelRef = useRef<HTMLDivElement>(null);
const { currentLevel, totalXp, currentStreak, rehydrateFleet } = useGameStore();

Expand Down Expand Up @@ -269,43 +271,94 @@ export default function GameProvider() {

{/* UI overlay */}
<div className="relative z-10 h-full flex flex-col lg:flex-row overflow-y-auto lg:overflow-hidden">
{/* Left: SQL Editor */}
<div className="w-full lg:w-[460px] h-[75dvh] lg:h-full flex-shrink-0 p-3 lg:pr-2">
{/* Left: SQL Editor — grows to fill when right panel is collapsed */}
<div className="w-full lg:flex-1 min-w-0 h-[75dvh] lg:h-full p-3 lg:pr-2">
<SQLPanel onQueryRun={handleQueryRun} />
</div>

{/* Right: Schema / Results + status */}
<div ref={rightPanelRef} className="w-full lg:w-[420px] h-[75dvh] lg:h-full flex-shrink-0 p-3 lg:pl-2 flex flex-col gap-2">
{/* Tab bar */}
<div
className="flex"
style={{ background: 'var(--lcb-panel)', border: '1px solid var(--lcb-border)', borderRadius: 6 }}
>
{(['schema', 'data'] as const).map((tab) => (
{/* Right: Schema / Results + status — collapsible on desktop */}
<div
ref={rightPanelRef}
className={`w-full h-[75dvh] lg:h-full flex-shrink-0 flex flex-col gap-2 transition-all duration-200 ease-in-out${isPanelCollapsed ? ' lg:w-10 p-1.5 lg:p-1.5' : ' lg:w-[420px] p-3 lg:pl-2'}`}
>
{isPanelCollapsed ? (
/* ── Collapsed rail (desktop only) ──────────────────────── */
<div className="hidden lg:flex flex-col items-center gap-3 pt-1">
<button
key={tab}
onClick={() => setActiveRightTab(tab)}
aria-pressed={activeRightTab === tab}
className="flex-1 py-2 text-xs tracking-widest uppercase transition-colors"
onClick={() => setIsPanelCollapsed(false)}
aria-label="Expand schema panel"
title="Expand schema panel"
className="p-1.5 transition-opacity hover:opacity-80"
style={{
fontFamily: 'var(--font-ibm-plex-mono)',
color: activeRightTab === tab ? 'var(--lcb-gold)' : 'var(--lcb-muted)',
borderBottom: activeRightTab === tab ? '2px solid var(--lcb-gold)' : '2px solid transparent',
background: 'transparent',
background: 'var(--lcb-panel)',
border: '1px solid var(--lcb-border)',
borderRadius: 4,
color: 'var(--lcb-gold)',
}}
>
{tab === 'schema' ? 'Schema' : 'Results'}
<ChevronLeft className="w-3.5 h-3.5" />
</button>
))}
</div>
<span
className="text-[9px] tracking-[0.15em] uppercase select-none"
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
color: 'var(--lcb-muted)',
fontFamily: 'var(--font-ibm-plex-mono)',
}}
>
Schema
</span>
</div>
) : (
<>
{/* Tab bar */}
<div
className="flex items-center"
style={{ background: 'var(--lcb-panel)', border: '1px solid var(--lcb-border)', borderRadius: 6 }}
>
{(['schema', 'data'] as const).map((tab) => (
<button
key={tab}
onClick={() => 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)',
color: activeRightTab === tab ? 'var(--lcb-gold)' : 'var(--lcb-muted)',
borderBottom: activeRightTab === tab ? '2px solid var(--lcb-gold)' : '2px solid transparent',
background: 'transparent',
}}
>
{tab === 'schema' ? 'Schema' : 'Results'}
</button>
))}
{/* Desktop collapse toggle */}
<button
onClick={() => setIsPanelCollapsed(true)}
aria-label="Collapse panel"
title="Collapse schema panel"
className="hidden lg:flex items-center justify-center px-2.5 py-2 transition-opacity hover:opacity-70"
style={{
borderLeft: '1px solid var(--lcb-border)',
color: 'var(--lcb-muted)',
background: 'transparent',
flexShrink: 0,
}}
>
<ChevronRight className="w-3 h-3" />
</button>
</div>

{/* Tab content */}
<div className="flex-1 min-h-0">
{activeRightTab === 'schema' ? <SchemaViewer /> : <DataPreview />}
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{activeRightTab === 'schema' ? <SchemaViewer /> : <DataPreview />}
</div>

{/* Harbour status bar */}
<HarbourStatus onOpenLevelNavigator={() => setShowLevelNavigator(true)} />
{/* Harbour status bar */}
<HarbourStatus onOpenLevelNavigator={() => setShowLevelNavigator(true)} />
</>
)}
</div>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/components/LevelUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const EPOCH_NEXT_HINT: Record<number, string> = {
69: 'Sessionization / gaps & islands: LAG → flag → SUM() OVER → aggregate — the universal session-detection pattern',
70: 'Risk scorecard: weighted-average rate via SUM(x*w)/SUM(w), CROSS JOIN scalar CTE for denominators, running exposure window function',
71: 'Year-over-Year growth: STRFTIME → GROUP BY year → LAG(total) OVER → CASE WHEN NULL — the canonical period-over-period growth pattern',
72: 'Funnel analysis: UNION ALL multi-stage aggregation + CROSS JOIN scalar CTE for conversion rates — the standard product-analytics funnel pattern',
73: 'Deduplication: ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) + WHERE rn = 1 — pick exactly one row per group, the universal dedup / latest-record-per-entity pattern',
};

export default function LevelUpModal() {
Expand Down Expand Up @@ -63,7 +65,7 @@ export default function LevelUpModal() {
return () => window.removeEventListener('keydown', onKey);
}, [showLevelUp, handleClose]);

const nextHintKey = [10, 20, 30, 40, 44, 54, 57, 59, 61, 63, 64, 65, 66, 67, 68, 69, 70, 71].find((k) => currentLevel < k) ?? 71;
const nextHintKey = [10, 20, 30, 40, 44, 54, 57, 59, 61, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73].find((k) => currentLevel < k) ?? 73;
const xpEarned = xpFor(currentLevel);

return (
Expand Down
171 changes: 171 additions & 0 deletions src/data/levels/expert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1337,4 +1337,175 @@ SELECT loan_year,
epoch: 'Expert',
difficulty: 4,
},

// ============================================================
// FUNNEL ANALYSIS & DEDUPLICATION (Levels 72–73)
// ============================================================

{
id: 72,
title: 'Loan Product Acquisition Funnel',
description: `Product Strategy needs a multi-stage acquisition funnel showing how many LCB customers progress through each stage of the loan product journey. Build a **vertical funnel** with four stages and a conversion percentage:

| stage | customer_count | conversion_pct |
|---|---|---|
| All Customers | 20 | 100.0 |
| Loan Applicants | 15 | 75.0 |
| Active Loan Holders | 12 | 60.0 |
| Active Home Loan Holders | 5 | 25.0 |

Use **two CTEs**:
1. **\`funnel\`**: a \`UNION ALL\` of four scalar aggregate queries, each tagged with an \`ord\` (1–4) and a \`stage\` label — one row per funnel stage. The four stages:
- \`ord=1\`: \`COUNT(*) FROM customers\`
- \`ord=2\`: \`COUNT(DISTINCT customer_id) FROM loans\`
- \`ord=3\`: \`COUNT(DISTINCT customer_id) FROM loans WHERE status = 'Active'\`
- \`ord=4\`: \`COUNT(DISTINCT customer_id) FROM loans WHERE status = 'Active' AND product_id = 7\` *(product 7 = HDB Home Loan)*
2. **\`total\`**: \`SELECT customer_count AS total_customers FROM funnel WHERE ord = 1\` — a scalar CTE for the denominator.

Final SELECT: \`CROSS JOIN funnel f\` with \`total t\`, compute \`ROUND(100.0 * f.customer_count / t.total_customers, 1) AS conversion_pct\`. \`ORDER BY f.customer_count DESC\` — the four counts are distinct (20 → 15 → 12 → 5), so descending count order matches funnel stage order.

This UNION ALL + CROSS JOIN scalar CTE pattern is the standard SQL funnel in BigQuery, Redshift, and Snowflake product-analytics pipelines at FAANG, digital banks, and MAS-regulated fintechs.`,
hint: 'CTE funnel: SELECT 1 AS ord, \'All Customers\' AS stage, COUNT(*) AS customer_count FROM customers UNION ALL SELECT 2, \'Loan Applicants\', COUNT(DISTINCT customer_id) FROM loans UNION ALL ... (stages 3 and 4 add WHERE status=\'Active\' and AND product_id=7). CTE total: SELECT customer_count AS total_customers FROM funnel WHERE ord=1. Final: f.stage, f.customer_count, ROUND(100.0 * f.customer_count / t.total_customers, 1) AS conversion_pct FROM funnel f CROSS JOIN total t ORDER BY f.customer_count DESC.',
seedQuery: `WITH funnel AS (
SELECT 1 AS ord, 'All Customers' AS stage,
COUNT(*) AS customer_count
FROM customers
UNION ALL
SELECT 2, 'Loan Applicants',
COUNT(DISTINCT )
FROM loans
UNION ALL
SELECT 3, 'Active Loan Holders',
COUNT(DISTINCT customer_id)
FROM loans
WHERE status =
UNION ALL
SELECT 4, 'Active Home Loan Holders',
COUNT(DISTINCT customer_id)
FROM loans
WHERE status = 'Active' AND product_id =
),
total AS (
SELECT customer_count AS total_customers
FROM funnel
WHERE ord = 1
)
SELECT f.stage,
f.customer_count,
ROUND(100.0 * f.customer_count / , 1) AS conversion_pct
FROM funnel f
CROSS JOIN total t
ORDER BY f.customer_count DESC`,
solutionQuery: `WITH funnel AS (
SELECT 1 AS ord, 'All Customers' AS stage,
COUNT(*) AS customer_count
FROM customers
UNION ALL
SELECT 2, 'Loan Applicants',
COUNT(DISTINCT customer_id)
FROM loans
UNION ALL
SELECT 3, 'Active Loan Holders',
COUNT(DISTINCT customer_id)
FROM loans
WHERE status = 'Active'
UNION ALL
SELECT 4, 'Active Home Loan Holders',
COUNT(DISTINCT customer_id)
FROM loans
WHERE status = 'Active' AND product_id = 7
),
total AS (
SELECT customer_count AS total_customers
FROM funnel
WHERE ord = 1
)
SELECT f.stage,
f.customer_count,
ROUND(100.0 * f.customer_count / t.total_customers, 1) AS conversion_pct
FROM funnel f
CROSS JOIN total t
ORDER BY f.customer_count DESC`,
epoch: 'Expert',
difficulty: 4,
},

{
id: 73,
title: 'First Salary Credit Per Account (ROW_NUMBER Dedup)',
description: `The Compliance team needs to verify when each LCB account first received a salary credit — a mandatory check for income-verification under MAS Notice 632. Some accounts have received multiple salary credits over the months; you must return **exactly one row per account**: the earliest one.

Return:
- \`account_id\`
- \`customer_name\`
- \`amount\` — salary of the first credit
- \`first_salary_date\` — \`transaction_date\` of the earliest salary credit

**Pattern: ROW_NUMBER deduplication**

Use a single CTE \`ranked\`:
\`\`\`sql
WITH ranked AS (
SELECT t.account_id, c.customer_name, t.amount,
t.transaction_date AS first_salary_date,
ROW_NUMBER() OVER (
PARTITION BY t.account_id
ORDER BY t.transaction_date ASC
) AS rn
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.merchant_category = 'Salary Credit'
)
SELECT account_id, customer_name, amount, first_salary_date
FROM ranked
WHERE rn = 1
ORDER BY first_salary_date
\`\`\`

\`PARTITION BY account_id ORDER BY transaction_date ASC\` assigns \`rn = 1\` to the earliest salary transaction per account. \`WHERE rn = 1\` in the outer query selects exactly one row per account — the dedup step.

This ROW_NUMBER PARTITION BY pattern is the universal deduplication and **latest/earliest record per group** technique used across every SQL dialect (BigQuery, Redshift, Snowflake, Spark SQL, PostgreSQL) in ETL pipelines, slowly-changing dimension snapshots, and data-quality remediations at FAANG, hedge funds, and GIC.`,
hint: 'CTE ranked: SELECT t.account_id, c.customer_name, t.amount, t.transaction_date AS first_salary_date, ROW_NUMBER() OVER (PARTITION BY t.account_id ORDER BY t.transaction_date ASC) AS rn 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.merchant_category=\'Salary Credit\'. Outer: SELECT account_id, customer_name, amount, first_salary_date FROM ranked WHERE rn=1 ORDER BY first_salary_date.',
seedQuery: `WITH ranked AS (
SELECT
t.account_id,
c.customer_name,
t.amount,
t.transaction_date AS first_salary_date,
ROW_NUMBER() OVER (
PARTITION BY
ORDER BY t.transaction_date
) AS rn
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.merchant_category =
)
SELECT account_id, customer_name, amount, first_salary_date
FROM ranked
WHERE rn =
ORDER BY first_salary_date`,
solutionQuery: `WITH ranked AS (
SELECT
t.account_id,
c.customer_name,
t.amount,
t.transaction_date AS first_salary_date,
ROW_NUMBER() OVER (
PARTITION BY t.account_id
ORDER BY t.transaction_date ASC
) AS rn
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.merchant_category = 'Salary Credit'
)
SELECT account_id, customer_name, amount, first_salary_date
FROM ranked
WHERE rn = 1
ORDER BY first_salary_date`,
epoch: 'Expert',
difficulty: 4,
},
];
Loading