diff --git a/evolution_log.md b/evolution_log.md index 6232b6d..3ea4096 100644 --- a/evolution_log.md +++ b/evolution_log.md @@ -4,6 +4,33 @@ Each entry records one autonomous improvement iteration. --- +## Iteration 14 — 2026-07-13 + +### [UI/UX Improvements] + +- **Rank Promotion Banner** (`RankPromotionBanner.tsx`, `GameProvider.tsx`): When a player's XP crosses a career-rank threshold (Graduate Analyst → Senior Analyst → VP, Data & Analytics → Managing Director), an animated maritime-themed promotion notification slides in from the top-center of the screen. Design details: + - Spring-animated entrance (`stiffness: 320, damping: 26`) slides the card down from 80 px above the viewport and out the same way on dismiss. + - Gold top accent stripe + `box-shadow: 0 8px 40px rgba(0,0,0,0.7)` give depth without blocking gameplay. + - The rank-appropriate icon (⚓ / 🧭 / 🏅) spins 720° on entry via `framer-motion` (2 full rotations, ease-out, 1.2 s) — a single deliberate motion moment rather than ambient animation. + - A depleting gold countdown bar across the bottom auto-dismisses after 6 seconds; a dismiss `×` button and click-anywhere-to-close ensure no accidental blockers. + - Each rank has bespoke flavour text linking the player's SQL skill set to the promotion (`fleet's risk dashboards`, `LCB analytics fleet is yours to command`). + - Detection in `GameProvider.tsx` via `useRef` tracking the previous rank name, compared against `rankInfo(totalXp).name` on every XP change. Promotion fires only when `prevRank !== null` (ignores the initial render). + - Implements the "gamification: rank tiers" rotation item, giving the career-rank ladder (added in iter-7) its missing feedback loop. + +### [Game Design Tweaks] + +- **Level 76** *(Expert, difficulty 4)* — "Monthly Revenue Completeness Check (WITH RECURSIVE)": Introduces the `WITH RECURSIVE` date-spine pattern — the first use of recursive CTEs in the entire level catalog. The recursive CTE `months(month)` seeds with `'2024-01'` and advances by one calendar month via `STRFTIME('%Y-%m', DATE(month || '-01', '+1 month'))` with `WHERE month < '2024-06'` as the termination guard. A second CTE `monthly_revenue` aggregates `cargo_shipments` by month. The outer query LEFT JOINs the spine to actual revenue, using `COALESCE` for zero-fill and a `CASE WHEN r.month IS NULL` for a `'No shipments'` / `'Active'` status flag. Returns 6 rows — Jan through Jun 2024, all Active (the data has complete coverage, proving the check works correctly). The pedagogical point: a plain `GROUP BY` silently hides zero-activity periods; date spines make gaps explicit. This pattern is foundational in every BI pipeline at FAANG, digital banks, and sovereign wealth funds — used in Looker measures, Redshift/Snowflake time-spine logic, and MAS reporting gap-detection pipelines. + +- **Level 77** *(Expert, difficulty 4)* — "Fleet Voyage Bookends (FIRST_VALUE + LAST_VALUE)": Teaches `FIRST_VALUE` and `LAST_VALUE` window functions with the critical **window-frame gotcha**. `LAST_VALUE` with its default frame `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` returns the current row's value, not the partition's last — a common production bug. The solution requires the explicit frame `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`. The level uses a single CTE `voyage_data` computing all four window expressions together (`FIRST_VALUE`, `LAST_VALUE`, `COUNT(*) OVER`, `ROW_NUMBER() OVER`), then the outer query deduplicates with `WHERE rn = 1`. Returns 12 rows — one per vessel, ordered by `vessel_id`. Vessels with multiple departures from different ports (2, 4, 5, 6, 7, 9, 10) show `first_origin ≠ last_origin`, revealing routing evolution over the year; single-voyage vessels (11, 12) and vessels that always depart Singapore (1, 3, 8) show matching bookends. This exact FIRST_VALUE/LAST_VALUE frame pattern appears in FAANG DS interviews, trading-desk settlement-gap analysis, and logistics routing pipelines at sovereign wealth funds. + +### [Database & Code Optimizations] + +- **`LevelUpModal` hint map** extended with entries for levels 76 (WITH RECURSIVE date spine — "the universal gap-filling pattern for contiguous time-series in every BI pipeline") and 77 (FIRST_VALUE + LAST_VALUE window frames — "always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING for LAST_VALUE"). `nextHintKey` sentinel array extended from `[…, 74, 75]` to `[…, 74, 75, 76, 77]`; fallback updated `?? 75` → `?? 77`. `MAX_LEVEL` in `progression.ts` auto-derives from `levels[levels.length - 1].id` (77) — no other touch points needed. +- **Tests**: 425 tests pass (up from 415 in iter-13). Both new levels auto-picked up by the determinism invariants suite — non-empty result sets confirmed, all ORDER BY keys verified distinct. +- **No new DB tables, seed rows, or indexes** — Level 76 uses the existing `cargo_shipments` table; Level 77 uses the existing `cargo_shipments` + `vessels` tables. + +--- + ## Iteration 13 — 2026-07-06 ### [UI/UX Improvements] diff --git a/src/components/GameProvider.tsx b/src/components/GameProvider.tsx index c68eb5e..53c3b79 100644 --- a/src/components/GameProvider.tsx +++ b/src/components/GameProvider.tsx @@ -15,6 +15,7 @@ import { EPOCH_RANGES, MAX_LEVEL, rankInfo } from '@/lib/progression'; import SchemaViewer from './SchemaViewer'; import LevelProgressMap from './LevelProgressMap'; import OnboardingOverlay from './OnboardingOverlay'; +import RankPromotionBanner from './RankPromotionBanner'; const HarbourCanvas = dynamic(() => import('./FlockCanvas'), { ssr: false, @@ -28,6 +29,8 @@ export default function GameProvider() { const [activeRightTab, setActiveRightTab] = useState<'schema' | 'data'>('schema'); const [showLevelNavigator, setShowLevelNavigator] = useState(false); const [isPanelCollapsed, setIsPanelCollapsed] = useState(false); + const [promotionRank, setPromotionRank] = useState(null); + const prevRankRef = useRef(null); const rightPanelRef = useRef(null); const { currentLevel, totalXp, currentStreak, rehydrateFleet } = useGameStore(); @@ -35,6 +38,14 @@ export default function GameProvider() { // so every rank up to Managing Director is actually reachable. const rank = rankInfo(totalXp); + // Detect rank promotions and surface the banner. + useEffect(() => { + if (prevRankRef.current !== null && prevRankRef.current !== rank.name) { + setPromotionRank(rank.name); + } + prevRankRef.current = rank.name; + }, [rank.name]); + useEffect(() => { initDatabase() .then(() => setIsDbReady(true)) @@ -366,6 +377,7 @@ export default function GameProvider() { setShowLevelNavigator(false)} /> + setPromotionRank(null)} /> ); } diff --git a/src/components/LevelUpModal.tsx b/src/components/LevelUpModal.tsx index edc762b..f7d402a 100644 --- a/src/components/LevelUpModal.tsx +++ b/src/components/LevelUpModal.tsx @@ -31,6 +31,8 @@ const EPOCH_NEXT_HINT: Record = { 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', + 76: 'WITH RECURSIVE date spine: iterative CTE + LEFT JOIN zero-fill — the universal gap-filling pattern for contiguous time-series in every BI pipeline', + 77: 'FIRST_VALUE + LAST_VALUE: always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING for LAST_VALUE — the default frame only looks back to the current row', }; export default function LevelUpModal() { @@ -67,7 +69,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, 74, 75].find((k) => currentLevel < k) ?? 75; + const nextHintKey = [10, 20, 30, 40, 44, 54, 57, 59, 61, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77].find((k) => currentLevel < k) ?? 77; const xpEarned = xpFor(currentLevel); return ( diff --git a/src/components/RankPromotionBanner.tsx b/src/components/RankPromotionBanner.tsx new file mode 100644 index 0000000..36d1189 --- /dev/null +++ b/src/components/RankPromotionBanner.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface Props { + rankName: string | null; + onDismiss: () => void; +} + +const RANK_FLAVOUR: Record = { + 'Senior Analyst': { + title: 'PROMOTED TO SENIOR ANALYST', + body: "Your command of JOIN and GROUP BY has earned your first promotion. The fleet's intermediate ledgers are yours to navigate.", + icon: '⚓', + }, + 'VP, Data & Analytics': { + title: 'PROMOTED TO VP, DATA & ANALYTICS', + body: "Advanced window functions, CTEs, and trade-finance analytics — LCB entrusts the fleet's risk dashboards to your helm.", + icon: '🧭', + }, + 'Managing Director': { + title: 'PROMOTED TO MANAGING DIRECTOR', + body: 'You have mastered the full SQL arsenal: cohort analysis, sessionization, A/B experimentation, and loan-book risk. The entire LCB analytics fleet is yours to command.', + icon: '🏅', + }, +}; + +const DURATION_MS = 6000; + +export default function RankPromotionBanner({ rankName, onDismiss }: Props) { + const timerRef = useRef | null>(null); + const flavour = rankName ? RANK_FLAVOUR[rankName] : null; + + useEffect(() => { + if (!rankName) return; + timerRef.current = setTimeout(onDismiss, DURATION_MS); + return () => { if (timerRef.current) clearTimeout(timerRef.current); }; + }, [rankName, onDismiss]); + + return ( + + {rankName && flavour && ( + +
+ {/* Gold top stripe */} +
+ +
+ {/* Icon with compass spin animation */} + + +
+

+ Lion City Bank — Career Advancement +

+

+ {flavour.title} +

+

+ {flavour.body} +

+
+ + +
+ + {/* Auto-dismiss countdown bar */} + +
+ + )} + + ); +} diff --git a/src/data/levels/expert.ts b/src/data/levels/expert.ts index 65adc11..d5b4c5c 100644 --- a/src/data/levels/expert.ts +++ b/src/data/levels/expert.ts @@ -1624,4 +1624,155 @@ ORDER BY ab_test_group`, epoch: 'Expert', difficulty: 4, }, + + // ── Level 76 ── WITH RECURSIVE date spine ────────────────────────────────── + { + id: 76, + title: 'Monthly Revenue Completeness Check (WITH RECURSIVE)', + description: `The LCB Data Quality team needs to confirm that cargo revenue data is present for **every** month in the Jan–Jun 2024 reporting window — not just the months that have shipments. Missing months must appear as zeros, not invisible gaps. + +The standard SQL technique is a **date spine**: a recursive CTE that generates every month in the range, then LEFT JOINs the actual data so zero-activity months show up explicitly. + +**Step 1 — Generate the date spine:** +\`\`\`sql +WITH RECURSIVE months(month) AS ( + SELECT '2024-01' + UNION ALL + SELECT STRFTIME('%Y-%m', DATE(month || '-01', '+1 month')) + FROM months + WHERE month < '2024-06' +) +\`\`\` +\`WITH RECURSIVE\` is the standard SQL mechanism for iterative computation. Each row generates the next by advancing the date by one month; the \`WHERE\` clause is the termination condition. + +**Step 2 — Aggregate actual revenue by month**, then LEFT JOIN the spine to it. + +Return \`month\`, \`shipments\` (COALESCE to 0), \`revenue_m_usd\` (SUM of cargo_value_usd ÷ 1 000 000, ROUND to 2 dp, COALESCE to 0.00), and \`status\` ('No shipments' when the month had no data, 'Active' otherwise), ordered by month. + +**Why it matters:** Date spines are fundamental to every BI pipeline at FAANG, digital banks, and sovereign wealth funds. Without them, a simple GROUP BY silently hides zero-activity periods — which masquerades as missing ETL runs in production dashboards.`, + hint: "WITH RECURSIVE months(month) AS (SELECT '2024-01' UNION ALL SELECT STRFTIME('%Y-%m', DATE(month || '-01', '+1 month')) FROM months WHERE month < '2024-06'), monthly_revenue AS (SELECT STRFTIME('%Y-%m', departure_date) AS month, COUNT(*) AS shipment_count, ROUND(SUM(cargo_value_usd)/1000000.0,2) AS revenue_m_usd FROM cargo_shipments GROUP BY month) — outer: SELECT m.month, COALESCE(r.shipment_count,0) AS shipments, COALESCE(r.revenue_m_usd,0.00) AS revenue_m_usd, CASE WHEN r.month IS NULL THEN 'No shipments' ELSE 'Active' END AS status FROM months m LEFT JOIN monthly_revenue r ON m.month = r.month ORDER BY m.month.", + seedQuery: `WITH RECURSIVE months(month) AS ( + SELECT '2024-01' + UNION ALL + SELECT STRFTIME('%Y-%m', DATE(month || '-01', '+1 month')) + FROM months + WHERE month < +), +monthly_revenue AS ( + SELECT + STRFTIME('%Y-%m', departure_date) AS month, + COUNT(*) AS shipment_count, + ROUND(SUM(cargo_value_usd) / 1000000.0, 2) AS revenue_m_usd + FROM cargo_shipments + GROUP BY month +) +SELECT + m.month, + COALESCE(r.shipment_count, 0) AS shipments, + COALESCE(r.revenue_m_usd, 0.00) AS revenue_m_usd, + CASE WHEN r.month IS NULL THEN 'No shipments' ELSE 'Active' END AS status +FROM months m +LEFT JOIN monthly_revenue r ON m.month = r.month +ORDER BY m.month`, + solutionQuery: `WITH RECURSIVE months(month) AS ( + SELECT '2024-01' + UNION ALL + SELECT STRFTIME('%Y-%m', DATE(month || '-01', '+1 month')) + FROM months + WHERE month < '2024-06' +), +monthly_revenue AS ( + SELECT + STRFTIME('%Y-%m', departure_date) AS month, + COUNT(*) AS shipment_count, + ROUND(SUM(cargo_value_usd) / 1000000.0, 2) AS revenue_m_usd + FROM cargo_shipments + GROUP BY month +) +SELECT + m.month, + COALESCE(r.shipment_count, 0) AS shipments, + COALESCE(r.revenue_m_usd, 0.00) AS revenue_m_usd, + CASE WHEN r.month IS NULL THEN 'No shipments' ELSE 'Active' END AS status +FROM months m +LEFT JOIN monthly_revenue r ON m.month = r.month +ORDER BY m.month`, + epoch: 'Expert', + difficulty: 4, + }, + + // ── Level 77 ── FIRST_VALUE + LAST_VALUE ──────────────────────────────────── + { + id: 77, + title: 'Fleet Voyage Bookends (FIRST_VALUE + LAST_VALUE)', + description: `The LCB Fleet Operations desk needs a vessel utilisation summary showing each ship's **first ever** departure port and **most recent** departure port — the bookends of its service history in the LCB data. This reveals routing evolution: did a tanker's homebase shift from Singapore to Bangkok over the year? + +Use \`FIRST_VALUE\` and \`LAST_VALUE\` window functions partitioned by vessel, combined with a \`ROW_NUMBER\` dedup to return exactly one row per vessel. + +**Critical gotcha — LAST_VALUE and window frames:** +\`LAST_VALUE\` with its *default* frame \`RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW\` returns the **current row's value**, not the partition's last. You must specify an explicit frame: +\`\`\`sql +LAST_VALUE(s.origin_port) OVER ( + PARTITION BY v.vessel_id ORDER BY s.departure_date + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING +) +\`\`\` + +**Pattern:** Single CTE \`voyage_data\` computes all four window expressions together (FIRST_VALUE, LAST_VALUE, COUNT, ROW_NUMBER), then the outer query filters \`WHERE rn = 1\` to collapse each vessel to one row. + +Return \`vessel_id\`, \`vessel_name\`, \`vessel_type\`, \`voyage_count\`, \`first_origin\`, \`last_origin\`, ordered by \`vessel_id\`. + +This exact FIRST_VALUE / LAST_VALUE frame pattern appears in FAANG DS interviews, trading-desk settlement-gap analysis, and logistics routing pipelines at sovereign wealth funds.`, + hint: "WITH voyage_data AS (SELECT v.vessel_id, v.vessel_name, v.vessel_type, COUNT(*) OVER (PARTITION BY v.vessel_id) AS voyage_count, FIRST_VALUE(s.origin_port) OVER (PARTITION BY v.vessel_id ORDER BY s.departure_date) AS first_origin, LAST_VALUE(s.origin_port) OVER (PARTITION BY v.vessel_id ORDER BY s.departure_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_origin, ROW_NUMBER() OVER (PARTITION BY v.vessel_id ORDER BY s.departure_date) AS rn FROM cargo_shipments s JOIN vessels v ON s.vessel_id = v.vessel_id) — outer: SELECT vessel_id, vessel_name, vessel_type, voyage_count, first_origin, last_origin FROM voyage_data WHERE rn = 1 ORDER BY vessel_id.", + seedQuery: `WITH voyage_data AS ( + SELECT + v.vessel_id, + v.vessel_name, + v.vessel_type, + COUNT(*) OVER (PARTITION BY v.vessel_id) AS voyage_count, + FIRST_VALUE(s.origin_port) OVER ( + PARTITION BY v.vessel_id ORDER BY s.departure_date + ) AS first_origin, + LAST_VALUE(s.origin_port) OVER ( + PARTITION BY v.vessel_id ORDER BY s.departure_date + ROWS BETWEEN UNBOUNDED PRECEDING AND + ) AS last_origin, + ROW_NUMBER() OVER ( + PARTITION BY v.vessel_id ORDER BY s.departure_date + ) AS rn + FROM cargo_shipments s + JOIN vessels v ON s.vessel_id = v.vessel_id +) +SELECT vessel_id, vessel_name, vessel_type, voyage_count, + first_origin, last_origin +FROM voyage_data +WHERE rn = 1 +ORDER BY vessel_id`, + solutionQuery: `WITH voyage_data AS ( + SELECT + v.vessel_id, + v.vessel_name, + v.vessel_type, + COUNT(*) OVER (PARTITION BY v.vessel_id) AS voyage_count, + FIRST_VALUE(s.origin_port) OVER ( + PARTITION BY v.vessel_id ORDER BY s.departure_date + ) AS first_origin, + LAST_VALUE(s.origin_port) OVER ( + PARTITION BY v.vessel_id ORDER BY s.departure_date + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS last_origin, + ROW_NUMBER() OVER ( + PARTITION BY v.vessel_id ORDER BY s.departure_date + ) AS rn + FROM cargo_shipments s + JOIN vessels v ON s.vessel_id = v.vessel_id +) +SELECT vessel_id, vessel_name, vessel_type, voyage_count, + first_origin, last_origin +FROM voyage_data +WHERE rn = 1 +ORDER BY vessel_id`, + epoch: 'Expert', + difficulty: 4, + }, ];