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
27 changes: 27 additions & 0 deletions evolution_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>` 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]
Expand Down
12 changes: 12 additions & 0 deletions src/components/GameProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,13 +29,23 @@ 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<string | null>(null);
const prevRankRef = useRef<string | null>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const { currentLevel, totalXp, currentStreak, rehydrateFleet } = useGameStore();

// Career-rank ladder derived from the level data (see progression.ts),
// 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))
Expand Down Expand Up @@ -366,6 +377,7 @@ export default function GameProvider() {
<LevelNavigator isOpen={showLevelNavigator} onClose={() => setShowLevelNavigator(false)} />
<LevelUpModal />
<OnboardingOverlay />
<RankPromotionBanner rankName={promotionRank} onDismiss={() => setPromotionRank(null)} />
</div>
);
}
4 changes: 3 additions & 1 deletion src/components/LevelUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const EPOCH_NEXT_HINT: Record<number, string> = {
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() {
Expand Down Expand Up @@ -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 (
Expand Down
163 changes: 163 additions & 0 deletions src/components/RankPromotionBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { title: string; body: string; icon: string }> = {
'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<ReturnType<typeof setTimeout> | 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 (
<AnimatePresence>
{rankName && flavour && (
<motion.div
key={rankName}
initial={{ y: -80, opacity: 0, scale: 0.96 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: -80, opacity: 0, scale: 0.96 }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
onClick={onDismiss}
style={{
position: 'fixed',
top: 56,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 55,
cursor: 'pointer',
minWidth: 320,
maxWidth: 480,
}}
aria-live="assertive"
role="status"
>
<div
style={{
background: 'var(--lcb-panel-2)',
border: '1.5px solid var(--lcb-gold)',
borderRadius: 6,
boxShadow: '0 8px 40px rgba(0,0,0,0.7), 0 0 0 1px rgba(201,168,76,0.15)',
overflow: 'hidden',
}}
>
{/* Gold top stripe */}
<div style={{ height: 3, background: 'var(--lcb-gold)', width: '100%' }} />

<div className="flex items-start gap-4 px-5 py-4">
{/* Icon with compass spin animation */}
<motion.span
initial={{ rotate: 0 }}
animate={{ rotate: 720 }}
transition={{ duration: 1.2, ease: 'easeOut', delay: 0.1 }}
style={{ fontSize: 28, flexShrink: 0, lineHeight: 1 }}
aria-hidden="true"
>
{flavour.icon}
</motion.span>

<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
fontSize: 9,
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: 'var(--lcb-gold)',
fontFamily: 'var(--font-ibm-plex-mono)',
marginBottom: 4,
}}
>
Lion City Bank — Career Advancement
</p>
<p
style={{
fontSize: 13,
fontWeight: 700,
letterSpacing: '0.06em',
color: 'var(--lcb-gold)',
fontFamily: 'var(--font-playfair)',
marginBottom: 6,
textTransform: 'uppercase',
lineHeight: 1.3,
}}
>
{flavour.title}
</p>
<p
style={{
fontSize: 12,
lineHeight: 1.55,
color: 'var(--lcb-muted)',
fontFamily: 'var(--font-ibm-plex-mono)',
}}
>
{flavour.body}
</p>
</div>

<button
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
aria-label="Dismiss promotion notification"
style={{
flexShrink: 0,
color: 'var(--lcb-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
padding: '0 2px',
opacity: 0.6,
}}
>
×
</button>
</div>

{/* Auto-dismiss countdown bar */}
<motion.div
initial={{ width: '100%' }}
animate={{ width: '0%' }}
transition={{ duration: DURATION_MS / 1000, ease: 'linear' }}
style={{
height: 2,
background: 'var(--lcb-gold)',
opacity: 0.45,
marginTop: -1,
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
Loading
Loading