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

---

## Iteration 11 β€” 2026-06-22

### [UI/UX Improvements]

- **Ship-wheel loading spinner** (`GameProvider.tsx`): Replaced the flat shimmer-bar + lion emoji loading screen with an animated SVG maritime ship's helm. An 8-spoke wheel (hub radius 5.5, rim radius 24, knob radius 2.8 at each spoke tip) rotates at 3 s/revolution via `@keyframes spinWheel`. All geometry computed inline via `SPOKE_ANGLES.map()` β€” no external asset. The spinner is `aria-hidden="true"` since the adjacent text already describes the loading state. Directly implements the "ship-wheel / compass loading spinners" item from the UI/UX rotation.

- **Progressive hint system** (`SQLPanel.tsx`): Replaced the auto-display of the full hint (triggered on any failed attempt) with a click-to-reveal progressive disclosure UI. A `splitHint()` helper splits each level's hint string at `. ` boundaries into at most 3 chunks (grouping more if the hint is longer). After a first failed submission, a gold `πŸ’‘ Request hint` button appears instead of the full text. Each click reveals one additional chunk in the hint panel; earlier chunks fade to 70% opacity to visually de-emphasise them as the current tip. A `hintChunkIdx` state (reset on every level change) tracks disclosure depth. The button label changes to `Show more hint (N step(s) remaining)` after the first reveal. This changes the cognitive posture from passive delivery to active retrieval β€” learners who request hints engage more deeply than those who receive them automatically.

### [Game Design Tweaks]

- **Level 70** *(Expert, difficulty 4)* β€” "Loan Book Risk Scorecard β€” Weighted Rate & Running Exposure": Teaches four high-value patterns in a single query: (1) **weighted-average rate** via `ROUND(SUM(principal_amount * interest_rate) / SUM(principal_amount), 2)` β€” the correct way to aggregate rates that differ in volume; (2) **scalar CTE cross-join** using a second `total` CTE and `CROSS JOIN` in the final SELECT to access the grand total for portfolio share computation; (3) **portfolio percentage** `ROUND(100.0 * g.total_principal / t.grand_total, 1)`; (4) **running cumulative exposure** via `SUM(g.total_principal) OVER (ORDER BY g.risk_grade ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)`. Against the active loan book (12 loans, grades A/B/C only) the result is 3 rows: A (5 loans, $1.88M, 2.50%, 86.5%, cumulative $1.88M), B (4 loans, $245K, 2.89%, 11.3%, cumulative $2.13M), C (3 loans, $48K, 5.50%, 2.2%, cumulative $2.17M). This exact scorecard is used in Basel III RWA dashboards at GIC, JPMorgan, and DBS. No new data needed β€” uses the existing `loans` table.

- **Level 71** *(Expert, difficulty 4)* β€” "Loan Portfolio Year-over-Year Disbursement Growth": Teaches the canonical YoY growth pattern using two CTEs and `LAG`. CTE `yearly` groups all 15 loans (regardless of current status) by `STRFTIME('%Y', start_date)` into loan_count and total_disbursed. CTE `with_lag` adds `LAG(total_disbursed) OVER (ORDER BY loan_year) AS prev_disbursed`. Outer query computes `CASE WHEN prev_disbursed IS NULL THEN NULL ELSE ROUND(100.0 * (total_disbursed - prev_disbursed) / prev_disbursed, 1) END AS yoy_growth_pct`. Returns 7 rows (2018–2024) showing dramatic origination swings: +100% in 2020, -71.4% in 2021, +172.5% in 2022, -87.2% in 2023. The NULL-safe CASE WHEN pattern is the canonical SQL idiom for period-over-period growth, used in strategic planning decks, investor reports, and loan-book analytics at commercial banks and sovereign wealth funds. No new data needed.

### [Database & Code Optimizations]

- **`LevelUpModal` hint map** extended with entries for levels 70 (risk scorecard β€” weighted rate, CROSS JOIN scalar CTE) and 71 (YoY growth β€” STRFTIME + LAG + CASE WHEN NULL). `nextHintKey` sentinel array extended from `[…, 69]` to `[…, 69, 70, 71]`; fallback updated `?? 69` β†’ `?? 71`. `MAX_LEVEL` in `progression.ts` auto-derives from the last level in the array (71) β€” no other touch points needed.
- **Test suite**: 395 tests pass (up from 375 in iter-10). Both new levels are automatically picked up by the determinism invariants suite, verifying non-empty result sets and confirming all ORDER BY keys are distinct (no ties that could produce non-deterministic ordering).
- **No new DB tables, seed rows, or indexes** β€” both levels run cleanly against the existing `loans` table.

---

## Iteration 10 β€” 2026-06-15

### [UI/UX Improvements]
Expand Down
65 changes: 48 additions & 17 deletions src/components/GameProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,54 @@ export default function GameProvider() {

// ── Loading state ──────────────────────────────────────────────────────────
if (!isDbReady) {
const SPOKE_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315];
return (
<div className="fixed inset-0 flex flex-col items-center justify-center gap-6" style={{ background: 'var(--lcb-black)' }}>
{/* LCB lion crest placeholder */}
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" className="animate-gold-pulse">
<circle cx="24" cy="24" r="22" stroke="var(--lcb-gold)" strokeWidth="1.5" />
<text x="24" y="30" textAnchor="middle" fontSize="22" fill="var(--lcb-gold)" fontFamily="serif">🦁</text>
{/* Ship-wheel spinner */}
<svg
width="60"
height="60"
viewBox="0 0 56 56"
fill="none"
style={{ animation: 'spinWheel 3s linear infinite', flexShrink: 0 }}
aria-hidden="true"
>
{/* Outer rim */}
<circle cx="28" cy="28" r="24" stroke="var(--lcb-gold)" strokeWidth="2" opacity="0.55" />
{/* 8 spokes hub→rim */}
{SPOKE_ANGLES.map((deg) => {
const rad = (deg * Math.PI) / 180;
return (
<line
key={deg}
x1={28 + 6 * Math.sin(rad)}
y1={28 - 6 * Math.cos(rad)}
x2={28 + 21 * Math.sin(rad)}
y2={28 - 21 * Math.cos(rad)}
stroke="var(--lcb-gold)"
strokeWidth="1.5"
strokeLinecap="round"
/>
);
})}
{/* Handle knobs at spoke tips */}
{SPOKE_ANGLES.map((deg) => {
const rad = (deg * Math.PI) / 180;
return (
<circle
key={`k${deg}`}
cx={28 + 24 * Math.sin(rad)}
cy={28 - 24 * Math.cos(rad)}
r="2.8"
fill="var(--lcb-gold)"
/>
);
})}
{/* Hub */}
<circle cx="28" cy="28" r="5.5" stroke="var(--lcb-gold)" strokeWidth="1.5" fill="rgba(201,168,76,0.15)" />
<circle cx="28" cy="28" r="2" fill="var(--lcb-gold)" />
</svg>

<div className="text-center">
<p className="text-base font-medium tracking-widest uppercase" style={{ color: 'var(--lcb-gold)', fontFamily: 'var(--font-playfair)' }}>
Lion City Bank
Expand All @@ -99,20 +140,10 @@ export default function GameProvider() {
Initialising SQL Analytics Engine…
</p>
</div>
<div className="w-40 h-px overflow-hidden" style={{ background: 'var(--lcb-border)' }}>
<div
className="h-full"
style={{
background: 'var(--lcb-gold)',
animation: 'shimmerBar 1.4s ease-in-out infinite',
width: '40%',
}}
/>
</div>
<style>{`
@keyframes shimmerBar {
0% { margin-left: -40%; }
100% { margin-left: 100%; }
@keyframes spinWheel {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</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 @@ -25,6 +25,8 @@ const EPOCH_NEXT_HINT: Record<number, string> = {
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',
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',
};

export default function LevelUpModal() {
Expand Down Expand Up @@ -61,7 +63,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].find((k) => currentLevel < k) ?? 69;
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 xpEarned = xpFor(currentLevel);

return (
Expand Down
70 changes: 61 additions & 9 deletions src/components/SQLPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,25 @@ interface SQLPanelProps {
onQueryRun?: () => void;
}

/** Split a hint string into up to 3 progressive reveal chunks. */
function splitHint(hint: string): string[] {
const raw = hint.split('. ');
const parts = raw.map((p, i) => (i < raw.length - 1 ? p + '.' : p));
if (parts.length <= 3) return parts;
const n = parts.length;
return [
parts.slice(0, Math.ceil(n / 3)).join(' '),
parts.slice(Math.ceil(n / 3), Math.ceil((2 * n) / 3)).join(' '),
parts.slice(Math.ceil((2 * n) / 3)).join(' '),
];
}

export default function SQLPanel({ onQueryRun }: SQLPanelProps) {
const [query, setQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [failedAttempts, setFailedAttempts] = useState(0);
const [doubloonAmt, setDoubloonAmt] = useState<number | null>(null);
const [hintChunkIdx, setHintChunkIdx] = useState(0);
const editorRef = useRef<unknown>(null);
const monacoRef = useRef<Monaco | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -277,6 +291,7 @@ export default function SQLPanel({ onQueryRun }: SQLPanelProps) {
setQuery(levels.find((l) => l.id === currentLevel)?.seedQuery ?? '');
setError(null);
setFailedAttempts(0);
setHintChunkIdx(0);
const editor = editorRef.current as { focus?: () => void } | null;
if (editor?.focus) setTimeout(() => editor.focus?.(), 50);
}, [currentLevel]);
Expand All @@ -287,6 +302,8 @@ export default function SQLPanel({ onQueryRun }: SQLPanelProps) {
const expectedShape =
failedAttempts >= 3 && level ? getExpectedShape(level.solutionQuery) : null;

const hintChunks = level?.hint ? splitHint(level.hint) : [];

return (
<div
className="h-full flex flex-col overflow-hidden fade-in-up"
Expand Down Expand Up @@ -362,15 +379,50 @@ export default function SQLPanel({ onQueryRun }: SQLPanelProps) {
<p className="text-xs leading-5" style={{ color: 'var(--lcb-white)', opacity: 0.8, fontFamily: 'var(--font-ibm-plex-mono)', whiteSpace: 'pre-wrap' }}>
{level?.description}
</p>
{hasAttemptedCurrent && level?.hint && (
<div
className="flex items-start gap-2 mt-2 px-3 py-2"
style={{ border: '1px solid rgba(201,168,76,0.22)', background: 'rgba(201,168,76,0.05)', borderRadius: 3 }}
>
<Lightbulb className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--lcb-gold)' }} />
<p className="text-xs" style={{ color: 'var(--lcb-gold)', fontFamily: 'var(--font-ibm-plex-mono)' }}>
{level.hint}
</p>
{hasAttemptedCurrent && hintChunks.length > 0 && (
<div className="mt-2">
{hintChunkIdx > 0 && (
<div
className="flex items-start gap-2 px-3 py-2 mb-1"
style={{ border: '1px solid rgba(201,168,76,0.22)', background: 'rgba(201,168,76,0.05)', borderRadius: 3 }}
>
<Lightbulb className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--lcb-gold)' }} />
<div className="flex-1 min-w-0">
{hintChunks.slice(0, hintChunkIdx).map((chunk, i) => (
<p
key={i}
className="text-xs leading-5"
style={{
color: 'var(--lcb-gold)',
fontFamily: 'var(--font-ibm-plex-mono)',
opacity: i < hintChunkIdx - 1 ? 0.7 : 1,
marginTop: i > 0 ? 4 : 0,
}}
>
{chunk}
</p>
))}
</div>
</div>
)}
{hintChunkIdx < hintChunks.length && (
<button
onClick={() => setHintChunkIdx((n) => n + 1)}
className="flex items-center gap-1.5 text-xs px-2 py-1 transition-opacity hover:opacity-80"
style={{
fontFamily: 'var(--font-ibm-plex-mono)',
color: 'var(--lcb-gold)',
border: '1px solid rgba(201,168,76,0.3)',
borderRadius: 3,
background: 'rgba(201,168,76,0.04)',
}}
>
<Lightbulb className="w-3 h-3" />
{hintChunkIdx === 0
? 'Request hint'
: `Show more hint (${hintChunks.length - hintChunkIdx} step${hintChunks.length - hintChunkIdx === 1 ? '' : 's'} remaining)`}
</button>
)}
</div>
)}
{expectedShape && (
Expand Down
Loading
Loading