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 13 — 2026-07-06

### [UI/UX Improvements]

- **Keyboard Shortcuts Help Panel** (`SQLPanel.tsx`): Added a "Navigator's Chart" floating shortcuts reference card accessible via a `HelpCircle` icon button in the SQLPanel header (immediately left of the Run button). The panel appears as a gold-bordered floating card (`box-shadow: 0 8px 32px rgba(0,0,0,0.6)`) anchored above the button. It lists all four key bindings — Run (`⌘↵` / `Ctrl↵`), Submit (`⇧⌘↵` / `⇧Ctrl↵`), Toggle shortcuts (`?`), and Close overlays (`Esc`) — in a two-column layout with gold `<kbd>` chips. A compass (`Compass` Lucide icon) and thematic footer note ("Run freely to explore — only Submit counts toward grading") reinforce the maritime theme. The `?` key itself toggles the panel globally, except when the Monaco editor textarea has focus (to avoid intercepting SQL input). `Escape` closes it. Implements the "Accessibility & DX: keyboard shortcuts" item from the UI/UX rotation.

### [Game Design Tweaks]

- **Level 74** *(Expert, difficulty 4)* — "Active Loan Interest Accrual": Teaches simple-interest accrual date arithmetic, the standard MAS regulatory approximation for loan-book exposure. The formula `principal × (rate / 100) × JULIANDAY days / 365` is computed inline in a single SELECT JOIN against loans and customers — no CTE needed. Against the 12 active loans the result ranks Lee Hui Min's $450k grade-A home loan (started 2018-09-30, ~7.75 years elapsed) at the top with ~$87k accrued, and Siti Rahimah's $8k personal loan (started 2024-01-15) at the bottom with ~$1.1k accrued. The deliberate range (5× difference between oldest and newest loans) illustrates how accrual compounds time × principal simultaneously. This exact formula appears in IFRS 9 expected-credit-loss provisioning, Basel III RWA calculations, and treasury liquidity reports at GIC, DBS, and JPMorgan. No new tables or seed data required.

- **Level 75** *(Expert, difficulty 4)* — "A/B Test Sample-Ratio Mismatch (SRM) Check": Teaches the experimentation-platform SRM guard — the first check before any A/B test result can be trusted. Three-CTE chain: (1) `counts` groups `customers.ab_test_group` to get actual group sizes; (2) `total` sums to get the grand total; (3) `expected` CROSS JOINs counts with total to compute `expected_n = total × 0.5`. The outer query adds `deviation_pct = ROUND(100*(actual − expected)/expected, 1)` and a `CASE` expression that flags `'FAIL — SRM detected'` when `|deviation|/expected > 5 %` or `'PASS'` otherwise. With the balanced alternating 'A'/'B' assignment (10 each), both groups return `deviation_pct = 0.0, srm_check = PASS` — the cleanest pedagogical outcome: the check works correctly and the test is valid to analyse. This exact guard is mandated in Experimentation platforms at Booking.com, Airbnb, Netflix, and Meta before any metric computation. Uses the existing `ab_test_group` column added in Iteration 2.

### [Database & Code Optimizations]

- **`LevelUpModal` hint map** extended with entries for levels 74 (simple-interest accrual formula and JULIANDAY arithmetic) and 75 (multi-CTE SRM check pattern). `nextHintKey` sentinel array extended from `[…, 73]` to `[…, 73, 74, 75]`; fallback updated `?? 73` → `?? 75`. `MAX_LEVEL` in `progression.ts` auto-derives from the last level in the array (75) — no other touch points needed.
- **Tests**: 415 tests pass (up from 395 in iter-12). Both new levels are automatically picked up by the determinism invariants suite.
- **No new DB tables, seed rows, or indexes** — Level 74 uses the existing `loans` + `customers` tables; Level 75 uses the existing `customers.ab_test_group` column.

---

## Iteration 12 — 2026-06-29

### [UI/UX Improvements]
Expand Down
4 changes: 3 additions & 1 deletion src/components/LevelUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const EPOCH_NEXT_HINT: Record<number, string> = {
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',
74: 'Simple-interest accrual: principal × (rate/100) × JULIANDAY days / 365 — the standard MAS regulatory loan-book reporting formula',
75: 'A/B test SRM check: multi-CTE counts → CROSS JOIN total → deviation_pct → CASE srm_check — the Experimentation platform guard used at every FAANG-scale company',
};

export default function LevelUpModal() {
Expand Down Expand Up @@ -65,7 +67,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, 72, 73].find((k) => currentLevel < k) ?? 73;
const nextHintKey = [10, 20, 30, 40, 44, 54, 57, 59, 61, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75].find((k) => currentLevel < k) ?? 75;
const xpEarned = xpFor(currentLevel);

return (
Expand Down
116 changes: 115 additions & 1 deletion src/components/SQLPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState, useCallback, useRef, useEffect } from 'react';
import Editor, { Monaco, loader } from '@monaco-editor/react';
import { Play, Lightbulb, X, Anchor, Check } from 'lucide-react';
import { Play, Lightbulb, X, Anchor, Check, HelpCircle, Compass } from 'lucide-react';
import { useGameStore } from '@/store/useGameStore';
import { executeQuery } from '@/lib/db';
import { validateQuery, getExpectedShape } from '@/lib/validator';
Expand Down Expand Up @@ -63,6 +63,7 @@ export default function SQLPanel({ onQueryRun }: SQLPanelProps) {
const [failedAttempts, setFailedAttempts] = useState(0);
const [doubloonAmt, setDoubloonAmt] = useState<number | null>(null);
const [hintChunkIdx, setHintChunkIdx] = useState(0);
const [showShortcuts, setShowShortcuts] = useState(false);
const editorRef = useRef<unknown>(null);
const monacoRef = useRef<Monaco | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -91,6 +92,21 @@ export default function SQLPanel({ onQueryRun }: SQLPanelProps) {
if (!/Mac|iP(hone|ad|od)/.test(navigator.platform)) setModKey('Ctrl');
}, []);

// ? key toggles the shortcuts panel (ignored when editor textarea has focus).
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.target as HTMLElement).tagName === 'TEXTAREA') return;
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
setShowShortcuts((s) => !s);
} else if (e.key === 'Escape') {
setShowShortcuts(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);

// Touch devices get a 16px editor font (anything smaller makes iOS zoom the
// whole page when the editor focuses) and word wrap for narrow screens.
const [isTouch, setIsTouch] = useState(false);
Expand Down Expand Up @@ -338,6 +354,104 @@ export default function SQLPanel({ onQueryRun }: SQLPanelProps) {
+{doubloonAmt} ⬡ XP
</div>
)}

{/* ── Keyboard Shortcuts Help ───────────────────────────── */}
<div className="relative">
<button
onClick={() => setShowShortcuts((s) => !s)}
title="Keyboard shortcuts (?)"
aria-label="Toggle keyboard shortcuts"
className="lcb-icon-btn flex items-center justify-center w-6 h-6 transition-opacity hover:opacity-80"
style={{
color: showShortcuts ? 'var(--lcb-gold)' : 'var(--lcb-muted)',
border: `1px solid ${showShortcuts ? 'rgba(201,168,76,0.5)' : 'transparent'}`,
borderRadius: 4,
}}
>
<HelpCircle className="w-3.5 h-3.5" />
</button>

{showShortcuts && (
<div
className="absolute z-50"
style={{
bottom: 'calc(100% + 10px)',
right: 0,
width: 280,
background: 'var(--lcb-black)',
border: '1px solid rgba(201,168,76,0.35)',
borderRadius: 6,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
padding: '12px 14px',
}}
>
<div className="flex items-center gap-2 mb-3">
<Compass className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--lcb-gold)' }} />
<span
className="text-xs font-semibold uppercase tracking-widest"
style={{ color: 'var(--lcb-gold)', fontFamily: 'var(--font-ibm-plex-mono)' }}
>
Navigator&apos;s Chart
</span>
<button
onClick={() => setShowShortcuts(false)}
className="ml-auto lcb-icon-btn p-0.5 transition-opacity hover:opacity-60"
style={{ color: 'var(--lcb-muted)' }}
aria-label="Close shortcuts"
>
<X className="w-3 h-3" />
</button>
</div>

<div className="flex flex-col gap-2">
{([
{ keys: [`${modKey}↵`], label: 'Run query (free exploration)' },
{ keys: [`⇧${modKey}↵`], label: 'Submit for grading' },
{ keys: ['?'], label: 'Toggle this panel' },
{ keys: ['Esc'], label: 'Close overlays' },
] as { keys: string[]; label: string }[]).map(({ keys, label }) => (
<div key={label} className="flex items-center justify-between gap-3">
<span
className="text-xs"
style={{ color: 'var(--lcb-white)', opacity: 0.75, fontFamily: 'var(--font-ibm-plex-mono)' }}
>
{label}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{keys.map((k) => (
<kbd
key={k}
className="px-1.5 py-0.5 text-xs rounded"
style={{
fontFamily: 'var(--font-ibm-plex-mono)',
fontSize: 10,
color: 'var(--lcb-gold)',
background: 'rgba(201,168,76,0.12)',
border: '1px solid rgba(201,168,76,0.3)',
lineHeight: 1.4,
}}
>
{k}
</kbd>
))}
</div>
</div>
))}
</div>

<div
className="mt-3 pt-2.5"
style={{ borderTop: '1px solid rgba(201,168,76,0.15)' }}
>
<p className="text-xs" style={{ color: 'var(--lcb-muted)', fontFamily: 'var(--font-ibm-plex-mono)', lineHeight: 1.5 }}>
<Anchor className="w-3 h-3 inline mr-1" style={{ color: 'var(--lcb-gold)', opacity: 0.6, verticalAlign: 'middle' }} />
Run freely to explore — only Submit counts toward grading.
</p>
</div>
</div>
)}
</div>

<button
onClick={handleRun}
disabled={isExecuting}
Expand Down
116 changes: 116 additions & 0 deletions src/data/levels/expert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,4 +1508,120 @@ SELECT account_id, customer_name, amount, first_salary_date
epoch: 'Expert',
difficulty: 4,
},

// ── Level 74 ── Interest Accrual (date arithmetic) ─────────────────────────
{
id: 74,
title: 'Active Loan Interest Accrual',
description: `The MAS Liquidity Coverage Ratio report requires LCB to quantify total interest outstanding on every active loan as of today (2026-07-06). Using simple-interest accrual — the standard approximation for MAS regulatory reporting — compute how much interest has accumulated on each active loan from its start date to the reference date.

Formula: accrued_interest = principal × (rate / 100) × days_elapsed / 365

Return \`loan_id\`, \`customer_name\`, \`principal_amount\`, \`interest_rate\`, and \`accrued_interest_usd\` (rounded to 2 dp), ordered by accrued_interest_usd descending.

Note: Use JULIANDAY('2026-07-06') - JULIANDAY(l.start_date) for days_elapsed. Include only loans with status = 'Active'.`,
hint: "JOIN loans to customers on customer_id. Filter WHERE l.status = 'Active'. Compute ROUND(l.principal_amount * (l.interest_rate / 100.0) * (JULIANDAY('2026-07-06') - JULIANDAY(l.start_date)) / 365.0, 2) AS accrued_interest_usd. No CTE needed — a single SELECT with the arithmetic expression is sufficient. ORDER BY accrued_interest_usd DESC.",
seedQuery: `SELECT
l.loan_id,
c.customer_name,
l.principal_amount,
l.interest_rate,
ROUND(
l.principal_amount * (l.interest_rate / 100.0) *
(JULIANDAY( ) - JULIANDAY(l.start_date)) / 365.0,
2) AS accrued_interest_usd
FROM loans l
JOIN customers c ON l.customer_id =
WHERE l.status =
ORDER BY accrued_interest_usd DESC`,
solutionQuery: `SELECT
l.loan_id,
c.customer_name,
l.principal_amount,
l.interest_rate,
ROUND(
l.principal_amount * (l.interest_rate / 100.0) *
(JULIANDAY('2026-07-06') - JULIANDAY(l.start_date)) / 365.0,
2
) AS accrued_interest_usd
FROM loans l
JOIN customers c ON l.customer_id = c.customer_id
WHERE l.status = 'Active'
ORDER BY accrued_interest_usd DESC`,
epoch: 'Expert',
difficulty: 4,
},

// ── Level 75 ── A/B Test Sample-Ratio Mismatch ──────────────────────────────
{
id: 75,
title: 'A/B Test Sample-Ratio Mismatch (SRM) Check',
description: `LCB ran an A/B test on a new digital onboarding UI (captured in customers.ab_test_group). Before analysing conversion rates, the Experimentation team must verify that the traffic split is valid — a sample-ratio mismatch (SRM) means the assignment mechanism was broken and results cannot be trusted.

An SRM is declared when |actual_n − expected_n| / expected_n > 5 % (0.05).

For a balanced 50/50 split design, build a multi-CTE query:
1. \`counts\` — actual group sizes (GROUP BY ab_test_group)
2. \`total\` — grand total across both groups
3. \`expected\` — each group's expected_n = total × 0.5

Return \`ab_test_group\`, \`actual_n\`, \`expected_n\` (ROUND to 1 dp), \`deviation_pct\` (ROUND(100*(actual−expected)/expected, 1)), and \`srm_check\` ('FAIL — SRM detected' or 'PASS'), ordered by ab_test_group.`,
hint: "WITH counts AS (SELECT ab_test_group, COUNT(*) AS actual_n FROM customers GROUP BY ab_test_group), total AS (SELECT SUM(actual_n) AS total_n FROM counts), expected AS (SELECT c.ab_test_group, c.actual_n, ROUND(t.total_n * 0.5, 1) AS expected_n FROM counts c CROSS JOIN total t) — outer SELECT adds deviation_pct = ROUND(100.0*(actual_n - expected_n)/expected_n, 1) and CASE WHEN ABS(actual_n - expected_n)/expected_n > 0.05 THEN 'FAIL — SRM detected' ELSE 'PASS' END AS srm_check.",
seedQuery: `WITH counts AS (
SELECT ab_test_group, COUNT(*) AS actual_n
FROM customers
GROUP BY ab_test_group
),
total AS (
SELECT SUM(actual_n) AS total_n FROM counts
),
expected AS (
SELECT
c.ab_test_group,
c.actual_n,
ROUND(t.total_n * 0.5, 1) AS expected_n
FROM counts c
CROSS JOIN total t
)
SELECT
ab_test_group,
actual_n,
expected_n,
ROUND(100.0 * (actual_n - expected_n) / expected_n, 1) AS deviation_pct,
CASE
WHEN ABS(actual_n - expected_n) / expected_n > THEN 'FAIL — SRM detected'
ELSE 'PASS'
END AS srm_check
FROM expected
ORDER BY ab_test_group`,
solutionQuery: `WITH counts AS (
SELECT ab_test_group, COUNT(*) AS actual_n
FROM customers
GROUP BY ab_test_group
),
total AS (
SELECT SUM(actual_n) AS total_n FROM counts
),
expected AS (
SELECT
c.ab_test_group,
c.actual_n,
ROUND(t.total_n * 0.5, 1) AS expected_n
FROM counts c
CROSS JOIN total t
)
SELECT
ab_test_group,
actual_n,
expected_n,
ROUND(100.0 * (actual_n - expected_n) / expected_n, 1) AS deviation_pct,
CASE
WHEN ABS(actual_n - expected_n) / expected_n > 0.05 THEN 'FAIL — SRM detected'
ELSE 'PASS'
END AS srm_check
FROM expected
ORDER BY ab_test_group`,
epoch: 'Expert',
difficulty: 4,
},
];
Loading