` stays transparent.
+
+The refactored component actually mounts in the Problem 3 tab, with
+`useWalletBalances`, `usePrices`, `WalletRow`, and `BoxProps` provided
+by [`mocks.tsx`](src/problem3/mocks.tsx).
+
+---
+
+## Demo / running the project
+
+```bash
+npm install
+npm run dev # interactive UI at http://localhost:5173
+npm test # vitest (294 tests, problem 1)
+npm run typecheck # tsc --noEmit
+npm run build # static build into ./dist
+```
+
+Tabs use hash routing β go straight to a problem via
+`#/problem1`, `#/problem2`, or `#/problem3`.
+
+### Stack
+
+- Vite 5 + React 18 + TypeScript 5 (strict mode)
+- Vitest for the Problem 1 algorithm tests
+- No runtime deps beyond React + ReactDOM
+
+### Layout
+
+```
+code-challenge/
+βββ package.json, tsconfig.json, vite.config.ts, index.html
+βββ assets/
+β βββ solution2-preview.png
+βββ src/
+β βββ main.tsx, App.tsx, shell.css # tab shell
+β βββ problem1/
+β β βββ lib/{sumToN,bigNumber}.ts # 4 implementations + helpers
+β β βββ tests/sumToN.test.ts # vitest, 294 tests
+β β βββ solutions.ts # algorithm metadata
+β β βββ view.tsx, style.css
+β βββ problem2/
+β β βββ view.tsx, style.css
+β β βββ api.ts, types.ts # fetch + dedupe prices
+β β βββ balances.ts # JSON seed + applySwap
+β β βββ data/balances.json # initial wallet snapshot
+β β βββ format.ts, tokenIcon.ts
+β β βββ components/{TokenSelect,TokenIcon}.tsx
+β βββ problem3/
+β βββ analysis.ts # 17 catalogued issues
+β βββ Refactored.tsx # working WalletPage
+β βββ mocks.tsx # useWalletBalances / usePrices / ...
+β βββ original.tsx.txt # prompt code, raw-imported
+β βββ view.tsx, style.css
+```
+
+### Submission
+
+Either link to this repository or attach the build output β every problem
+has its source under `src/` and is reachable from the tabbed UI started
+with `npm run dev`.
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000000..a84a2b20c8
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from 'react';
+import { Problem1View } from './problem1/view';
+import { Problem2View } from './problem2/view';
+import { Problem3View } from './problem3/view';
+
+type TabId = 'problem1' | 'problem2' | 'problem3';
+
+const TABS: { id: TabId; label: string; blurb: string }[] = [
+ {
+ id: 'problem1',
+ label: 'Problem 1 β Sum to N',
+ blurb: 'Four implementations: iterative, formula, functional, big-number.',
+ },
+ {
+ id: 'problem2',
+ label: 'Problem 2 β Fancy Swap',
+ blurb: 'Currency swap form with live Switcheo prices.',
+ },
+ {
+ id: 'problem3',
+ label: 'Problem 3 β Messy React',
+ blurb: 'Issue analysis + refactored WalletPage live demo.',
+ },
+];
+
+const parseHash = (): TabId => {
+ const raw = window.location.hash.replace(/^#\/?/, '');
+ if (raw === 'problem2' || raw === 'problem3') return raw;
+ return 'problem1';
+};
+
+export function App() {
+ const [tab, setTab] = useState
(parseHash);
+
+ useEffect(() => {
+ const onHashChange = () => setTab(parseHash());
+ window.addEventListener('hashchange', onHashChange);
+ return () => window.removeEventListener('hashchange', onHashChange);
+ }, []);
+
+ const activeTab = TABS.find((t) => t.id === tab)!;
+
+ return (
+
+
+
+
+ {tab === 'problem1' && }
+ {tab === 'problem2' && }
+ {tab === 'problem3' && }
+
+
+
+
+ Source on GitHub Β· Run with npm run dev Β· Tests with{' '}
+ npm test
+
+
+
+ );
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000000..fba284fe94
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+import './shell.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/src/problem1/.keep b/src/problem1/.keep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/problem1/lib/bigNumber.ts b/src/problem1/lib/bigNumber.ts
new file mode 100644
index 0000000000..7a5c730ae1
--- /dev/null
+++ b/src/problem1/lib/bigNumber.ts
@@ -0,0 +1,129 @@
+// Decimal big-number arithmetic on plain strings.
+//
+// Inputs are integer strings, optionally prefixed with '-'.
+// All exported operations preserve sign correctly and never lose precision.
+// Used by sum_to_n_d to compute n * (n + 1) / 2 without hitting
+// Number.MAX_SAFE_INTEGER.
+
+export const stripLeadingZeros = (s: string): string => {
+ let i = 0;
+ while (i < s.length - 1 && s.charCodeAt(i) === 48) i++;
+ return s.slice(i);
+};
+
+// Compare magnitudes of two non-negative decimal strings. Returns -1, 0, or 1.
+export const cmpAbs = (a: string, b: string): -1 | 0 | 1 => {
+ const aa = stripLeadingZeros(a);
+ const bb = stripLeadingZeros(b);
+ if (aa.length !== bb.length) return aa.length < bb.length ? -1 : 1;
+ if (aa < bb) return -1;
+ if (aa > bb) return 1;
+ return 0;
+};
+
+interface Split {
+ sign: 1 | -1;
+ abs: string;
+}
+
+const splitSign = (s: string): Split => {
+ if (s.startsWith('-')) return { sign: -1, abs: s.slice(1) };
+ return { sign: 1, abs: s };
+};
+
+const applySign = (sign: 1 | -1, abs: string): string => {
+ const stripped = stripLeadingZeros(abs);
+ if (stripped === '0') return '0';
+ return sign === -1 ? '-' + stripped : stripped;
+};
+
+// Add two non-negative decimal strings, right-to-left, with carry.
+const addAbs = (a: string, b: string): string => {
+ let i = a.length - 1;
+ let j = b.length - 1;
+ let carry = 0;
+ let result = '';
+ while (i >= 0 || j >= 0 || carry > 0) {
+ let sum = carry;
+ if (i >= 0) sum += a.charCodeAt(i--) - 48;
+ if (j >= 0) sum += b.charCodeAt(j--) - 48;
+ carry = (sum / 10) | 0;
+ result = (sum % 10) + result;
+ }
+ return result;
+};
+
+// Subtract b from a assuming a >= b >= 0 (both non-negative decimal strings).
+const subAbs = (a: string, b: string): string => {
+ let i = a.length - 1;
+ let j = b.length - 1;
+ let borrow = 0;
+ let result = '';
+ while (i >= 0) {
+ let diff = (a.charCodeAt(i--) - 48) - borrow;
+ if (j >= 0) diff -= (b.charCodeAt(j--) - 48);
+ if (diff < 0) {
+ diff += 10;
+ borrow = 1;
+ } else {
+ borrow = 0;
+ }
+ result = diff + result;
+ }
+ return stripLeadingZeros(result);
+};
+
+// Signed addition. Handles all four sign combinations by reducing to
+// addAbs / subAbs of magnitudes.
+export const addBigNumbers = (a: string, b: string): string => {
+ const A = splitSign(a);
+ const B = splitSign(b);
+ if (A.sign === B.sign) {
+ return applySign(A.sign, addAbs(A.abs, B.abs));
+ }
+ const cmp = cmpAbs(A.abs, B.abs);
+ if (cmp === 0) return '0';
+ if (cmp > 0) return applySign(A.sign, subAbs(A.abs, B.abs));
+ return applySign(B.sign, subAbs(B.abs, A.abs));
+};
+
+// Long multiplication, O(d_a * d_b).
+const mulAbs = (a: string, b: string): string => {
+ const A = stripLeadingZeros(a);
+ const B = stripLeadingZeros(b);
+ if (A === '0' || B === '0') return '0';
+ const out: number[] = new Array(A.length + B.length).fill(0);
+ for (let i = A.length - 1; i >= 0; i--) {
+ const da = A.charCodeAt(i) - 48;
+ for (let j = B.length - 1; j >= 0; j--) {
+ const db = B.charCodeAt(j) - 48;
+ const pos = i + j + 1;
+ const total = out[pos] + da * db;
+ out[pos] = total % 10;
+ out[pos - 1] += (total / 10) | 0;
+ }
+ }
+ return stripLeadingZeros(out.join(''));
+};
+
+export const mulBigNumbers = (a: string, b: string): string => {
+ const A = splitSign(a);
+ const B = splitSign(b);
+ const sign = (A.sign * B.sign) as 1 | -1;
+ return applySign(sign, mulAbs(A.abs, B.abs));
+};
+
+// Floor division of a decimal string by 2, single-digit long division.
+// The only caller (sum_to_n_d) always divides a non-negative even product,
+// so the floor matches the true quotient.
+export const divBy2 = (s: string): string => {
+ const { sign, abs } = splitSign(s);
+ let result = '';
+ let carry = 0;
+ for (let i = 0; i < abs.length; i++) {
+ const cur = carry * 10 + (abs.charCodeAt(i) - 48);
+ result += ((cur / 2) | 0).toString();
+ carry = cur % 2;
+ }
+ return applySign(sign, result);
+};
diff --git a/src/problem1/lib/sumToN.ts b/src/problem1/lib/sumToN.ts
new file mode 100644
index 0000000000..8f4faeff28
--- /dev/null
+++ b/src/problem1/lib/sumToN.ts
@@ -0,0 +1,54 @@
+// Four implementations of sum_to_n.
+//
+// sum_to_n_a β iterative loop O(n) time, O(1) space
+// sum_to_n_b β Gauss closed-form formula O(1) time, O(1) space
+// sum_to_n_c β Array.from + reduce O(n) time, O(n) space
+// sum_to_n_d β big-number Gauss on strings O(d^2) where d = digits of n
+//
+// a / b / c assume the result fits in Number.MAX_SAFE_INTEGER, per the
+// problem statement. d lifts that restriction by doing every arithmetic
+// step on decimal strings; it accepts number | string and returns string.
+//
+// For negative n, every variant returns the negation of the positive sum:
+// sum_to_n(-5) === -(1 + 2 + 3 + 4 + 5) === -15.
+
+import { addBigNumbers, mulBigNumbers, divBy2 } from './bigNumber';
+
+export const sum_to_n_a = (n: number): number => {
+ if (n < 0) return -sum_to_n_a(-n);
+ let sum = 0;
+ for (let i = 1; i <= n; i++) sum += i;
+ return sum;
+};
+
+export const sum_to_n_b = (n: number): number => {
+ if (n < 0) return -sum_to_n_b(-n);
+ return (n * (n + 1)) / 2;
+};
+
+export const sum_to_n_c = (n: number): number => {
+ if (n < 0) return -sum_to_n_c(-n);
+ return Array.from({ length: n }, (_, i) => i + 1).reduce((a, b) => a + b, 0);
+};
+
+// Accepts number | string. Always returns a string, because the result can
+// exceed Number.MAX_SAFE_INTEGER.
+export const sum_to_n_d = (n: number | string): string => {
+ const input = String(n).trim();
+ if (!/^-?\d+$/.test(input)) {
+ throw new Error(
+ `sum_to_n_d: invalid integer input: ${JSON.stringify(input)}`,
+ );
+ }
+
+ const negative = input.startsWith('-');
+ const abs = negative ? input.slice(1) : input;
+
+ // sum = abs * (abs + 1) / 2
+ const absPlus1 = addBigNumbers(abs, '1');
+ const product = mulBigNumbers(abs, absPlus1);
+ const half = divBy2(product);
+
+ if (half === '0') return '0';
+ return negative ? '-' + half : half;
+};
diff --git a/src/problem1/problem1.md b/src/problem1/problem1.md
new file mode 100644
index 0000000000..6b8ac424ee
--- /dev/null
+++ b/src/problem1/problem1.md
@@ -0,0 +1,31 @@
+# Problem 1: Three ways to sum to n
+
+
+β° Duration: You should not spend more than **2 hours** on this problem.
+*Time estimation is for internship roles, if you are a software professional you should spend significantly less time.*
+
+
+
+# Task
+
+Provide 3 unique implementations of the following function in JavaScript.
+
+**Input**: `n` - any integer
+
+*Assuming this input will always produce a result lesser than `Number.MAX_SAFE_INTEGER`*.
+
+**Output**: `return` - summation to `n`, i.e. `sum_to_n(5) === 1 + 2 + 3 + 4 + 5 === 15`.
+
+```jsx
+var sum_to_n_a = function(n) {
+ // your code here
+};
+
+var sum_to_n_b = function(n) {
+ // your code here
+};
+
+var sum_to_n_c = function(n) {
+ // your code here
+};
+```
\ No newline at end of file
diff --git a/src/problem1/solutions.ts b/src/problem1/solutions.ts
new file mode 100644
index 0000000000..fcb9fefdce
--- /dev/null
+++ b/src/problem1/solutions.ts
@@ -0,0 +1,146 @@
+// Metadata + display text for the four sum_to_n solutions. Rendered in
+// the left-hand panel of the Problem 1 tab when a solution is selected.
+
+export type SolutionId = 'a' | 'b' | 'c' | 'd';
+
+export interface Solution {
+ id: SolutionId;
+ name: string;
+ time: string;
+ space: string;
+ explanation: string;
+ code: string;
+}
+
+export const solutions: Record = {
+ a: {
+ id: 'a',
+ name: 'sum_to_n_a β Iterative Loop',
+ time: 'O(n)',
+ space: 'O(1)',
+ explanation:
+ 'Walks from 1 up to n with a counter, accumulating the running total ' +
+ 'in a single variable. This is the most direct translation of "add ' +
+ 'every number from 1 to n". It performs n additions, so runtime grows ' +
+ 'linearly with n; for n in the millions it is still fast (sub-second) ' +
+ 'but cannot beat the constant-time formula. Negative n is handled by ' +
+ 'recursing on -n and negating the result, so sum_to_n_a(-5) = ' +
+ '-(1 + 2 + 3 + 4 + 5) = -15.',
+ code: `var sum_to_n_a = function (n) {
+ if (n < 0) return -sum_to_n_a(-n);
+ let sum = 0;
+ for (let i = 1; i <= n; i++) sum += i;
+ return sum;
+};`,
+ },
+
+ b: {
+ id: 'b',
+ name: 'sum_to_n_b β Gauss Formula',
+ time: 'O(1)',
+ space: 'O(1)',
+ explanation:
+ 'Uses the closed-form arithmetic-series formula n * (n + 1) / 2. ' +
+ 'Pair 1 + n, 2 + (nβ1), ... β every pair sums to n + 1 and there are ' +
+ 'n / 2 pairs, so the total is n Β· (n + 1) / 2. Constant time and ' +
+ 'constant space regardless of n, so this is the right choice whenever ' +
+ 'the result fits in Number.MAX_SAFE_INTEGER.',
+ code: `var sum_to_n_b = function (n) {
+ if (n < 0) return -sum_to_n_b(-n);
+ return (n * (n + 1)) / 2;
+};`,
+ },
+
+ c: {
+ id: 'c',
+ name: 'sum_to_n_c β Functional (Array.from + reduce)',
+ time: 'O(n)',
+ space: 'O(n)',
+ explanation:
+ 'Builds the array [1, 2, ..., n] with Array.from and folds it into a ' +
+ 'single sum with reduce. Same number of additions as the iterative ' +
+ 'loop, but it also allocates an array of length n, so it uses O(n) ' +
+ 'memory. Included as a "functional style" contrast to the imperative ' +
+ 'loop, not because it is faster. For very large n the allocation ' +
+ 'fails before the sum does.',
+ code: `var sum_to_n_c = function (n) {
+ if (n < 0) return -sum_to_n_c(-n);
+ return Array.from({ length: n }, (_, i) => i + 1)
+ .reduce((a, b) => a + b, 0);
+};`,
+ },
+
+ d: {
+ id: 'd',
+ name: 'sum_to_n_d β Big-Number Formula (decimal strings)',
+ time: 'O(dΒ²) where d = digits of n',
+ space: 'O(d)',
+ explanation:
+ 'Same Gauss identity as sum_to_n_b, but every arithmetic step is ' +
+ 'performed on decimal strings so the result is not bounded by ' +
+ 'Number.MAX_SAFE_INTEGER. Pipeline: (1) parse the input string and ' +
+ 'peel off the sign; (2) addBigNumbers(n, "1") computes n + 1 by ' +
+ 'schoolbook addition right-to-left with a carry; (3) ' +
+ 'mulBigNumbers(n, n + 1) does long multiplication, O(dΒ²); (4) ' +
+ 'divBy2(product) does single-digit long division by 2 β the product ' +
+ 'is always even because n and n+1 are consecutive, so the result is ' +
+ 'exact; (5) reattach the sign. Accepts number | string and always ' +
+ 'returns a string, e.g. sum_to_n_d("123456789012345678901234567890") ' +
+ 'yields a 58-digit answer.',
+ code: `// Accepts number | string. Returns string.
+var sum_to_n_d = function (n) {
+ const input = String(n).trim();
+ if (!/^-?\\d+$/.test(input)) throw new Error('Invalid integer');
+
+ const negative = input.startsWith('-');
+ const abs = negative ? input.slice(1) : input;
+
+ const absPlus1 = addBigNumbers(abs, '1');
+ const product = mulBigNumbers(abs, absPlus1);
+ const half = divBy2(product);
+
+ if (half === '0') return '0';
+ return negative ? '-' + half : half;
+};
+
+// --- string-based helpers (non-negative inputs) ---
+
+function addBigNumbers(a, b) {
+ let i = a.length - 1, j = b.length - 1, carry = 0, result = '';
+ while (i >= 0 || j >= 0 || carry > 0) {
+ let sum = carry;
+ if (i >= 0) sum += a.charCodeAt(i--) - 48;
+ if (j >= 0) sum += b.charCodeAt(j--) - 48;
+ carry = (sum / 10) | 0;
+ result = (sum % 10) + result;
+ }
+ return result;
+}
+
+function mulBigNumbers(a, b) {
+ if (a === '0' || b === '0') return '0';
+ const out = new Array(a.length + b.length).fill(0);
+ for (let i = a.length - 1; i >= 0; i--) {
+ const da = a.charCodeAt(i) - 48;
+ for (let j = b.length - 1; j >= 0; j--) {
+ const db = b.charCodeAt(j) - 48;
+ const pos = i + j + 1;
+ const total = out[pos] + da * db;
+ out[pos] = total % 10;
+ out[pos - 1] += (total / 10) | 0;
+ }
+ }
+ return out.join('').replace(/^0+/, '') || '0';
+}
+
+function divBy2(s) {
+ let result = '', carry = 0;
+ for (let i = 0; i < s.length; i++) {
+ const cur = carry * 10 + (s.charCodeAt(i) - 48);
+ result += ((cur / 2) | 0).toString();
+ carry = cur % 2;
+ }
+ return result.replace(/^0+/, '') || '0';
+}`,
+ },
+};
diff --git a/src/problem1/style.css b/src/problem1/style.css
new file mode 100644
index 0000000000..ebef9b3197
--- /dev/null
+++ b/src/problem1/style.css
@@ -0,0 +1,283 @@
+/* Problem 1 β Sum to N */
+
+.p1.layout-split {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 20px;
+}
+
+@media (max-width: 1080px) {
+ .p1.layout-split {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ---------------- solution nav ---------------- */
+
+.p1-sol-nav {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.p1-sol-btn {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ text-align: left;
+ font-family: inherit;
+ transition: all 0.1s ease;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.p1-sol-btn:hover {
+ border-color: var(--accent);
+ color: var(--text-strong);
+}
+
+.p1-sol-btn.active {
+ background: var(--accent-bg);
+ border-color: var(--accent);
+ color: var(--text-strong);
+}
+
+.p1-sol-btn-id {
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.p1-sol-btn-tag {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.p1-sol-btn.active .p1-sol-btn-tag {
+ color: var(--accent-strong);
+}
+
+/* ---------------- solution detail ---------------- */
+
+.p1-sol-detail h3 {
+ margin: 20px 0 10px;
+ font-size: 17px;
+ font-weight: 600;
+}
+
+.p1-sol-meta {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.meta-pill {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 12px;
+ color: var(--muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.meta-pill strong {
+ color: var(--text-strong);
+ font-weight: 600;
+ margin-left: 4px;
+}
+
+.p1-sol-explanation {
+ font-size: 14px;
+ line-height: 1.65;
+ margin: 0;
+}
+
+.p1-sol-code {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ border-radius: var(--radius-sm);
+ padding: 14px 16px;
+ overflow: auto;
+ font-size: 12.5px;
+ line-height: 1.55;
+ margin: 0;
+ max-height: 520px;
+}
+
+/* ---------------- input + result ---------------- */
+
+.p1-input-row {
+ margin-bottom: 10px;
+}
+
+.p1-input {
+ width: 100%;
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ color: var(--text-strong);
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ font-size: 14px;
+}
+
+.p1-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-bg);
+}
+
+.p1-button-row {
+ display: flex;
+ gap: 8px;
+}
+
+.p1-input-hint {
+ font-size: 11.5px;
+ color: var(--muted);
+ margin: 6px 2px 10px;
+}
+
+.p1-input-hint code {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ padding: 0 4px;
+ border-radius: 3px;
+ font-size: 11px;
+ color: var(--text);
+}
+
+.p1-input-hint strong {
+ color: var(--text-strong);
+ font-weight: 600;
+}
+
+.p1-result-warning {
+ margin-top: 8px;
+ padding: 6px 10px;
+ background: rgba(240, 182, 87, 0.1);
+ border-left: 2px solid var(--warn);
+ border-radius: 0 4px 4px 0;
+ font-size: 11.5px;
+ color: var(--warn);
+}
+
+.p1-result {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ border-radius: var(--radius-sm);
+ padding: 14px 16px;
+ min-height: 64px;
+ font-size: 13px;
+ word-break: break-all;
+}
+
+.p1-result.ok {
+ border-color: var(--pass);
+}
+
+.p1-result.error {
+ border-color: var(--fail);
+}
+
+.p1-result-placeholder {
+ color: var(--muted);
+ font-style: italic;
+}
+
+.p1-result-value {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-strong);
+}
+
+.p1-result.error .p1-result-value {
+ color: var(--fail);
+}
+
+.p1-result-meta {
+ color: var(--muted);
+ font-size: 11.5px;
+ margin-top: 6px;
+}
+
+/* ---------------- tests ---------------- */
+
+.p1-test-summary {
+ font-size: 13px;
+ color: var(--muted);
+ margin-bottom: 8px;
+}
+
+.p1-test-summary.all-pass {
+ color: var(--pass);
+ font-weight: 600;
+}
+
+.p1-test-summary.some-fail {
+ color: var(--fail);
+ font-weight: 600;
+}
+
+.p1-test-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ max-height: 320px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+
+.p1-test-item {
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr);
+ grid-template-rows: auto auto;
+ column-gap: 6px;
+ row-gap: 2px;
+ padding: 8px 12px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ border-bottom: 1px solid var(--border);
+}
+
+.p1-test-item:last-child {
+ border-bottom: none;
+}
+
+.p1-test-item.pass .p1-test-mark {
+ color: var(--pass);
+ font-weight: 700;
+}
+
+.p1-test-item.fail .p1-test-mark {
+ color: var(--fail);
+ font-weight: 700;
+}
+
+.p1-test-mark {
+ grid-row: 1 / span 2;
+ font-size: 14px;
+}
+
+.p1-test-label {
+ color: var(--text-strong);
+ font-weight: 600;
+}
+
+.p1-test-expected {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.p1-test-expected code {
+ background: var(--code-bg);
+ padding: 1px 5px;
+ border-radius: 3px;
+ border: 1px solid var(--code-border);
+ font-size: 11px;
+ color: var(--text);
+}
diff --git a/src/problem1/tests/sumToN.test.ts b/src/problem1/tests/sumToN.test.ts
new file mode 100644
index 0000000000..b1f6ba67d1
--- /dev/null
+++ b/src/problem1/tests/sumToN.test.ts
@@ -0,0 +1,149 @@
+import { describe, it, expect } from 'vitest';
+import {
+ sum_to_n_a,
+ sum_to_n_b,
+ sum_to_n_c,
+ sum_to_n_d,
+} from '../lib/sumToN';
+import {
+ addBigNumbers,
+ mulBigNumbers,
+ divBy2,
+ cmpAbs,
+} from '../lib/bigNumber';
+
+const BASIC_CASES: Array<[number, number]> = [
+ [0, 0],
+ [1, 1],
+ [2, 3],
+ [5, 15],
+ [10, 55],
+ [100, 5050],
+ [1000, 500500],
+ [-1, -1],
+ [-5, -15],
+ [-100, -5050],
+];
+
+describe.each([
+ ['a', sum_to_n_a],
+ ['b', sum_to_n_b],
+ ['c', sum_to_n_c],
+] as const)('sum_to_n_%s', (label, fn) => {
+ for (const [n, expected] of BASIC_CASES) {
+ it(`sum_to_n_${label}(${n}) === ${expected}`, () => {
+ expect(fn(n)).toBe(expected);
+ });
+ }
+});
+
+describe('a / b / c agree on the integer range', () => {
+ for (let n = -50; n <= 200; n++) {
+ it(`agree at n=${n}`, () => {
+ const a = sum_to_n_a(n);
+ const b = sum_to_n_b(n);
+ const c = sum_to_n_c(n);
+ expect(a).toBe(b);
+ expect(b).toBe(c);
+ });
+ }
+});
+
+describe('sum_to_n_d', () => {
+ it('accepts number input', () => {
+ expect(sum_to_n_d(0)).toBe('0');
+ expect(sum_to_n_d(5)).toBe('15');
+ expect(sum_to_n_d(100)).toBe('5050');
+ });
+
+ it('accepts string input', () => {
+ expect(sum_to_n_d('0')).toBe('0');
+ expect(sum_to_n_d('5')).toBe('15');
+ expect(sum_to_n_d('1000000')).toBe('500000500000');
+ });
+
+ it('handles inputs whose result exceeds MAX_SAFE_INTEGER', () => {
+ expect(sum_to_n_d('100000000000')).toBe('5000000000050000000000');
+ });
+
+ it('handles a 30-digit input (verified against BigInt)', () => {
+ const n = '123456789012345678901234567890';
+ const big = BigInt(n);
+ const expected = ((big * (big + 1n)) / 2n).toString();
+ expect(sum_to_n_d(n)).toBe(expected);
+ });
+
+ it('handles negative input', () => {
+ expect(sum_to_n_d(-5)).toBe('-15');
+ expect(sum_to_n_d('-100')).toBe('-5050');
+ expect(sum_to_n_d('-100000000000')).toBe('-5000000000050000000000');
+ });
+
+ it('matches a / b / c on the small integer range', () => {
+ for (let n = -30; n <= 30; n++) {
+ expect(sum_to_n_d(n)).toBe(String(sum_to_n_a(n)));
+ }
+ });
+
+ it('throws on invalid input', () => {
+ expect(() => sum_to_n_d('abc')).toThrow();
+ expect(() => sum_to_n_d('1.5')).toThrow();
+ expect(() => sum_to_n_d('')).toThrow();
+ expect(() => sum_to_n_d('--5')).toThrow();
+ });
+});
+
+describe('bigNumber helpers', () => {
+ describe('addBigNumbers', () => {
+ it('adds positive numbers', () => {
+ expect(addBigNumbers('0', '0')).toBe('0');
+ expect(addBigNumbers('999', '1')).toBe('1000');
+ expect(addBigNumbers('123', '456')).toBe('579');
+ expect(addBigNumbers('99999999999999999999', '1')).toBe(
+ '100000000000000000000',
+ );
+ });
+ it('handles signed addition', () => {
+ expect(addBigNumbers('-5', '3')).toBe('-2');
+ expect(addBigNumbers('5', '-3')).toBe('2');
+ expect(addBigNumbers('-5', '-3')).toBe('-8');
+ expect(addBigNumbers('5', '-5')).toBe('0');
+ expect(addBigNumbers('-100', '50')).toBe('-50');
+ });
+ });
+
+ describe('mulBigNumbers', () => {
+ it('multiplies positive numbers', () => {
+ expect(mulBigNumbers('0', '999')).toBe('0');
+ expect(mulBigNumbers('12', '12')).toBe('144');
+ expect(mulBigNumbers('123', '456')).toBe('56088');
+ expect(
+ mulBigNumbers('100000000000000000000', '100000000000000000000'),
+ ).toBe('10000000000000000000000000000000000000000');
+ });
+ it('handles signed multiplication', () => {
+ expect(mulBigNumbers('-12', '12')).toBe('-144');
+ expect(mulBigNumbers('-12', '-12')).toBe('144');
+ expect(mulBigNumbers('12', '0')).toBe('0');
+ });
+ });
+
+ describe('divBy2', () => {
+ it('floor-divides by 2', () => {
+ expect(divBy2('0')).toBe('0');
+ expect(divBy2('2')).toBe('1');
+ expect(divBy2('1000')).toBe('500');
+ expect(divBy2('1001')).toBe('500');
+ expect(divBy2('10000000000000000000000')).toBe('5000000000000000000000');
+ });
+ });
+
+ describe('cmpAbs', () => {
+ it('compares magnitudes', () => {
+ expect(cmpAbs('5', '5')).toBe(0);
+ expect(cmpAbs('5', '10')).toBe(-1);
+ expect(cmpAbs('10', '5')).toBe(1);
+ expect(cmpAbs('0005', '5')).toBe(0);
+ });
+ });
+});
diff --git a/src/problem1/view.tsx b/src/problem1/view.tsx
new file mode 100644
index 0000000000..5786a4c3c7
--- /dev/null
+++ b/src/problem1/view.tsx
@@ -0,0 +1,336 @@
+import { useMemo, useState } from 'react';
+import {
+ sum_to_n_a,
+ sum_to_n_b,
+ sum_to_n_c,
+ sum_to_n_d,
+} from './lib/sumToN';
+import { solutions, type SolutionId } from './solutions';
+import './style.css';
+
+const fns: Record number | string> = {
+ a: sum_to_n_a as (n: never) => number,
+ b: sum_to_n_b as (n: never) => number,
+ c: sum_to_n_c as (n: never) => number,
+ d: sum_to_n_d as (n: never) => string,
+};
+
+interface ComputeResult {
+ value?: string;
+ time?: number;
+ error?: string;
+ warning?: string;
+}
+
+interface TestRow {
+ pass: boolean;
+ label: string;
+ expected: string;
+ actual: string;
+}
+
+const SMALL_CASES: Array<{ n: number; expected: number }> = [
+ { n: 0, expected: 0 },
+ { n: 1, expected: 1 },
+ { n: 5, expected: 15 },
+ { n: 10, expected: 55 },
+ { n: 100, expected: 5050 },
+ { n: 1000, expected: 500500 },
+ { n: -1, expected: -1 },
+ { n: -5, expected: -15 },
+ { n: -100, expected: -5050 },
+];
+
+const BIG_CASES: Array<{ n: string; expected: string }> = [
+ { n: '1000000', expected: '500000500000' },
+ { n: '100000000000', expected: '5000000000050000000000' },
+ {
+ n: '123456789012345678901234567890',
+ expected:
+ '7620789614188397919306039269735013345025385542661216720495',
+ },
+ { n: '-100000000000', expected: '-5000000000050000000000' },
+];
+
+// Largest |n| for which n*(n+1)/2 still fits in Number.MAX_SAFE_INTEGER.
+// Derived from n*(n+1) <= 2 * MAX_SAFE_INTEGER (= 2^54 - 2):
+// floor((sqrt(1 + 8 * MAX_SAFE_INTEGER) - 1) / 2) === 134_217_727.
+// At n = 134_217_727 the sum is 9_007_199_187_632_128, still inside the
+// safe range. At n = 134_217_728 it overflows.
+const MAX_SAFE_N = 134_217_727;
+
+// Per-solution practical input ceiling. a/b are bounded only by the
+// overflow constraint above. c additionally allocates an array of length
+// |n|, so we cap it well below the engine's array-length limit to avoid
+// "Invalid array length" / out-of-memory. a is also slow at the upper
+// end (~1.5s loop), so we mark long runs with a warning in the result.
+const PER_SOLUTION_MAX: Record<'a' | 'b' | 'c', number> = {
+ a: MAX_SAFE_N,
+ b: MAX_SAFE_N,
+ c: 1_000_000,
+};
+
+const SLOW_THRESHOLD = 5_000_000;
+
+export function Problem1View() {
+ const [selected, setSelected] = useState('a');
+ const [rawInput, setRawInput] = useState('100');
+ const [result, setResult] = useState(null);
+ const [tests, setTests] = useState(null);
+
+ const sol = solutions[selected];
+
+ const onSelect = (id: SolutionId) => {
+ setSelected(id);
+ setResult(null);
+ setTests(null);
+ };
+
+ const onCompute = () => {
+ const raw = rawInput.trim();
+ if (!raw) {
+ setResult({ error: 'Please enter a value for n.' });
+ return;
+ }
+
+ try {
+ let arg: number | string;
+ let slowWarning: string | undefined;
+ if (selected === 'd') {
+ arg = raw;
+ } else {
+ const num = Number(raw);
+ if (!Number.isFinite(num) || !Number.isInteger(num)) {
+ setResult({
+ error: `n must be an integer for sum_to_n_${selected}. Switch to sum_to_n_d for arbitrarily large input.`,
+ });
+ return;
+ }
+ const limit = PER_SOLUTION_MAX[selected];
+ if (Math.abs(num) > limit) {
+ const reason =
+ selected === 'c'
+ ? `sum_to_n_c allocates an array of length |n|, so the practical ceiling is ${limit.toLocaleString()}.`
+ : `Result would exceed Number.MAX_SAFE_INTEGER. The largest |n| keeping nΒ·(n+1)/2 inside the safe range is ${limit.toLocaleString()}.`;
+ setResult({
+ error: `${reason} Switch to sum_to_n_d for arbitrarily large input.`,
+ });
+ return;
+ }
+ if (selected === 'a' && Math.abs(num) > SLOW_THRESHOLD) {
+ slowWarning = `Heads up: sum_to_n_a loops |n| = ${Math.abs(num).toLocaleString()} times β the UI may freeze briefly.`;
+ }
+ arg = num;
+ }
+
+ const t0 = performance.now();
+ const value = (fns[selected] as (n: number | string) => number | string)(
+ arg,
+ );
+ const t1 = performance.now();
+ setResult({
+ value: String(value),
+ time: t1 - t0,
+ warning: slowWarning,
+ });
+ } catch (err) {
+ setResult({ error: err instanceof Error ? err.message : String(err) });
+ }
+ };
+
+ const onRunTests = () => {
+ const fn = fns[selected] as (n: number | string) => number | string;
+ const cases =
+ selected === 'd'
+ ? [
+ ...SMALL_CASES.map(({ n, expected }) => ({
+ n: String(n),
+ expected: String(expected),
+ })),
+ ...BIG_CASES,
+ ]
+ : SMALL_CASES.map(({ n, expected }) => ({
+ n: n as number | string,
+ expected: expected as number | string,
+ }));
+
+ const rows: TestRow[] = cases.map(({ n, expected }) => {
+ try {
+ const actual = fn(n as never);
+ return {
+ pass: actual === expected,
+ label: `sum_to_n_${selected}(${n})`,
+ expected: String(expected),
+ actual: String(actual),
+ };
+ } catch (err) {
+ return {
+ pass: false,
+ label: `sum_to_n_${selected}(${n})`,
+ expected: String(expected),
+ actual: `threw: ${err instanceof Error ? err.message : String(err)}`,
+ };
+ }
+ });
+
+ setTests(rows);
+ };
+
+ const testSummary = useMemo(() => {
+ if (!tests) return null;
+ const passed = tests.filter((r) => r.pass).length;
+ return { passed, total: tests.length };
+ }, [tests]);
+
+ return (
+
+ {/* ---------------- LEFT: solution picker + detail ---------------- */}
+
+ Solutions
+
+ {(Object.keys(solutions) as SolutionId[]).map((id) => (
+ onSelect(id)}
+ >
+ sum_to_n_{id}
+
+ {solutions[id].time}
+
+
+ ))}
+
+
+
+ {sol.name}
+
+
+ Time {sol.time}
+
+
+ Space {sol.space}
+
+
+
+ Explanation
+ {sol.explanation}
+
+ Code
+
+ {sol.code}
+
+
+
+
+ {/* ---------------- RIGHT: compute + tests ---------------- */}
+
+ Try it
+
+ setRawInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') onCompute();
+ }}
+ placeholder="Enter n (e.g. 100, -50, or 123456789012345678901234567890)"
+ autoComplete="off"
+ className="p1-input mono"
+ />
+
+
+ {selected === 'd' ? (
+ <>
+ sum_to_n_d accepts arbitrarily long decimal
+ strings. No upper bound.
+ >
+ ) : (
+ <>
+ Max |n| for sum_to_n_{selected}:{' '}
+
+ {PER_SOLUTION_MAX[selected].toLocaleString()}
+
+ {selected === 'c'
+ ? ' (bounded by array memory).'
+ : ' (keeps result inside Number.MAX_SAFE_INTEGER).'}
+ >
+ )}
+
+
+
+ Compute
+
+
+ Run test suite
+
+
+
+ Result
+
+ {!result && (
+
No computation yet.
+ )}
+ {result?.error && (
+ <>
+
Error
+
{result.error}
+ >
+ )}
+ {result?.value !== undefined && !result.error && (
+ <>
+
{result.value}
+
+ computed in {result.time?.toFixed(3)} ms
+
+ {result.warning && (
+
{result.warning}
+ )}
+ >
+ )}
+
+
+ Tests
+ {!tests && (
+
+ Click Run test suite to execute the in-browser test cases.
+
+ )}
+ {testSummary && (
+
+ {testSummary.passed} / {testSummary.total} passed
+
+ )}
+ {tests && (
+
+ {tests.map((r, i) => (
+
+ {r.pass ? 'β' : 'β'}
+ {r.label}
+
+ expected {r.expected}
+ {!r.pass && (
+ <>
+ , got {r.actual}
+ >
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/problem2/api.ts b/src/problem2/api.ts
new file mode 100644
index 0000000000..668b089db4
--- /dev/null
+++ b/src/problem2/api.ts
@@ -0,0 +1,40 @@
+import type { PriceEntry } from './types';
+
+const PRICES_URL = 'https://interview.switcheo.com/prices.json';
+
+// Fetch the raw price feed. The endpoint returns an array of
+// `{ currency, date, price }` entries. The same currency can appear
+// multiple times with different timestamps, so the caller is responsible
+// for deduplicating (see `dedupeLatest`).
+export async function fetchPrices(): Promise {
+ const res = await fetch(PRICES_URL);
+ if (!res.ok) {
+ throw new Error(
+ `Failed to fetch prices: ${res.status} ${res.statusText}`,
+ );
+ }
+ const data = (await res.json()) as PriceEntry[];
+ if (!Array.isArray(data)) {
+ throw new Error('Unexpected price feed shape: expected an array.');
+ }
+ return data;
+}
+
+// Keep only the most recent price per currency. Entries without a valid
+// numeric price are dropped (per the spec: "tokens that do not [have a
+// price] can be omitted").
+export function dedupeLatest(entries: PriceEntry[]): PriceEntry[] {
+ const latest = new Map();
+ for (const entry of entries) {
+ if (!entry || typeof entry.price !== 'number' || !isFinite(entry.price)) {
+ continue;
+ }
+ const prev = latest.get(entry.currency);
+ if (!prev || new Date(entry.date) > new Date(prev.date)) {
+ latest.set(entry.currency, entry);
+ }
+ }
+ return [...latest.values()].sort((a, b) =>
+ a.currency.localeCompare(b.currency),
+ );
+}
diff --git a/src/problem2/balances.ts b/src/problem2/balances.ts
new file mode 100644
index 0000000000..89fc2370f5
--- /dev/null
+++ b/src/problem2/balances.ts
@@ -0,0 +1,62 @@
+// Mock wallet balance store.
+//
+// Initial balances come from `data/balances.json` so the demo starts in a
+// realistic state. Tokens that aren't in the JSON fall back to a
+// deterministic hash-derived balance, so every symbol in the live price
+// feed still has something to spend.
+//
+// During the session the balances are mutated by `applySwap` (called from
+// the swap form's submit handler). Reloading the page resets to the JSON
+// snapshot β by design, since we deliberately don't touch localStorage to
+// keep the demo reproducible.
+
+import seedData from './data/balances.json';
+
+const seed = seedData as Record;
+
+const hashSymbol = (symbol: string): number => {
+ let h = 5381;
+ for (let i = 0; i < symbol.length; i++) {
+ h = ((h << 5) + h + symbol.charCodeAt(i)) | 0;
+ }
+ return Math.abs(h);
+};
+
+// Fallback for symbols not listed in balances.json: spread across roughly
+// four orders of magnitude so the UI shows a believable mix.
+const fallbackBalance = (symbol: string): number => {
+ const h = hashSymbol(symbol);
+ const bucket = h % 4;
+ const mantissa = 1 + ((h >> 4) % 9000) / 1000;
+ const scales = [0.1, 1, 100, 10_000];
+ return Number((mantissa * scales[bucket]).toFixed(4));
+};
+
+export const initialBalance = (symbol: string): number =>
+ Object.prototype.hasOwnProperty.call(seed, symbol)
+ ? seed[symbol]
+ : fallbackBalance(symbol);
+
+// Build the initial balance map for the given set of symbols.
+export const loadInitialBalances = (
+ symbols: string[],
+): Record => {
+ const out: Record = {};
+ for (const s of symbols) out[s] = initialBalance(s);
+ return out;
+};
+
+// Pure helper used by the view's swap handler: deduct `amountIn` from the
+// FROM token and credit `amountOut` to the TO token. Returns a new map
+// (immutable update) so React state updates trigger re-render.
+export const applySwap = (
+ balances: Record,
+ fromSymbol: string,
+ amountIn: number,
+ toSymbol: string,
+ amountOut: number,
+): Record => ({
+ ...balances,
+ [fromSymbol]: Math.max(0, (balances[fromSymbol] ?? 0) - amountIn),
+ [toSymbol]: (balances[toSymbol] ?? 0) + amountOut,
+});
diff --git a/src/problem2/components/TokenIcon.tsx b/src/problem2/components/TokenIcon.tsx
new file mode 100644
index 0000000000..c2697a2132
--- /dev/null
+++ b/src/problem2/components/TokenIcon.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import { iconUrl } from '../tokenIcon';
+
+interface Props {
+ symbol: string;
+ size?: number;
+}
+
+// Renders the Switcheo SVG icon for a token, falling back to a coloured
+// letter avatar if the network request fails (not every symbol in the
+// price feed has an icon in the repo).
+export function TokenIcon({ symbol, size = 28 }: Props) {
+ const [errored, setErrored] = useState(false);
+
+ if (errored) {
+ const hue = symbolHue(symbol);
+ return (
+
+ {symbol.slice(0, 2).toUpperCase()}
+
+ );
+ }
+
+ return (
+ setErrored(true)}
+ />
+ );
+}
+
+const symbolHue = (symbol: string): number => {
+ let h = 0;
+ for (let i = 0; i < symbol.length; i++) {
+ h = (h * 31 + symbol.charCodeAt(i)) | 0;
+ }
+ return Math.abs(h) % 360;
+};
diff --git a/src/problem2/components/TokenSelect.tsx b/src/problem2/components/TokenSelect.tsx
new file mode 100644
index 0000000000..f25afd27c3
--- /dev/null
+++ b/src/problem2/components/TokenSelect.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { Token } from '../types';
+import { TokenIcon } from './TokenIcon';
+import { formatBalance, formatPrice } from '../format';
+
+interface Props {
+ tokens: Token[];
+ value: Token | null;
+ onChange: (token: Token) => void;
+ excludeSymbol?: string | null;
+ placeholder?: string;
+}
+
+// Searchable token dropdown. Trigger shows the selected token (icon +
+// symbol); opening the panel reveals a search box and a scrollable list.
+// The list is filtered case-insensitively against the symbol, and any
+// token matching `excludeSymbol` is greyed out and unselectable so the
+// user cannot pick the same token on both sides of the swap.
+export function TokenSelect({
+ tokens,
+ value,
+ onChange,
+ excludeSymbol = null,
+ placeholder = 'Select a token',
+}: Props) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const containerRef = useRef(null);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ setQuery('');
+ return;
+ }
+ const onDocClick = (e: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node)
+ ) {
+ setOpen(false);
+ }
+ };
+ const onEsc = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('mousedown', onDocClick);
+ document.addEventListener('keydown', onEsc);
+ // Focus the search input after the dropdown paints.
+ queueMicrotask(() => inputRef.current?.focus());
+ return () => {
+ document.removeEventListener('mousedown', onDocClick);
+ document.removeEventListener('keydown', onEsc);
+ };
+ }, [open]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return tokens;
+ return tokens.filter((t) => t.symbol.toLowerCase().includes(q));
+ }, [tokens, query]);
+
+ return (
+
+
setOpen((v) => !v)}
+ >
+ {value ? (
+ <>
+
+ {value.symbol}
+ >
+ ) : (
+ {placeholder}
+ )}
+
+ βΎ
+
+
+
+ {open && (
+
+
+ setQuery(e.target.value)}
+ placeholder="Search tokens..."
+ autoComplete="off"
+ />
+
+
+ {filtered.length === 0 && (
+ No tokens match "{query}"
+ )}
+ {filtered.map((t) => {
+ const disabled = t.symbol === excludeSymbol;
+ const selected = value?.symbol === t.symbol;
+ return (
+
+ {
+ if (disabled) return;
+ onChange(t);
+ setOpen(false);
+ }}
+ disabled={disabled}
+ title={
+ disabled
+ ? 'Already selected on the other side'
+ : undefined
+ }
+ >
+
+
+ {t.symbol}
+
+ Balance: {formatBalance(t.balance)}
+
+
+
+ {formatPrice(t.price)}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/problem2/data/balances.json b/src/problem2/data/balances.json
new file mode 100644
index 0000000000..f8567ee8e6
--- /dev/null
+++ b/src/problem2/data/balances.json
@@ -0,0 +1,30 @@
+{
+ "ETH": 2.5,
+ "WBTC": 0.05,
+ "USDC": 1500.0,
+ "USD": 1200.0,
+ "USDT": 950.0,
+ "ATOM": 45.2,
+ "OSMO": 1280.5,
+ "ARB": 530.0,
+ "ZIL": 12500.0,
+ "NEO": 42.18,
+ "GAS": 60.0,
+ "BNB": 3.7,
+ "SWTH": 100000.0,
+ "BLUR": 5000.0,
+ "STRD": 320.0,
+ "STEVMOS": 180.0,
+ "STOSMO": 410.0,
+ "STATOM": 90.0,
+ "STLUNA": 75.0,
+ "LUNA": 120.0,
+ "EVMOS": 250.0,
+ "AKT": 320.0,
+ "AXLUSDC": 850.0,
+ "KUJI": 540.0,
+ "USC": 700.0,
+ "rATOM": 60.0,
+ "wstETH": 1.2,
+ "RATOM": 60.0
+}
diff --git a/src/problem2/format.ts b/src/problem2/format.ts
new file mode 100644
index 0000000000..8eb7cfed28
--- /dev/null
+++ b/src/problem2/format.ts
@@ -0,0 +1,26 @@
+// Small display helpers for numbers shown in the swap form.
+
+const COMPACT_THRESHOLD = 1_000_000;
+
+export const formatBalance = (n: number): string => {
+ if (!isFinite(n)) return 'β';
+ if (n === 0) return '0';
+ if (n >= COMPACT_THRESHOLD) {
+ return n.toLocaleString(undefined, {
+ notation: 'compact',
+ maximumFractionDigits: 2,
+ });
+ }
+ if (n < 0.0001) return n.toExponential(2);
+ if (n < 1) return n.toPrecision(4);
+ return n.toLocaleString(undefined, { maximumFractionDigits: 4 });
+};
+
+export const formatPrice = (n: number): string => {
+ if (!isFinite(n) || n === 0) return '$0';
+ if (n < 0.0001) return `$${n.toExponential(2)}`;
+ if (n < 1) return `$${n.toPrecision(4)}`;
+ return `$${n.toLocaleString(undefined, { maximumFractionDigits: 4 })}`;
+};
+
+export const formatPct = (n: number): string => `${n.toFixed(2)}%`;
diff --git a/src/problem2/index.html b/src/problem2/index.html
deleted file mode 100644
index 4058a68bff..0000000000
--- a/src/problem2/index.html
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
- Fancy Form
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/problem2/problem2.md b/src/problem2/problem2.md
new file mode 100644
index 0000000000..df4fba8516
--- /dev/null
+++ b/src/problem2/problem2.md
@@ -0,0 +1,31 @@
+# Problem 2: Fancy Form
+
+
+β° Duration: You should not spend more than **16 hours** on this problem.
+*Time estimation is for internship roles, if you are a software professional you should spend significantly less time.*
+
+
+
+# Task
+
+Create a currency swap form based on the template provided in the folder. A user would use this form to swap assets from one currency to another.
+
+*You may use any third party plugin, library, and/or framework for this problem.*
+
+1. You may add input validation/error messages to make the form interactive.
+2. Your submission will be rated on its usage intuitiveness and visual attractiveness.
+3. Show us your frontend development and design skills, feel free to totally disregard the provided files for this problem.
+4. You may use this [repo](https://github.com/Switcheo/token-icons/tree/main/tokens) for token images, e.g. [SVG image](https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens/SWTH.svg).
+5. You may use this [URL](https://interview.switcheo.com/prices.json) for token price information and to compute exchange rates (not every token has a price, those that do not can be omitted).
+
+
+β¨ Bonus: extra points if you use [Vite](https://vite.dev/) for this task!
+
+
+
+Please submit your solution using the files provided in the skeletal repo, including any additional files your solution may use.
+
+
+π‘ Hint: feel free to simulate or mock interactions with a backend service, e.g. implement a loading indicator with a timeout delay for the submit button is good enough.
+
+
\ No newline at end of file
diff --git a/src/problem2/script.js b/src/problem2/script.js
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/problem2/style.css b/src/problem2/style.css
index 915af91c72..039614705c 100644
--- a/src/problem2/style.css
+++ b/src/problem2/style.css
@@ -1,8 +1,459 @@
-body {
+/* Problem 2 β Fancy Currency Swap */
+
+.p2-wrap {
display: flex;
- flex-direction: row;
+ justify-content: center;
+ padding: 16px 0 48px;
+}
+
+.p2-card {
+ width: 100%;
+ max-width: 480px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ padding: 22px;
+ box-shadow: var(--shadow-2);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.p2-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.p2-title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-strong);
+}
+
+/* ---------------- slippage ---------------- */
+
+.p2-slip {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: var(--panel-2);
+ padding: 4px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+}
+
+.p2-slip-label {
+ font-size: 11px;
+ color: var(--muted);
+ padding: 0 6px;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+}
+
+.p2-slip-btn {
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 5px 9px;
+ border-radius: 5px;
+ cursor: pointer;
+}
+
+.p2-slip-btn:hover {
+ color: var(--text);
+}
+
+.p2-slip-btn.active {
+ background: var(--accent-bg);
+ color: var(--text-strong);
+}
+
+.p2-slip-input {
+ width: 60px;
+ background: var(--code-bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 6px;
+ font-size: 12px;
+ text-align: right;
+}
+
+.p2-slip-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.p2-slip-input::-webkit-outer-spin-button,
+.p2-slip-input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+/* ---------------- leg (from / to) ---------------- */
+
+.p2-leg {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ transition: border-color 0.1s ease;
+}
+
+.p2-leg:focus-within {
+ border-color: var(--accent);
+}
+
+.p2-leg-head {
+ display: flex;
+ justify-content: space-between;
align-items: center;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.p2-balance {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.p2-max {
+ background: var(--accent-bg);
+ color: var(--accent-strong);
+ border: none;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 700;
+ cursor: pointer;
+ font-family: inherit;
+}
+
+.p2-max:hover {
+ background: var(--accent);
+ color: #0a0f1a;
+}
+
+.p2-max:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.p2-leg-body {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.p2-amount {
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: none;
+ color: var(--text-strong);
+ font-size: 26px;
+ font-weight: 500;
+ padding: 4px 0;
+ letter-spacing: -0.02em;
+}
+
+.p2-amount:focus {
+ outline: none;
+}
+
+.p2-amount.readonly {
+ cursor: default;
+}
+
+.p2-amount::placeholder {
+ color: var(--muted);
+ opacity: 0.4;
+}
+
+.p2-leg-foot {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+/* ---------------- flip button ---------------- */
+
+.p2-flip-row {
+ display: flex;
justify-content: center;
- min-width: 360px;
- font-family: Arial, Helvetica, sans-serif;
+ margin: -10px 0;
+ position: relative;
+ z-index: 2;
+}
+
+.p2-flip {
+ background: var(--panel);
+ border: 3px solid var(--bg);
+ color: var(--text);
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s ease;
+}
+
+.p2-flip:hover:not(:disabled) {
+ background: var(--accent-bg);
+ color: var(--accent-strong);
+ transform: rotate(180deg);
+}
+
+.p2-flip:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ---------------- summary ---------------- */
+
+.p2-summary {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 12px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.p2-summary-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.p2-summary-value {
+ color: var(--text);
+}
+
+/* ---------------- validation ---------------- */
+
+.p2-validation {
+ color: var(--warn);
+ font-size: 12.5px;
+ padding: 8px 12px;
+ background: rgba(240, 182, 87, 0.08);
+ border: 1px solid rgba(240, 182, 87, 0.3);
+ border-radius: 8px;
+}
+
+/* ---------------- submit ---------------- */
+
+.p2-submit {
+ width: 100%;
+ padding: 14px;
+ font-size: 15px;
+ border-radius: 10px;
+ margin-top: 4px;
+}
+
+/* ---------------- token select ---------------- */
+
+.ts {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.ts-trigger {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ color: var(--text-strong);
+ padding: 6px 10px 6px 8px;
+ border-radius: 999px;
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+ transition: all 0.1s ease;
+}
+
+.ts-trigger:hover {
+ border-color: var(--accent);
+}
+
+.ts-trigger.placeholder {
+ color: var(--muted);
+ font-weight: 500;
+}
+
+.ts-trigger-symbol {
+ font-size: 14px;
+}
+
+.ts-trigger-caret {
+ font-size: 9px;
+ color: var(--muted);
+ margin-left: 2px;
+}
+
+.ts-panel {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ width: 320px;
+ background: var(--panel);
+ border: 1px solid var(--border-strong);
+ border-radius: 10px;
+ box-shadow: var(--shadow-2);
+ z-index: 10;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ max-height: 380px;
+}
+
+.ts-search-row {
+ padding: 10px;
+ border-bottom: 1px solid var(--border);
+}
+
+.ts-search {
+ width: 100%;
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 8px 10px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-family: inherit;
+}
+
+.ts-search:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.ts-list {
+ list-style: none;
+ padding: 6px;
+ margin: 0;
+ overflow-y: auto;
+}
+
+.ts-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 10px;
+ background: transparent;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ color: var(--text);
+ font: inherit;
+ text-align: left;
+}
+
+.ts-option:hover:not(.disabled) {
+ background: var(--panel-2);
+}
+
+.ts-option.selected {
+ background: var(--accent-bg);
+}
+
+.ts-option.disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.ts-option-main {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+}
+
+.ts-option-symbol {
+ font-weight: 600;
+ color: var(--text-strong);
+ font-size: 13px;
+}
+
+.ts-option-balance {
+ font-size: 11px;
+ color: var(--muted);
+ margin-top: 1px;
+}
+
+.ts-option-price {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.ts-empty {
+ padding: 16px;
+ text-align: center;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+/* ---------------- token icon ---------------- */
+
+.token-icon {
+ border-radius: 50%;
+ background: var(--panel-2);
+}
+
+.token-fallback {
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ text-transform: uppercase;
+ flex-shrink: 0;
+}
+
+/* ---------------- load / error states ---------------- */
+
+.p2-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px;
+ gap: 12px;
+ color: var(--muted);
+}
+
+.p2-state .spinner {
+ color: var(--accent);
+}
+
+.p2-state.error code {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ padding: 8px 12px;
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--fail);
+ max-width: 600px;
+ word-break: break-all;
}
diff --git a/src/problem2/tokenIcon.ts b/src/problem2/tokenIcon.ts
new file mode 100644
index 0000000000..276b5abff8
--- /dev/null
+++ b/src/problem2/tokenIcon.ts
@@ -0,0 +1,9 @@
+// Switcheo token icon SVGs are served from the `token-icons` repo at
+// raw.githubusercontent.com. Not every symbol in the price feed has an
+// icon β `IconImg` in view.tsx swaps to a fallback letter avatar on error.
+
+const ICON_BASE =
+ 'https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens';
+
+export const iconUrl = (symbol: string): string =>
+ `${ICON_BASE}/${symbol}.svg`;
diff --git a/src/problem2/types.ts b/src/problem2/types.ts
new file mode 100644
index 0000000000..6d2b4c7fad
--- /dev/null
+++ b/src/problem2/types.ts
@@ -0,0 +1,23 @@
+// Domain types for the currency swap form.
+
+export interface PriceEntry {
+ currency: string;
+ date: string;
+ price: number;
+}
+
+export interface Token {
+ symbol: string;
+ price: number;
+ balance: number;
+ iconUrl: string;
+}
+
+export interface SwapQuote {
+ amountIn: number;
+ amountOut: number;
+ rate: number;
+ feePct: number;
+ feeAmount: number;
+ minReceived: number;
+}
diff --git a/src/problem2/view.tsx b/src/problem2/view.tsx
new file mode 100644
index 0000000000..5b9bbe4d21
--- /dev/null
+++ b/src/problem2/view.tsx
@@ -0,0 +1,419 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { dedupeLatest, fetchPrices } from './api';
+import { applySwap, loadInitialBalances } from './balances';
+import { iconUrl } from './tokenIcon';
+import { TokenSelect } from './components/TokenSelect';
+import { TokenIcon } from './components/TokenIcon';
+import { formatBalance, formatPct, formatPrice } from './format';
+import type { SwapQuote, Token } from './types';
+import './style.css';
+
+const FEE_PCT = 0.003; // 0.3% protocol fee (mock)
+const SLIPPAGE_PRESETS = [0.1, 0.5, 1.0];
+const DEFAULT_SLIPPAGE = 0.5;
+const SUBMIT_DELAY_MS = 1500;
+
+interface TokenMeta {
+ symbol: string;
+ price: number;
+ iconUrl: string;
+}
+
+type LoadState =
+ | { kind: 'loading' }
+ | { kind: 'error'; message: string }
+ | { kind: 'ready'; meta: TokenMeta[] };
+
+type SubmitState =
+ | { kind: 'idle' }
+ | { kind: 'submitting' }
+ | { kind: 'done'; ok: true; message: string }
+ | { kind: 'done'; ok: false; message: string };
+
+export function Problem2View() {
+ const [load, setLoad] = useState({ kind: 'loading' });
+ const [balances, setBalances] = useState>({});
+ const [fromSymbol, setFromSymbol] = useState(null);
+ const [toSymbol, setToSymbol] = useState(null);
+ const [amountRaw, setAmountRaw] = useState('');
+ const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE);
+ const [submit, setSubmit] = useState({ kind: 'idle' });
+
+ const loadPrices = useCallback(() => {
+ setLoad({ kind: 'loading' });
+ fetchPrices()
+ .then((entries) => {
+ const deduped = dedupeLatest(entries);
+ const meta: TokenMeta[] = deduped.map((e) => ({
+ symbol: e.currency,
+ price: e.price,
+ iconUrl: iconUrl(e.currency),
+ }));
+ setLoad({ kind: 'ready', meta });
+ setBalances(loadInitialBalances(meta.map((m) => m.symbol)));
+ // Pick a sensible default pair so the form has something to show.
+ const eth = meta.find((m) => m.symbol === 'ETH');
+ const usdc = meta.find((m) => m.symbol === 'USDC');
+ setFromSymbol((eth ?? meta[0])?.symbol ?? null);
+ setToSymbol((usdc ?? meta[1] ?? meta[0])?.symbol ?? null);
+ })
+ .catch((err: unknown) => {
+ setLoad({
+ kind: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }, []);
+
+ useEffect(() => {
+ loadPrices();
+ }, [loadPrices]);
+
+ // Auto-dismiss the submit toast after a couple of seconds.
+ useEffect(() => {
+ if (submit.kind !== 'done') return;
+ const t = setTimeout(() => setSubmit({ kind: 'idle' }), 3000);
+ return () => clearTimeout(t);
+ }, [submit]);
+
+ // Derive the user-facing token list by joining static price metadata
+ // with the live (mutable) balance map.
+ const tokens: Token[] = useMemo(() => {
+ if (load.kind !== 'ready') return [];
+ return load.meta.map((m) => ({
+ symbol: m.symbol,
+ price: m.price,
+ iconUrl: m.iconUrl,
+ balance: balances[m.symbol] ?? 0,
+ }));
+ }, [load, balances]);
+
+ const fromToken = tokens.find((t) => t.symbol === fromSymbol) ?? null;
+ const toToken = tokens.find((t) => t.symbol === toSymbol) ?? null;
+
+ const amount = parseFloat(amountRaw);
+ const amountValid =
+ amountRaw !== '' && Number.isFinite(amount) && amount > 0;
+
+ const quote: SwapQuote | null = useMemo(() => {
+ if (!fromToken || !toToken || !amountValid) return null;
+ const rate = fromToken.price / toToken.price;
+ const grossOut = amount * rate;
+ const feeAmount = grossOut * FEE_PCT;
+ const amountOut = grossOut - feeAmount;
+ const minReceived = amountOut * (1 - slippage / 100);
+ return {
+ amountIn: amount,
+ amountOut,
+ rate,
+ feePct: FEE_PCT * 100,
+ feeAmount,
+ minReceived,
+ };
+ }, [fromToken, toToken, amount, amountValid, slippage]);
+
+ const validation = useMemo(() => {
+ if (!fromToken || !toToken) return 'Select both tokens to continue.';
+ if (fromToken.symbol === toToken.symbol)
+ return 'From and to must be different tokens.';
+ if (amountRaw === '') return null;
+ if (!Number.isFinite(amount)) return 'Amount must be a valid number.';
+ if (amount <= 0) return 'Amount must be greater than zero.';
+ if (amount > fromToken.balance)
+ return `Insufficient balance. Max: ${formatBalance(fromToken.balance)} ${fromToken.symbol}.`;
+ return null;
+ }, [fromToken, toToken, amount, amountRaw]);
+
+ const canSubmit =
+ !!fromToken &&
+ !!toToken &&
+ fromToken.symbol !== toToken.symbol &&
+ amountValid &&
+ amount <= fromToken.balance &&
+ submit.kind !== 'submitting';
+
+ const onFlip = () => {
+ if (!fromToken || !toToken) return;
+ setFromSymbol(toToken.symbol);
+ setToSymbol(fromToken.symbol);
+ setAmountRaw('');
+ };
+
+ const onMax = () => {
+ if (!fromToken) return;
+ setAmountRaw(String(fromToken.balance));
+ };
+
+ const onSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!canSubmit || !fromToken || !toToken || !quote) return;
+ setSubmit({ kind: 'submitting' });
+ // Mock backend: pretend to send the swap, then update the local
+ // balance map so the FROM token is debited and the TO token credited.
+ setTimeout(() => {
+ setBalances((prev) =>
+ applySwap(
+ prev,
+ fromToken.symbol,
+ quote.amountIn,
+ toToken.symbol,
+ quote.amountOut,
+ ),
+ );
+ setSubmit({
+ kind: 'done',
+ ok: true,
+ message: `Swapped ${formatBalance(quote.amountIn)} ${fromToken.symbol} β ${formatBalance(quote.amountOut)} ${toToken.symbol}.`,
+ });
+ setAmountRaw('');
+ }, SUBMIT_DELAY_MS);
+ };
+
+ if (load.kind === 'loading') {
+ return (
+
+
+
Loading Switcheo pricesβ¦
+
+ );
+ }
+
+ if (load.kind === 'error') {
+ return (
+
+
Failed to load price feed.
+
{load.message}
+
+ Try again
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* ---------------- Toast ---------------- */}
+ {submit.kind === 'done' && (
+
+ {submit.ok ? (
+ fromToken && toToken ? (
+ <>
+
+ {submit.message}
+ >
+ ) : (
+ {submit.message}
+ )
+ ) : (
+ {submit.message}
+ )}
+
+ )}
+
+ );
+}
+
+// --------------- subcomponents ---------------
+
+function SlippageControl({
+ value,
+ onChange,
+}: {
+ value: number;
+ onChange: (v: number) => void;
+}) {
+ const isPreset = SLIPPAGE_PRESETS.includes(value);
+ return (
+
+ Slippage
+ {SLIPPAGE_PRESETS.map((p) => (
+ onChange(p)}
+ >
+ {p}%
+
+ ))}
+ {
+ const v = parseFloat(e.target.value);
+ if (Number.isFinite(v) && v > 0) onChange(v);
+ }}
+ />
+
+ );
+}
+
+function SummaryRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/src/problem3/.keep b/src/problem3/.keep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/problem3/Refactored.tsx b/src/problem3/Refactored.tsx
new file mode 100644
index 0000000000..fabfe564f6
--- /dev/null
+++ b/src/problem3/Refactored.tsx
@@ -0,0 +1,73 @@
+import { useMemo } from 'react';
+import {
+ type BoxProps,
+ type Blockchain,
+ type FormattedWalletBalance,
+ type WalletBalance,
+ WalletRow,
+ classes,
+ usePrices,
+ useWalletBalances,
+} from './mocks';
+
+// --- pure, dependency-free helpers hoisted out of the component ---
+
+const PRIORITY: Record = {
+ Osmosis: 100,
+ Ethereum: 50,
+ Arbitrum: 30,
+ Zilliqa: 20,
+ Neo: 20,
+};
+
+const getPriority = (blockchain: Blockchain): number =>
+ PRIORITY[blockchain] ?? -99;
+
+const AMOUNT_DECIMALS = 4;
+
+// `Props` adds nothing of its own, so use `BoxProps` directly. `children`
+// was destructured and never used in the original; leaving it on `...rest`
+// keeps the component a transparent wrapper if a parent passes it.
+export function WalletPage(props: BoxProps) {
+ const balances = useWalletBalances();
+ const prices = usePrices();
+
+ // Single memo: filter + sort + format + compute usdValue. Schwartzian
+ // transform means getPriority/price lookup runs once per row, not once
+ // per sort comparison. Depends on both `balances` and `prices` because
+ // both feed the final output.
+ const rows = useMemo(() => {
+ return balances
+ .filter(
+ (b: WalletBalance) =>
+ b.amount > 0 && getPriority(b.blockchain) > -99,
+ )
+ .map((b) => ({
+ balance: b,
+ priority: getPriority(b.blockchain),
+ price: prices[b.currency] ?? 0,
+ }))
+ .sort((a, b) => b.priority - a.priority)
+ .map(({ balance, price }) => ({
+ ...balance,
+ formatted: balance.amount.toFixed(AMOUNT_DECIMALS),
+ usdValue: price * balance.amount,
+ }));
+ }, [balances, prices]);
+
+ return (
+
+ {rows.map((row) => (
+
+ ))}
+
+ );
+}
diff --git a/src/problem3/analysis.ts b/src/problem3/analysis.ts
new file mode 100644
index 0000000000..07465b6e95
--- /dev/null
+++ b/src/problem3/analysis.ts
@@ -0,0 +1,200 @@
+// Catalogue of issues in the original WalletPage. Each entry is rendered
+// as a card in the Problem 3 tab.
+
+export type Severity = 'bug' | 'perf' | 'anti-pattern' | 'style';
+
+export interface Issue {
+ id: number;
+ title: string;
+ severity: Severity;
+ body: string;
+}
+
+export const issues: Issue[] = [
+ {
+ id: 1,
+ title: 'Filter references an undefined variable `lhsPriority`',
+ severity: 'bug',
+ body:
+ 'Inside `.filter(...)` the condition reads `lhsPriority`, a name ' +
+ 'that is never declared in scope. The intended variable is the ' +
+ '`balancePriority` that was computed one line above. In strict mode ' +
+ 'this is a ReferenceError; in non-strict (legacy) mode it is silently ' +
+ '`undefined`, so `undefined > -99` is `false` and every item is ' +
+ 'rejected before the inner amount check ever runs.',
+ },
+ {
+ id: 2,
+ title: 'Filter logic is inverted β keeps balances with `amount <= 0`',
+ severity: 'bug',
+ body:
+ 'The inner branch returns `true` when `balance.amount <= 0`. A swap / ' +
+ 'wallet view should drop empty or negative balances and keep the ' +
+ 'positive ones, so the comparison should be `amount > 0`. As written, ' +
+ 'the only way an item would survive the filter is if priority were ' +
+ 'valid AND the user had zero or negative funds.',
+ },
+ {
+ id: 3,
+ title: '`formattedBalances` is computed but never used',
+ severity: 'bug',
+ body:
+ 'After sorting, the code maps `sortedBalances` into `formattedBalances` ' +
+ 'with a `formatted` field, but `rows` then maps `sortedBalances` again β ' +
+ 'not `formattedBalances`. As a result `balance.formatted` is `undefined` ' +
+ 'at render time, which gets passed as `formattedAmount` to `WalletRow`. ' +
+ 'Either consume `formattedBalances` or fold the format step into the ' +
+ 'main pipeline.',
+ },
+ {
+ id: 4,
+ title: 'Sort comparator returns `undefined` for equal priorities',
+ severity: 'bug',
+ body:
+ 'When `leftPriority === rightPriority`, neither branch executes and ' +
+ 'the comparator implicitly returns `undefined`. The Array.prototype.sort ' +
+ 'spec requires a number; V8 currently treats this as 0 but the behaviour ' +
+ 'is fragile and TypeScript with `noImplicitReturns` flags it. Always ' +
+ 'return `0` (or a tiebreaker like alphabetical currency) explicitly.',
+ },
+ {
+ id: 5,
+ title: '`WalletBalance` type is missing the `blockchain` field',
+ severity: 'bug',
+ body:
+ 'The component reads `balance.blockchain` to compute priority, but the ' +
+ 'interface only declares `currency` and `amount`. With TS strict mode ' +
+ 'this is a compile error; without strict mode it silently typed as ' +
+ '`any`. The refactor adds `blockchain: Blockchain` to the interface so ' +
+ 'the lookup is type-safe.',
+ },
+ {
+ id: 6,
+ title: '`getPriority(blockchain: any)` defeats the type system',
+ severity: 'anti-pattern',
+ body:
+ 'Typing the parameter as `any` lets any string (or non-string) flow ' +
+ 'through unchecked. Define a `Blockchain` union of the supported chains ' +
+ 'and type the parameter as that, so adding a new chain becomes a ' +
+ 'compile-time TODO instead of a silent `-99`.',
+ },
+ {
+ id: 7,
+ title: '`getPriority` is re-declared on every render',
+ severity: 'perf',
+ body:
+ 'The function is pure and closes over nothing from the component scope. ' +
+ 'Defining it inside the component allocates a new function reference on ' +
+ 'every render, which doesn\'t matter for `getPriority` itself but does ' +
+ 'matter if you ever pass it as a prop or memo dependency. Hoist it to ' +
+ 'module scope (or wrap in `useCallback` if it truly needs state).',
+ },
+ {
+ id: 8,
+ title: '`getPriority` runs O(n log n) times during sort',
+ severity: 'perf',
+ body:
+ 'The sort comparator calls `getPriority` twice per comparison, and a ' +
+ 'sort performs O(n log n) comparisons, so the function is invoked ' +
+ 'O(n log n) times. Precompute the priority once per item with a ' +
+ '`.map` (a "Schwartzian transform"), sort by the precomputed number, ' +
+ 'and the lookup cost drops to O(n).',
+ },
+ {
+ id: 9,
+ title: '`useMemo` depends on `prices` but the memoised value does not use it',
+ severity: 'perf',
+ body:
+ 'The dependency array `[balances, prices]` causes `sortedBalances` to ' +
+ 'recompute whenever the price feed ticks, even though filter/sort only ' +
+ 'read `balance.amount` and `balance.blockchain`. Either drop `prices` ' +
+ 'from the deps OR consume it inside the memo (the refactor does the ' +
+ 'latter by including USD value).',
+ },
+ {
+ id: 10,
+ title: '`formattedBalances` is not memoised',
+ severity: 'perf',
+ body:
+ '`sortedBalances.map(...)` runs on every render, even when nothing ' +
+ 'relevant changed. Move the format step into the same `useMemo` as the ' +
+ 'sort so the entire derived array is cached together.',
+ },
+ {
+ id: 11,
+ title: 'Using array `index` as React `key`',
+ severity: 'anti-pattern',
+ body:
+ 'When the underlying list is sorted or items are inserted / removed, ' +
+ 'index-based keys force React to re-mount components instead of ' +
+ 'reusing them, which kills local state and animation continuity. Use a ' +
+ 'stable identifier β here `balance.currency` is naturally unique per ' +
+ 'row.',
+ },
+ {
+ id: 12,
+ title: '`prices[balance.currency]` may be undefined β NaN in the UI',
+ severity: 'bug',
+ body:
+ '`prices[unknownCurrency]` returns `undefined`, and `undefined * 2` is ' +
+ '`NaN`. The UI then shows "$NaN". The refactor either filters out ' +
+ 'rows with no price OR falls back to 0 (the demo chooses the latter to ' +
+ 'keep the row visible).',
+ },
+ {
+ id: 13,
+ title: 'Empty `interface Props extends BoxProps {}`',
+ severity: 'anti-pattern',
+ body:
+ 'The interface declares no members of its own β it is a redundant ' +
+ 'alias for `BoxProps`. Either use `BoxProps` directly, or `type Props ' +
+ '= BoxProps` if you want a local alias.',
+ },
+ {
+ id: 14,
+ title: '`children` destructured but never rendered',
+ severity: 'anti-pattern',
+ body:
+ 'The original pulls `children` out of `props` and then discards it. ' +
+ 'If the parent passes JSX between the tags it silently disappears. ' +
+ 'Either render `{children}` or include it in `...rest` so the wrapping ' +
+ '`` keeps acting as a transparent box.',
+ },
+ {
+ id: 15,
+ title: '`balance.amount.toFixed()` without a precision argument',
+ severity: 'bug',
+ body:
+ 'Calling `toFixed()` with no argument defaults to 0 decimals, so ' +
+ '`(1.2345).toFixed()` returns `"1"` and the displayed balance loses ' +
+ 'precision. Pick an explicit decimal count appropriate for the asset ' +
+ '(the refactor uses 4).',
+ },
+ {
+ id: 16,
+ title: 'Row items typed as `FormattedWalletBalance` but actually `WalletBalance`',
+ severity: 'bug',
+ body:
+ 'The `rows = sortedBalances.map(...)` callback annotates `balance` as ' +
+ '`FormattedWalletBalance`, but `sortedBalances` is `WalletBalance[]` ' +
+ 'because `formattedBalances` was never plumbed in. TS strict mode ' +
+ 'would flag this as an unsafe cast; without strict mode it hides the ' +
+ 'runtime "formatted is undefined" bug.',
+ },
+ {
+ id: 17,
+ title: '`usdValue` is recomputed in the render path',
+ severity: 'perf',
+ body:
+ 'Each render recalculates `prices[balance.currency] * balance.amount` ' +
+ 'for every row. Move it into the memoised pipeline so the multiplication ' +
+ 'only fires when balances or prices actually change.',
+ },
+];
+
+export const SEVERITY_LABEL: Record
= {
+ bug: 'Bug',
+ perf: 'Perf',
+ 'anti-pattern': 'Anti-pattern',
+ style: 'Style',
+};
diff --git a/src/problem3/mocks.tsx b/src/problem3/mocks.tsx
new file mode 100644
index 0000000000..b16480a896
--- /dev/null
+++ b/src/problem3/mocks.tsx
@@ -0,0 +1,98 @@
+// Stand-ins for symbols the original code pulled from external modules:
+// `useWalletBalances`, `usePrices`, `WalletRow`, `BoxProps`, `classes`.
+// They let the refactored component actually render inside the demo tab.
+
+import type React from 'react';
+
+export type Blockchain =
+ | 'Osmosis'
+ | 'Ethereum'
+ | 'Arbitrum'
+ | 'Zilliqa'
+ | 'Neo';
+
+export interface WalletBalance {
+ currency: string;
+ amount: number;
+ blockchain: Blockchain;
+}
+
+export interface FormattedWalletBalance extends WalletBalance {
+ formatted: string;
+ usdValue: number;
+}
+
+export type BoxProps = React.HTMLAttributes;
+
+export const classes = {
+ row: 'p3-row',
+} as const;
+
+// --- demo data (mock hook outputs) ---
+
+const FIXTURE_BALANCES: WalletBalance[] = [
+ { currency: 'ETH', amount: 2.4137, blockchain: 'Ethereum' },
+ { currency: 'OSMO', amount: 1280.5, blockchain: 'Osmosis' },
+ { currency: 'ARB', amount: 530.0, blockchain: 'Arbitrum' },
+ { currency: 'ZIL', amount: 12_500, blockchain: 'Zilliqa' },
+ { currency: 'NEO', amount: 42.18, blockchain: 'Neo' },
+ // amount <= 0 β should be filtered out
+ { currency: 'GAS', amount: 0, blockchain: 'Neo' },
+ // priority -99 β should be filtered out
+ { currency: 'UNKNOWN', amount: 10, blockchain: 'Osmosis' },
+];
+
+const FIXTURE_PRICES: Record = {
+ ETH: 2_385.0,
+ OSMO: 0.78,
+ ARB: 0.91,
+ ZIL: 0.018,
+ NEO: 11.4,
+ GAS: 4.2,
+ // UNKNOWN intentionally has no price
+};
+
+// React Hooks rules require a stable identity so React treats them as
+// hooks; using regular functions is fine because they have no state.
+export const useWalletBalances = (): WalletBalance[] => FIXTURE_BALANCES;
+export const usePrices = (): Record => FIXTURE_PRICES;
+
+interface WalletRowProps {
+ className?: string;
+ amount: number;
+ usdValue: number;
+ formattedAmount: string;
+ currency: string;
+ blockchain: Blockchain;
+}
+
+export function WalletRow({
+ className,
+ amount,
+ usdValue,
+ formattedAmount,
+ currency,
+ blockchain,
+}: WalletRowProps) {
+ return (
+
+
+ {currency}
+ {blockchain}
+
+
+
+ {formattedAmount}{' '}
+ ({amount})
+
+
+ $
+ {usdValue.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+
+
+ );
+}
diff --git a/src/problem3/original.tsx.txt b/src/problem3/original.tsx.txt
new file mode 100644
index 0000000000..78678160fe
--- /dev/null
+++ b/src/problem3/original.tsx.txt
@@ -0,0 +1,81 @@
+interface WalletBalance {
+ currency: string;
+ amount: number;
+}
+interface FormattedWalletBalance {
+ currency: string;
+ amount: number;
+ formatted: string;
+}
+
+interface Props extends BoxProps {
+
+}
+const WalletPage: React.FC = (props: Props) => {
+ const { children, ...rest } = props;
+ const balances = useWalletBalances();
+ const prices = usePrices();
+
+ const getPriority = (blockchain: any): number => {
+ switch (blockchain) {
+ case 'Osmosis':
+ return 100
+ case 'Ethereum':
+ return 50
+ case 'Arbitrum':
+ return 30
+ case 'Zilliqa':
+ return 20
+ case 'Neo':
+ return 20
+ default:
+ return -99
+ }
+ }
+
+ const sortedBalances = useMemo(() => {
+ return balances.filter((balance: WalletBalance) => {
+ const balancePriority = getPriority(balance.blockchain);
+ if (lhsPriority > -99) {
+ if (balance.amount <= 0) {
+ return true;
+ }
+ }
+ return false
+ }).sort((lhs: WalletBalance, rhs: WalletBalance) => {
+ const leftPriority = getPriority(lhs.blockchain);
+ const rightPriority = getPriority(rhs.blockchain);
+ if (leftPriority > rightPriority) {
+ return -1;
+ } else if (rightPriority > leftPriority) {
+ return 1;
+ }
+ });
+ }, [balances, prices]);
+
+ const formattedBalances = sortedBalances.map((balance: WalletBalance) => {
+ return {
+ ...balance,
+ formatted: balance.amount.toFixed()
+ }
+ })
+
+ const rows = sortedBalances.map((balance: FormattedWalletBalance, index: number) => {
+ const usdValue = prices[balance.currency] * balance.amount;
+ return (
+
+ )
+ })
+
+ return (
+
+ {rows}
+
+ )
+}
diff --git a/src/problem3/problem3.md b/src/problem3/problem3.md
new file mode 100644
index 0000000000..1bbd6f0194
--- /dev/null
+++ b/src/problem3/problem3.md
@@ -0,0 +1,99 @@
+# Problem 3: Messy React
+
+
+β° Duration: You should not spend more than 6 **hours** on this problem.
+*Time estimation is for internship roles, if you are a software professional you should spend significantly less time.*
+
+
+
+# Task
+
+List out the computational inefficiencies and anti-patterns found in the code block below.
+
+1. This code block uses
+ 1. ReactJS with TypeScript.
+ 2. Functional components.
+ 3. React Hooks
+2. You should also provide a refactored version of the code, but more points are awarded to accurately stating the issues and explaining correctly how to improve them.
+
+interface WalletBalance {
+ currency: string;
+ amount: number;
+}
+interface FormattedWalletBalance {
+ currency: string;
+ amount: number;
+ formatted: string;
+}
+
+interface Props extends BoxProps {
+
+}
+const WalletPage: React.FC = (props: Props) => {
+ const { children, ...rest } = props;
+ const balances = useWalletBalances();
+ const prices = usePrices();
+
+ const getPriority = (blockchain: any): number => {
+ switch (blockchain) {
+ case 'Osmosis':
+ return 100
+ case 'Ethereum':
+ return 50
+ case 'Arbitrum':
+ return 30
+ case 'Zilliqa':
+ return 20
+ case 'Neo':
+ return 20
+ default:
+ return -99
+ }
+ }
+
+ const sortedBalances = useMemo(() => {
+ return balances.filter((balance: WalletBalance) => {
+ const balancePriority = getPriority(balance.blockchain);
+ if (lhsPriority > -99) {
+ if (balance.amount <= 0) {
+ return true;
+ }
+ }
+ return false
+ }).sort((lhs: WalletBalance, rhs: WalletBalance) => {
+ const leftPriority = getPriority(lhs.blockchain);
+ const rightPriority = getPriority(rhs.blockchain);
+ if (leftPriority > rightPriority) {
+ return -1;
+ } else if (rightPriority > leftPriority) {
+ return 1;
+ }
+ });
+ }, [balances, prices]);
+
+ const formattedBalances = sortedBalances.map((balance: WalletBalance) => {
+ return {
+ ...balance,
+ formatted: balance.amount.toFixed()
+ }
+ })
+
+ const rows = sortedBalances.map((balance: FormattedWalletBalance, index: number) => {
+ const usdValue = prices[balance.currency] * balance.amount;
+ return (
+
+ )
+ })
+
+ return (
+
+ {rows}
+
+ )
+}
\ No newline at end of file
diff --git a/src/problem3/style.css b/src/problem3/style.css
new file mode 100644
index 0000000000..d052ee867f
--- /dev/null
+++ b/src/problem3/style.css
@@ -0,0 +1,292 @@
+/* Problem 3 β Messy React */
+
+.p3 {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.p3-section-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 14px;
+ flex-wrap: wrap;
+}
+
+.p3-section-head h2 {
+ text-transform: none;
+ letter-spacing: 0;
+ font-size: 16px;
+ color: var(--text-strong);
+ margin: 0 0 4px;
+}
+
+.p3-section-blurb {
+ margin: 0;
+ font-size: 12.5px;
+ color: var(--muted);
+ max-width: 720px;
+}
+
+.p3-section-blurb code {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ padding: 0 4px;
+ border-radius: 3px;
+ font-size: 11.5px;
+ color: var(--text);
+}
+
+/* ---------------- filter ---------------- */
+
+.p3-filter,
+.p3-layout-toggle {
+ display: flex;
+ gap: 4px;
+ background: var(--panel-2);
+ padding: 4px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ flex-wrap: wrap;
+}
+
+.p3-filter-btn {
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 5px 10px;
+ border-radius: 5px;
+ cursor: pointer;
+}
+
+.p3-filter-btn:hover {
+ color: var(--text);
+}
+
+.p3-filter-btn.active {
+ background: var(--accent-bg);
+ color: var(--text-strong);
+}
+
+/* ---------------- issues list ---------------- */
+
+.p3-issue-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
+ gap: 12px;
+}
+
+.p3-issue {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--border-strong);
+ border-radius: 8px;
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.p3-issue.sev-bug {
+ border-left-color: var(--fail);
+}
+.p3-issue.sev-perf {
+ border-left-color: var(--warn);
+}
+.p3-issue.sev-anti-pattern {
+ border-left-color: var(--accent);
+}
+.p3-issue.sev-style {
+ border-left-color: var(--muted);
+}
+
+.p3-issue-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.p3-issue-num {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-weight: 700;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.p3-sev-badge {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 2px 7px;
+ border-radius: 3px;
+ background: var(--code-bg);
+ color: var(--muted);
+}
+
+.p3-sev-badge.sev-bug {
+ background: rgba(255, 104, 104, 0.16);
+ color: var(--fail);
+}
+.p3-sev-badge.sev-perf {
+ background: rgba(240, 182, 87, 0.16);
+ color: var(--warn);
+}
+.p3-sev-badge.sev-anti-pattern {
+ background: var(--accent-bg);
+ color: var(--accent-strong);
+}
+
+.p3-issue-title {
+ margin: 0;
+ font-size: 13.5px;
+ font-weight: 600;
+ color: var(--text-strong);
+ flex: 1;
+ min-width: 200px;
+}
+
+.p3-issue-body {
+ margin: 0;
+ font-size: 12.5px;
+ line-height: 1.6;
+ color: var(--text);
+}
+
+/* ---------------- code grid ---------------- */
+
+.p3-code-grid {
+ display: grid;
+ gap: 16px;
+}
+
+.p3-code-grid.split {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+.p3-code-grid.stacked {
+ grid-template-columns: 1fr;
+}
+
+@media (max-width: 1080px) {
+ .p3-code-grid.split {
+ grid-template-columns: 1fr;
+ }
+}
+
+.p3-code-block {
+ display: flex;
+ flex-direction: column;
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ border-radius: 8px;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.p3-code-block.original {
+ border-color: rgba(255, 104, 104, 0.3);
+}
+
+.p3-code-block.refactored {
+ border-color: rgba(68, 196, 102, 0.3);
+}
+
+.p3-code-label {
+ padding: 8px 14px;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ color: var(--muted);
+ border-bottom: 1px solid var(--code-border);
+ background: var(--panel-2);
+}
+
+.p3-code-block.original .p3-code-label {
+ color: var(--fail);
+}
+
+.p3-code-block.refactored .p3-code-label {
+ color: var(--pass);
+}
+
+.p3-code {
+ margin: 0;
+ padding: 14px 16px;
+ overflow: auto;
+ font-size: 12px;
+ line-height: 1.55;
+ max-height: 520px;
+}
+
+/* ---------------- demo ---------------- */
+
+.p3-demo {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.p3-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ gap: 16px;
+}
+
+.p3-row-main {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.p3-row-symbol {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-strong);
+}
+
+.p3-row-chain {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.p3-row-numbers {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+}
+
+.p3-row-amount {
+ font-size: 13px;
+ color: var(--text);
+}
+
+.p3-row-amount-raw {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.p3-row-usd {
+ font-size: 12px;
+ color: var(--muted);
+}
diff --git a/src/problem3/view.tsx b/src/problem3/view.tsx
new file mode 100644
index 0000000000..c4f13c7dc5
--- /dev/null
+++ b/src/problem3/view.tsx
@@ -0,0 +1,154 @@
+import { useMemo, useState } from 'react';
+import { issues, SEVERITY_LABEL, type Severity } from './analysis';
+import { WalletPage } from './Refactored';
+import originalSource from './original.tsx.txt?raw';
+import refactoredSource from './Refactored.tsx?raw';
+import './style.css';
+
+type CodeLayout = 'split' | 'stacked';
+type SeverityFilter = Severity | 'all';
+
+export function Problem3View() {
+ const [layout, setLayout] = useState('split');
+ const [filter, setFilter] = useState('all');
+
+ const filtered = useMemo(
+ () =>
+ filter === 'all'
+ ? issues
+ : issues.filter((i) => i.severity === filter),
+ [filter],
+ );
+
+ const counts = useMemo(() => {
+ const c: Record = {
+ bug: 0,
+ perf: 0,
+ 'anti-pattern': 0,
+ style: 0,
+ };
+ for (const i of issues) c[i.severity]++;
+ return c;
+ }, []);
+
+ return (
+
+ {/* ---------------- Section 1: Issues ---------------- */}
+
+
+
+
Issues found
+
+ {issues.length} distinct issues across bugs, performance, and
+ anti-patterns.
+
+
+
+ {(['all', 'bug', 'perf', 'anti-pattern'] as SeverityFilter[]).map(
+ (f) => (
+ setFilter(f)}
+ >
+ {f === 'all'
+ ? `All (${issues.length})`
+ : `${SEVERITY_LABEL[f as Severity]} (${counts[f as Severity]})`}
+
+ ),
+ )}
+
+
+
+
+ {filtered.map((issue) => (
+
+
+ #{issue.id}
+
+ {SEVERITY_LABEL[issue.severity]}
+
+
{issue.title}
+
+ {issue.body}
+
+ ))}
+
+
+
+ {/* ---------------- Section 2: Side-by-side code ---------------- */}
+
+
+ {/* ---------------- Section 3: Live demo ---------------- */}
+
+
+ );
+}
+
+function CodeBlock({
+ label,
+ source,
+ flavour,
+}: {
+ label: string;
+ source: string;
+ flavour: 'original' | 'refactored';
+}) {
+ return (
+
+
{label}
+
+ {source}
+
+
+ );
+}
diff --git a/src/problem4/.keep b/src/problem4/.keep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/problem5/.keep b/src/problem5/.keep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/shell.css b/src/shell.css
new file mode 100644
index 0000000000..808be7279f
--- /dev/null
+++ b/src/shell.css
@@ -0,0 +1,294 @@
+:root {
+ color-scheme: dark;
+ --bg: #0b0f17;
+ --bg-elev: #111827;
+ --panel: #161e2e;
+ --panel-2: #1c2638;
+ --border: #2a3447;
+ --border-strong: #3a465e;
+ --text: #e6edf6;
+ --text-strong: #f3f7fd;
+ --muted: #95a3b8;
+ --accent: #6aa7ff;
+ --accent-strong: #88baff;
+ --accent-bg: rgba(106, 167, 255, 0.14);
+ --pass: #44c466;
+ --fail: #ff6868;
+ --warn: #f0b657;
+ --code-bg: #060a13;
+ --code-border: #1f2a3d;
+ --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
+ --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.35);
+ --radius: 10px;
+ --radius-sm: 6px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+}
+
+body {
+ font-family:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Inter, Oxygen,
+ Ubuntu, Cantarell, sans-serif;
+ font-size: 14px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+
+code,
+pre,
+.mono {
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+a:hover {
+ color: var(--accent-strong);
+ text-decoration: underline;
+}
+
+/* ---------------- app shell ---------------- */
+
+.app {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 28px 32px 48px;
+}
+
+.app-header {
+ margin-bottom: 24px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--border);
+}
+
+.app-title {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.app-title h1 {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 600;
+ color: var(--text-strong);
+}
+
+.app-title-tag {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 1.5px;
+ color: var(--accent);
+ background: var(--accent-bg);
+ padding: 4px 8px;
+ border-radius: 4px;
+ text-transform: uppercase;
+}
+
+.app-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+}
+
+.app-tab {
+ padding: 9px 14px;
+ border-radius: 6px;
+ font-size: 13px;
+ color: var(--muted);
+ font-weight: 500;
+ border: 1px solid transparent;
+ transition: all 0.1s ease;
+}
+
+.app-tab:hover {
+ color: var(--text);
+ background: var(--panel);
+ text-decoration: none;
+}
+
+.app-tab.active {
+ color: var(--text-strong);
+ background: var(--accent-bg);
+ border-color: var(--accent);
+}
+
+.app-subtitle {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.app-main {
+ min-height: 60vh;
+}
+
+.app-footer {
+ margin-top: 32px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.app-footer code {
+ background: var(--code-bg);
+ border: 1px solid var(--code-border);
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 11.5px;
+}
+
+/* ---------------- shared panel ---------------- */
+
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 20px;
+ min-width: 0;
+}
+
+.panel h2,
+.panel h3 {
+ color: var(--text-strong);
+ margin: 0 0 10px;
+}
+
+.panel h2 {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+}
+
+.panel h2:not(:first-child) {
+ margin-top: 22px;
+}
+
+/* ---------------- shared buttons ---------------- */
+
+.btn {
+ border: none;
+ padding: 10px 18px;
+ border-radius: var(--radius-sm);
+ font-weight: 600;
+ font-family: inherit;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.1s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+}
+
+.btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #0a0f1a;
+}
+
+.btn-primary:not(:disabled):hover {
+ background: var(--accent-strong);
+}
+
+.btn-secondary {
+ background: transparent;
+ border: 1px solid var(--border-strong);
+ color: var(--text);
+}
+
+.btn-secondary:not(:disabled):hover {
+ border-color: var(--accent);
+ color: var(--text-strong);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--muted);
+ padding: 6px 10px;
+}
+
+.btn-ghost:hover {
+ color: var(--text);
+ background: var(--panel-2);
+}
+
+/* ---------------- spinner ---------------- */
+
+.spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ---------------- toast ---------------- */
+
+.toast {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--panel);
+ border: 1px solid var(--border-strong);
+ padding: 12px 18px;
+ border-radius: 8px;
+ box-shadow: var(--shadow-2);
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ animation: toast-in 0.2s ease;
+ z-index: 9999;
+}
+
+.toast.toast-success {
+ border-color: var(--pass);
+}
+
+.toast.toast-error {
+ border-color: var(--fail);
+}
+
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000000..310a55fa09
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true,
+ "useDefineForClassFields": true,
+ "types": ["vite/client"],
+ "noEmit": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000000..2bb2d8d10d
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ open: true,
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ sourcemap: true,
+ },
+ test: {
+ environment: 'node',
+ globals: false,
+ include: ['src/**/*.test.{ts,tsx}'],
+ },
+});