From eb1f4965181a2e1669f6ee9db059c74af8e0fc92 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 17:16:16 +0000 Subject: [PATCH] feat(iter-10): cohort retention, sessionization levels, schema auto-complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI/UX — Monaco schema-aware SQL auto-complete: - Register a CompletionItemProvider for 'sql' in handleEditorMount (SQLPanel.tsx) so all 10 LCB tables and their columns surface as IntelliSense suggestions while the player types. Tables appear as Class items ("LCB table" detail), columns as Field items (parent table name as detail). - lcbCompletionsRegistered module-level guard prevents duplicate registration on editor remounts. Fully self-hosted — no CDN dependency. - portal_logins added to SchemaViewer SCHEMA map with column descriptions. Game Design — two new Expert levels (68–69): - Level 68 (Expert, diff 4): "Portal Onboarding Cohort: Month-1 Retention" Three-CTE pattern: first_logins → retained (DATE '+1 month') → LEFT JOIN aggregate. Four Jan–Apr 2024 cohorts yield 75.0/66.7/50.0/0.0 % Month-1 retention. Canonical FAANG/fintech DS interview pattern. - Level 69 (Expert, diff 5): "Portal Session Reconstruction (Gaps & Islands)" Four-CTE sessionization: LAG → is_new_session flag (gap > 30 min via JULIANDAY arithmetic) → SUM() OVER cumulative session_id → GROUP BY customer+session. Returns total_sessions and longest_session_mins per customer (11 rows). Capstone Expert challenge; pattern is identical across BigQuery, Redshift, Snowflake, Spark SQL. Database: - portal_logins table added to seed.ts: 36 rows across customers 1–11, Jan–Jun 2024 timestamps with deliberate within-session clusters and cross-session gaps. Supports both levels above. - Two new indexes: idx_portal_logins_customer, idx_portal_logins_at. Tests: 385 passed (5 new per-level integrity + determinism tests; tsc clean). https://claude.ai/code/session_01SpHnzfYYhQWTi4RNZRLMtB --- evolution_log.md | 21 ++++ src/components/LevelUpModal.tsx | 4 +- src/components/SQLPanel.tsx | 53 ++++++++++ src/components/SchemaViewer.tsx | 9 ++ src/data/levels/expert.ts | 168 ++++++++++++++++++++++++++++++++ src/lib/seed.ts | 49 ++++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) 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)'); }