diff --git a/evolution_log.md b/evolution_log.md index 4887008..68513a3 100644 --- a/evolution_log.md +++ b/evolution_log.md @@ -4,6 +4,27 @@ Each entry records one autonomous improvement iteration. --- +## Iteration 10 — 2026-06-15 + +### [UI/UX Improvements] + +- **Schema-aware SQL auto-complete** (`SQLPanel.tsx`): Monaco now surfaces every LCB table name and column as IntelliSense suggestions when the player types in the editor. A module-level `LCB_SCHEMA` constant maps all 10 tables (customers, accounts, transactions, loans, products, branches, vessels, cargo_shipments, trade_finance_facilities, portal_logins) to their columns. A `registerCompletionItemProvider('sql', …)` call inside `handleEditorMount` registers table entries as `CompletionItemKind.Class` (with "LCB table" detail) and column entries as `CompletionItemKind.Field` (with the parent table name as detail). A `lcbCompletionsRegistered` module-level guard prevents duplicate registration across editor remounts. Players typing a partial table or column name see a ranked dropdown without any CDN dependency — auto-complete is fully self-hosted alongside Monaco. +- **`portal_logins` table in SchemaViewer**: the new table's columns (`login_id`, `customer_id`, `login_at`) are documented with descriptions and a sample query (`SELECT * FROM portal_logins ORDER BY customer_id, login_at`), keeping the schema panel the single source of truth for the playground. + +### [Game Design Tweaks] + +- **Level 68** *(Expert, difficulty 4)* — "Portal Onboarding Cohort: Month-1 Retention": + Teaches the canonical three-CTE cohort-retention pattern: (1) first_logins CTE groups `portal_logins` by `customer_id` to find each customer's earliest login and `STRFTIME('%Y-%m', MIN(login_at))` cohort month; (2) retained CTE identifies customers who logged in during the immediately following calendar month using `DATE(first_login_at, '+1 month')`; (3) outer query LEFT JOINs and aggregates `cohort_size`, `retained_month2`, and `ROUND(100.0 * retained / cohort, 1) AS retention_rate_pct` per cohort. Four cohorts (Jan–Apr 2024) yield 75.0 %, 66.7 %, 50.0 %, and 0.0 % Month-1 retention — authentic variation that makes the pedagogical point. This exact pattern appears in Stripe, Revolut, and FAANG DS take-homes. +- **Level 69** *(Expert, difficulty 5)* — "Portal Session Reconstruction (Gaps & Islands)": + Teaches the universal four-CTE sessionization pattern: (1) lag_applied adds `LAG(login_at) OVER (PARTITION BY customer_id ORDER BY login_at)`; (2) session_flags marks each row `is_new_session = 1` when the gap from the previous login exceeds 30 minutes using `(JULIANDAY(login_at) - JULIANDAY(prev_login_at)) * 24 * 60 > 30`; (3) sessions_numbered assigns a per-customer session ID via `SUM(is_new_session) OVER (PARTITION BY customer_id ORDER BY login_at)` — the classic cumulative-sum island trick; (4) session_stats aggregates each session's duration in whole minutes with `CAST(ROUND(… * 24 * 60) AS INTEGER)`. Outer query returns `total_sessions` and `longest_session_mins` per customer (11 rows). Identical pattern works in BigQuery, Redshift, Snowflake, and Spark SQL. Highest difficulty (5) in the catalog — the capstone Expert challenge. + +### [Database & Code Optimizations] + +- **`portal_logins` table** added to `src/lib/seed.ts`: 36 rows across customers 1–11, timestamps from Jan–Jun 2024 with deliberate within-session clusters (gap < 30 min) and cross-session gaps. Designed simultaneously to support cohort analysis (customers joining the portal in Jan/Feb/Mar/Apr 2024 cohorts) and sessionization (mixed session durations from 0 to 25 min). Two new indexes: `idx_portal_logins_customer ON portal_logins(customer_id)` and `idx_portal_logins_at ON portal_logins(login_at)`. +- **`LevelUpModal` hint map** extended with entries for levels 68 and 69; `nextHintKey` sentinel array extended from `[…, 67]` to `[…, 67, 68, 69]`; fallback `?? 67` updated to `?? 69`. `MAX_LEVEL` in `progression.ts` derives from `levels[levels.length - 1].id` and auto-updates to 69 — no other touch points needed. + +--- + ## Iteration 9 — 2026-06-12 ### [Mobile & Touch Pass — closes the improvement plan] diff --git a/src/components/LevelUpModal.tsx b/src/components/LevelUpModal.tsx index 662319c..f486a0e 100644 --- a/src/components/LevelUpModal.tsx +++ b/src/components/LevelUpModal.tsx @@ -23,6 +23,8 @@ const EPOCH_NEXT_HINT: Record = { 65: 'Running balance: SUM() OVER (ROWS UNBOUNDED PRECEDING) — the universal treasury ledger pattern', 66: 'Anti-joins: LEFT JOIN … IS NULL — finding the rows that have no match', 67: 'NOT EXISTS: the correlated anti-join without the NOT IN NULL trap', + 68: 'Cohort retention: first-login cohort month → Month-1 return rate — the core product-growth metric', + 69: 'Sessionization / gaps & islands: LAG → flag → SUM() OVER → aggregate — the universal session-detection pattern', }; export default function LevelUpModal() { @@ -59,7 +61,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].find((k) => currentLevel < k) ?? 67; + const nextHintKey = [10, 20, 30, 40, 44, 54, 57, 59, 61, 63, 64, 65, 66, 67, 68, 69].find((k) => currentLevel < k) ?? 69; const xpEarned = xpFor(currentLevel); return ( diff --git a/src/components/SQLPanel.tsx b/src/components/SQLPanel.tsx index dc13351..b9945aa 100644 --- a/src/components/SQLPanel.tsx +++ b/src/components/SQLPanel.tsx @@ -14,6 +14,21 @@ import { epochOf, xpFor, EPOCH_RANK } from '@/lib/progression'; // editor is the product, so it must not depend on a third party being up. loader.config({ paths: { vs: '/vendor/monaco/vs' } }); +// LCB schema for SQL auto-complete — registered once per page load. +let lcbCompletionsRegistered = false; +const LCB_SCHEMA: Record = { + customers: ['customer_id', 'customer_name', 'segment', 'credit_score', 'join_date', 'email', 'ab_test_group'], + accounts: ['account_id', 'customer_id', 'product_id', 'branch_id', 'balance', 'opened_date', 'status'], + transactions: ['transaction_id', 'account_id', 'amount', 'transaction_type', 'transaction_date', 'merchant_category', 'channel'], + loans: ['loan_id', 'customer_id', 'product_id', 'principal_amount', 'interest_rate', 'term_months', 'start_date', 'status', 'risk_grade'], + products: ['product_id', 'product_name', 'product_type', 'interest_rate', 'min_balance'], + branches: ['branch_id', 'branch_name', 'city', 'region', 'branch_type'], + vessels: ['vessel_id', 'vessel_name', 'vessel_type', 'flag_state', 'dwt_tonnes', 'year_built', 'owner_customer_id'], + cargo_shipments: ['shipment_id', 'vessel_id', 'origin_port', 'destination_port', 'cargo_type', 'cargo_value_usd', 'departure_date', 'arrival_date', 'status'], + trade_finance_facilities: ['facility_id', 'customer_id', 'vessel_id', 'facility_type', 'facility_amount', 'utilised_amount', 'expiry_date', 'status'], + portal_logins: ['login_id', 'customer_id', 'login_at'], +}; + const SQL_SNIPPETS = [ { label: 'SELECT', insert: 'SELECT ' }, { label: 'FROM', insert: '\nFROM ' }, @@ -108,6 +123,44 @@ export default function SQLPanel({ onQueryRun }: SQLPanelProps) { monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => handlersRef.current.submit() ); + + // Register schema-aware SQL completions once per page load. + if (!lcbCompletionsRegistered) { + lcbCompletionsRegistered = true; + monaco.languages.registerCompletionItemProvider('sql', { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provideCompletionItems(model: any, position: any) { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const suggestions: any[] = [ + ...Object.keys(LCB_SCHEMA).map(tbl => ({ + label: tbl, + kind: monaco.languages.CompletionItemKind.Class, + insertText: tbl, + range, + detail: 'LCB table', + })), + ...Object.entries(LCB_SCHEMA).flatMap(([tbl, cols]) => + cols.map(col => ({ + label: col, + kind: monaco.languages.CompletionItemKind.Field, + insertText: col, + range, + detail: tbl, + })) + ), + ]; + return { suggestions }; + }, + }); + } + ed.focus?.(); }; diff --git a/src/components/SchemaViewer.tsx b/src/components/SchemaViewer.tsx index 22c1206..97259aa 100644 --- a/src/components/SchemaViewer.tsx +++ b/src/components/SchemaViewer.tsx @@ -135,6 +135,15 @@ const SCHEMA: Record = { ], sample: 'SELECT * FROM trade_finance_facilities;', }, + portal_logins: { + description: 'Customer digital banking portal login events — used for cohort & session analysis', + columns: [ + { name: 'login_id', type: 'INTEGER', description: 'Unique identifier', primaryKey: true }, + { name: 'customer_id', type: 'INTEGER', description: 'FK → customers', foreignKey: 'customers.customer_id' }, + { name: 'login_at', type: 'TEXT', description: 'Login timestamp (YYYY-MM-DD HH:MM:SS)' }, + ], + sample: 'SELECT * FROM portal_logins ORDER BY customer_id, login_at;', + }, }; export default function SchemaViewer() { diff --git a/src/data/levels/expert.ts b/src/data/levels/expert.ts index a66e490..201070f 100644 --- a/src/data/levels/expert.ts +++ b/src/data/levels/expert.ts @@ -1032,4 +1032,172 @@ Prefer \`NOT EXISTS\` over \`NOT IN\` for anti-joins: if the \`NOT IN\` subquery epoch: 'Expert', difficulty: 4, }, + + // ============================================================ + // COHORT RETENTION (Level 68) + // ============================================================ + + { + id: 68, + title: 'Portal Onboarding Cohort: Month-1 Retention', + description: `The Digital Banking squad needs to know how well our onboarding funnel retains new users. Every customer who logs into the portal for the first time in a given month forms a **cohort**. Measure how many returned the following month. + +Using the \`portal_logins\` table: +1. A first CTE (\`first_logins\`) finds each customer's earliest login and labels them with their **cohort month** (\`STRFTIME('%Y-%m', MIN(login_at))\`). +2. A second CTE (\`retained\`) finds the \`DISTINCT\` set of customers who logged in during **the month immediately after** their cohort month — use \`DATE(first_login_at, '+1 month')\` to derive the next month and compare it with \`STRFTIME('%Y-%m', pl.login_at)\`. +3. The outer query LEFT JOINs \`first_logins\` to \`retained\` and aggregates per cohort: + - \`cohort_month\` — the calendar month (YYYY-MM) + - \`cohort_size\` — total distinct customers whose first login was in that month + - \`retained_month2\` — how many came back the following month + - \`retention_rate_pct\` — \`ROUND(100.0 * retained_month2 / cohort_size, 1)\` + +Order by \`cohort_month\`. + +Cohort retention is the first metric every Head of Growth asks for and a fixture in FAANG and fintech DS interviews. This exact three-CTE pattern (cohort definition → activity join → aggregation) appears in Stripe, Revolut, and DBS DS take-home tests.`, + hint: "CTE 1: SELECT customer_id, MIN(login_at) AS first_login_at, STRFTIME('%Y-%m', MIN(login_at)) AS cohort_month FROM portal_logins GROUP BY customer_id. CTE 2: DISTINCT customer_ids where STRFTIME('%Y-%m', pl.login_at) = STRFTIME('%Y-%m', DATE(fl.first_login_at, '+1 month')). Outer: LEFT JOIN + COUNT DISTINCT + ROUND.", + seedQuery: `WITH first_logins AS ( + SELECT customer_id, + MIN(login_at) AS first_login_at, + STRFTIME('%Y-%m', MIN(login_at)) AS cohort_month + FROM portal_logins + GROUP BY customer_id +), +retained AS ( + SELECT DISTINCT fl.customer_id + FROM first_logins fl + JOIN portal_logins pl ON pl.customer_id = fl.customer_id + WHERE STRFTIME('%Y-%m', pl.login_at) = STRFTIME('%Y-%m', DATE(fl.first_login_at, )) +) +SELECT fl.cohort_month, + COUNT(DISTINCT fl.customer_id) AS cohort_size, + COUNT(DISTINCT r.customer_id) AS retained_month2, + ROUND(100.0 * COUNT(DISTINCT r.customer_id) / COUNT(DISTINCT fl.customer_id), 1) AS retention_rate_pct + FROM first_logins fl + LEFT JOIN retained r ON r.customer_id = fl.customer_id + GROUP BY + ORDER BY fl.cohort_month`, + solutionQuery: `WITH first_logins AS ( + SELECT customer_id, + MIN(login_at) AS first_login_at, + STRFTIME('%Y-%m', MIN(login_at)) AS cohort_month + FROM portal_logins + GROUP BY customer_id +), +retained AS ( + SELECT DISTINCT fl.customer_id + FROM first_logins fl + JOIN portal_logins pl ON pl.customer_id = fl.customer_id + WHERE STRFTIME('%Y-%m', pl.login_at) = STRFTIME('%Y-%m', DATE(fl.first_login_at, '+1 month')) +) +SELECT fl.cohort_month, + COUNT(DISTINCT fl.customer_id) AS cohort_size, + COUNT(DISTINCT r.customer_id) AS retained_month2, + ROUND(100.0 * COUNT(DISTINCT r.customer_id) / COUNT(DISTINCT fl.customer_id), 1) AS retention_rate_pct + FROM first_logins fl + LEFT JOIN retained r ON r.customer_id = fl.customer_id + GROUP BY fl.cohort_month + ORDER BY fl.cohort_month`, + epoch: 'Expert', + difficulty: 4, + }, + + // ============================================================ + // SESSIONIZATION / GAPS & ISLANDS (Level 69) + // ============================================================ + + { + id: 69, + title: 'Portal Session Reconstruction (Gaps & Islands)', + description: `The UX Insights team needs to understand how deeply customers engage during each visit. A **session** is a continuous run of logins by the same customer where each consecutive login arrives within **30 minutes** of the previous one. A gap larger than 30 minutes — or the customer's very first login — marks the start of a new session. + +Using \`portal_logins\`, reconstruct sessions and return per-customer session statistics: +- \`customer_id\` +- \`total_sessions\` — count of distinct sessions +- \`longest_session_mins\` — duration in whole minutes of the longest session (start → last login in that session) + +The four-CTE approach: +1. **\`lag_applied\`**: use \`LAG(login_at) OVER (PARTITION BY customer_id ORDER BY login_at)\` to fetch each row's previous login timestamp. +2. **\`session_flags\`**: \`CASE WHEN prev_login_at IS NULL OR (JULIANDAY(login_at) - JULIANDAY(prev_login_at)) * 24 * 60 > 30 THEN 1 ELSE 0 END AS is_new_session\`. +3. **\`sessions_numbered\`**: \`SUM(is_new_session) OVER (PARTITION BY customer_id ORDER BY login_at)\` gives each row a stable session ID within the customer. +4. **\`session_stats\`**: GROUP BY customer_id + session_id → compute \`CAST(ROUND((JULIANDAY(MAX(login_at)) - JULIANDAY(MIN(login_at))) * 24 * 60) AS INTEGER) AS duration_mins\`. + +Outer query: COUNT sessions and MAX duration per customer, ORDER BY \`customer_id\`. + +Sessionization is the canonical "gaps and islands" interview question at FAANG, fintech, and product-analytics roles. The four-CTE pattern — LAG → flag → cumsum → aggregate — works identically in BigQuery, Redshift, Snowflake, Spark SQL, and SQLite.`, + hint: "CTE 1: LAG(login_at) OVER (PARTITION BY customer_id ORDER BY login_at). CTE 2: CASE WHEN prev IS NULL OR (JULIANDAY(login_at)-JULIANDAY(prev))*24*60 > 30 THEN 1 ELSE 0 END. CTE 3: SUM(is_new_session) OVER (...) AS session_id. CTE 4: GROUP BY customer_id, session_id → CAST(ROUND(duration_mins) AS INTEGER). Final: COUNT + MAX per customer.", + seedQuery: `WITH lag_applied AS ( + SELECT customer_id, + login_at, + LAG(login_at) OVER (PARTITION BY customer_id ORDER BY login_at) AS prev_login_at + FROM portal_logins +), +session_flags AS ( + SELECT customer_id, + login_at, + CASE + WHEN prev_login_at IS NULL + OR (JULIANDAY(login_at) - JULIANDAY(prev_login_at)) * 24 * 60 > + THEN 1 + ELSE 0 + END AS is_new_session + FROM lag_applied +), +sessions_numbered AS ( + SELECT customer_id, + login_at, + SUM(is_new_session) OVER (PARTITION BY customer_id ORDER BY login_at) AS session_id + FROM session_flags +), +session_stats AS ( + SELECT customer_id, + session_id, + CAST(ROUND((JULIANDAY(MAX(login_at)) - JULIANDAY(MIN(login_at))) * 24 * 60) AS INTEGER) AS duration_mins + FROM sessions_numbered + GROUP BY customer_id, session_id +) +SELECT customer_id, + COUNT(session_id) AS total_sessions, + MAX(duration_mins) AS longest_session_mins + FROM session_stats + GROUP BY + ORDER BY customer_id`, + solutionQuery: `WITH lag_applied AS ( + SELECT customer_id, + login_at, + LAG(login_at) OVER (PARTITION BY customer_id ORDER BY login_at) AS prev_login_at + FROM portal_logins +), +session_flags AS ( + SELECT customer_id, + login_at, + CASE + WHEN prev_login_at IS NULL + OR (JULIANDAY(login_at) - JULIANDAY(prev_login_at)) * 24 * 60 > 30 + THEN 1 + ELSE 0 + END AS is_new_session + FROM lag_applied +), +sessions_numbered AS ( + SELECT customer_id, + login_at, + SUM(is_new_session) OVER (PARTITION BY customer_id ORDER BY login_at) AS session_id + FROM session_flags +), +session_stats AS ( + SELECT customer_id, + session_id, + CAST(ROUND((JULIANDAY(MAX(login_at)) - JULIANDAY(MIN(login_at))) * 24 * 60) AS INTEGER) AS duration_mins + FROM sessions_numbered + GROUP BY customer_id, session_id +) +SELECT customer_id, + COUNT(session_id) AS total_sessions, + MAX(duration_mins) AS longest_session_mins + FROM session_stats + GROUP BY customer_id + ORDER BY customer_id`, + epoch: 'Expert', + difficulty: 5, + }, ]; diff --git a/src/lib/seed.ts b/src/lib/seed.ts index 5c7cad2..44370e6 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -396,6 +396,53 @@ export function seedDatabase(database: Database): void { ) ); + // ── Portal Logins ───────────────────────────────────────────────────────── + // 36 rows across 11 customers (IDs 1–11), Jan–Jun 2024. Timestamps enable + // cohort-retention analysis (Level 68) and session-gap detection (Level 69). + database.run(` + CREATE TABLE IF NOT EXISTS portal_logins ( + login_id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL, + login_at TEXT NOT NULL, + FOREIGN KEY (customer_id) REFERENCES customers(customer_id) + ); + `); + + type LoginRow = [number, string]; + const loginData: LoginRow[] = [ + // Customer 1 — Jan cohort; sessions: (09:00-09:18), (10:05), (Feb 03), (Mar 08) + [1, '2024-01-15 09:00:00'], [1, '2024-01-15 09:18:00'], [1, '2024-01-15 10:05:00'], + [1, '2024-02-03 11:00:00'], [1, '2024-03-08 15:30:00'], + // Customer 2 — Jan cohort; sessions: (14:00-14:22), (Feb 18) + [2, '2024-01-20 14:00:00'], [2, '2024-01-20 14:22:00'], [2, '2024-02-18 09:30:00'], + // Customer 3 — Jan cohort; sessions: (Jan 25), (Feb 07 16:00), (Feb 07 16:40), (Apr 12) + [3, '2024-01-25 08:45:00'], [3, '2024-02-07 16:00:00'], + [3, '2024-02-07 16:40:00'], [3, '2024-04-12 10:20:00'], + // Customer 4 — Jan cohort, churned Feb; sessions: (10:00-10:25), (May 20) + [4, '2024-01-10 10:00:00'], [4, '2024-01-10 10:25:00'], [4, '2024-05-20 08:00:00'], + // Customer 5 — Feb cohort; sessions: (09:00), (09:45), (Mar 10) + [5, '2024-02-05 09:00:00'], [5, '2024-02-05 09:45:00'], [5, '2024-03-10 14:30:00'], + // Customer 6 — Feb cohort; sessions: (Feb 14), (09:00-09:12 Mar 22) + [6, '2024-02-14 11:00:00'], [6, '2024-03-22 09:00:00'], [6, '2024-03-22 09:12:00'], + // Customer 7 — Feb cohort, churned Mar; sessions: (Feb 28), (Jun 01) + [7, '2024-02-28 16:00:00'], [7, '2024-06-01 10:00:00'], + // Customer 8 — Mar cohort; sessions: (Mar 01), (13:00-13:20 Apr 15), (13:55 Apr 15) + [8, '2024-03-01 08:00:00'], [8, '2024-04-15 13:00:00'], + [8, '2024-04-15 13:20:00'], [8, '2024-04-15 13:55:00'], + // Customer 9 — Mar cohort, churned Apr; sessions: (10:00-10:10 Mar 18), (May 30) + [9, '2024-03-18 10:00:00'], [9, '2024-03-18 10:10:00'], [9, '2024-05-30 09:00:00'], + // Customer 10 — Apr cohort; sessions: (09:00-09:08 Apr 02), (Jun 10) + [10, '2024-04-02 09:00:00'], [10, '2024-04-02 09:08:00'], [10, '2024-06-10 14:00:00'], + // Customer 11 — Apr cohort; sessions: (15:00-15:15 Apr 20), (16:00 Apr 20) + [11, '2024-04-20 15:00:00'], [11, '2024-04-20 15:15:00'], [11, '2024-04-20 16:00:00'], + ]; + loginData.forEach(([cid, lat]) => + database.run( + `INSERT INTO portal_logins (customer_id, login_at) VALUES (?, ?)`, + [cid, lat] + ) + ); + // ── Indexes ─────────────────────────────────────────────────────────────── database.run('CREATE INDEX IF NOT EXISTS idx_accounts_customer ON accounts(customer_id)'); database.run('CREATE INDEX IF NOT EXISTS idx_accounts_product ON accounts(product_id)'); @@ -416,4 +463,6 @@ export function seedDatabase(database: Database): void { database.run('CREATE INDEX IF NOT EXISTS idx_customers_ab_group ON customers(ab_test_group)'); database.run('CREATE INDEX IF NOT EXISTS idx_accounts_opened_date ON accounts(opened_date)'); database.run('CREATE INDEX IF NOT EXISTS idx_transactions_channel ON transactions(channel)'); + database.run('CREATE INDEX IF NOT EXISTS idx_portal_logins_customer ON portal_logins(customer_id)'); + database.run('CREATE INDEX IF NOT EXISTS idx_portal_logins_at ON portal_logins(login_at)'); }