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
21 changes: 21 additions & 0 deletions evolution_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion src/components/LevelUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const EPOCH_NEXT_HINT: Record<number, string> = {
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() {
Expand Down Expand Up @@ -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 (
Expand Down
53 changes: 53 additions & 0 deletions src/components/SQLPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
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 ' },
Expand Down Expand Up @@ -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?.();
};

Expand Down
9 changes: 9 additions & 0 deletions src/components/SchemaViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ const SCHEMA: Record<string, TableSchema> = {
],
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() {
Expand Down
168 changes: 168 additions & 0 deletions src/data/levels/expert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];
Loading
Loading