From a2aa0e9d8f852c3cca2f54bf98ebdd9795e51ba9 Mon Sep 17 00:00:00 2001 From: Le Hoang Dai Date: Fri, 15 May 2026 18:18:05 +0700 Subject: [PATCH] feat(): solution --- src/problem1/main.js | 29 +++++ src/problem2/index.html | 143 +++++++++++++++++++--- src/problem2/script.js | 248 +++++++++++++++++++++++++++++++++++++++ src/problem2/style.css | 31 +++-- src/problem3/ANALYSIS.md | 95 +++++++++++++++ src/problem3/main.tsx | 59 ++++++++++ 6 files changed, 581 insertions(+), 24 deletions(-) create mode 100644 src/problem1/main.js create mode 100644 src/problem3/ANALYSIS.md create mode 100644 src/problem3/main.tsx diff --git a/src/problem1/main.js b/src/problem1/main.js new file mode 100644 index 0000000000..91bd268074 --- /dev/null +++ b/src/problem1/main.js @@ -0,0 +1,29 @@ +var sum_to_n_a = function (n) { + if (n < 1) { + return 0 + } + + let sum = 0; + for (let i = 1; i <= n; i++) { + sum += i; + } + return sum +}; + +var sum_to_n_b = function (n) { + if (n < 1) { + return 0 + } + return n + sum_to_n_b(n - 1) +}; + +var sum_to_n_c = function (n) { + if (n < 1) { + return 0 + } + return n * (n + 1) / 2 +}; + +console.log(sum_to_n_a(5)) +console.log(sum_to_n_b(5)) +console.log(sum_to_n_c(5)) diff --git a/src/problem2/index.html b/src/problem2/index.html index 4058a68bff..58fcecc9e4 100644 --- a/src/problem2/index.html +++ b/src/problem2/index.html @@ -1,27 +1,136 @@ - - + + - - Fancy Form - - + + + Swap + + + + + + + +
+ + +
+ +
+ +
+ + +
+

Swap

+

Exchange tokens instantly

+
- + +
+
+ You pay +
+
+
+ + +
+ +
+ +
- -
-
Swap
- - + +
+ +
- - + +
+
+ You receive +
+
+
+ + +
+ +
+
+ + + + + + + + + + +
+
+ + +
+ + +
- - - diff --git a/src/problem2/script.js b/src/problem2/script.js index e69de29bb2..8d1ecdf755 100644 --- a/src/problem2/script.js +++ b/src/problem2/script.js @@ -0,0 +1,248 @@ +const PRICES_URL = "https://interview.switcheo.com/prices.json"; +const ICON_BASE = "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens"; +const icon = (s) => `${ICON_BASE}/${s}.svg`; +const $ = (id) => document.getElementById(id); + +let prices = {}, tokens = [], fromToken = null, toToken = null; + +const els = { + fromBtn: $("from-btn"), + fromChevron: $("from-chevron"), + fromIcon: $("from-icon"), + fromSymbol: $("from-symbol"), + fromDropdown: $("from-dropdown"), + fromList: $("from-list"), + fromSearch: $("from-search"), + toBtn: $("to-btn"), + toChevron: $("to-chevron"), + toIcon: $("to-icon"), + toSymbol: $("to-symbol"), + toDropdown: $("to-dropdown"), + toList: $("to-list"), + toSearch: $("to-search"), + inputAmount: $("input-amount"), + outputAmount: $("output-amount"), + rateBar: $("rate-bar"), + amountError: $("amount-error"), + globalError: $("global-error"), + confirmBtn: $("confirm-btn"), + btnText: $("btn-text"), + spinner: $("spinner"), + swapDirBtn: $("swap-direction"), + toast: $("toast"), + toastMsg: $("toast-msg"), + themeToggle: $("theme-toggle"), + iconSun: $("icon-sun"), + iconMoon: $("icon-moon"), +}; + +// ── Init ────────────────────────────────────────────────────────────────────── +async function init() { + try { + const data = await fetch(PRICES_URL).then(r => r.json()); + data.forEach(({ currency, price }) => { prices[currency] = parseFloat(price); }); + tokens = Object.keys(prices).sort(); + + renderList(els.fromList, selectFrom); + renderList(els.toList, selectTo); + + const preferred = ["ETH", "BTC", "USDC", "SWTH"]; + const first = preferred.find(t => tokens.includes(t)) || tokens[0]; + const second = preferred.filter(t => t !== first).find(t => tokens.includes(t)) || tokens[1]; + selectFrom(first); + selectTo(second); + } catch { + showErr(els.globalError, "⚠ Failed to load prices. Please refresh."); + } +} + +// ── Render token list ───────────────────────────────────────────────────────── +function renderList(ul, onSelect, list = tokens) { + ul.innerHTML = list.map(t => ` +
  • + ${t} + ${t} + + $${prices[t].toFixed(prices[t] < 0.01 ? 6 : 4)} + +
  • `).join(""); + + ul.querySelectorAll("li").forEach(li => + li.addEventListener("click", () => onSelect(li.dataset.symbol)) + ); +} + +function markActive(ul, symbol) { + ul.querySelectorAll("li").forEach(li => + li.classList.toggle("active-token", li.dataset.symbol === symbol) + ); +} + +// ── Token selection ─────────────────────────────────────────────────────────── +function selectToken(side, symbol) { + if (side === "from") fromToken = symbol; + else toToken = symbol; + + els[`${side}Symbol`].textContent = symbol; + els[`${side}Icon`].src = icon(symbol); + els[`${side}Icon`].style.visibility = ""; + closeDropdown(els[`${side}Dropdown`], els[`${side}Chevron`]); + markActive(els[`${side}List`], symbol); + compute(); + updateRate(); +} + +const selectFrom = (s) => selectToken("from", s); +const selectTo = (s) => selectToken("to", s); + +// ── Compute & rate ──────────────────────────────────────────────────────────── +function compute() { + const val = parseFloat(els.inputAmount.value); + const canCompute = fromToken && toToken && !isNaN(val) && val > 0; + els.outputAmount.value = canCompute + ? fmt((val * prices[fromToken]) / prices[toToken]) + : ""; +} + +function updateRate() { + if (!fromToken || !toToken) { + els.rateBar.style.display = "none"; + return; + } + const rate = prices[fromToken] / prices[toToken]; + els.rateBar.innerHTML = ` + + 1 ${fromToken} ≈  + ${fmt(rate)} ${toToken} + live prices`; + els.rateBar.style.display = "flex"; +} + +function fmt(n) { + if (!n) return "0"; + if (n < 0.000001) return n.toExponential(4); + if (n < 0.01) return n.toFixed(8); + if (n < 1000) return n.toFixed(6); + return n.toLocaleString("en-US", { maximumFractionDigits: 4 }); +} + +// ── Dropdown ────────────────────────────────────────────────────────────────── +function openDropdown(d, c) { + d.classList.remove("hidden"); + c.classList.add("rotate-180"); +} +function closeDropdown(d, c) { + d.classList.add("hidden"); + c.classList.remove("rotate-180"); +} +function closeAll() { + closeDropdown(els.fromDropdown, els.fromChevron); + closeDropdown(els.toDropdown, els.toChevron); +} + +els.fromBtn.addEventListener("click", e => { + e.stopPropagation(); + const wasHidden = els.fromDropdown.classList.contains("hidden"); + closeAll(); + if (wasHidden) { + openDropdown(els.fromDropdown, els.fromChevron); + els.fromSearch.focus(); +} +}); + +els.toBtn.addEventListener("click", e => { + e.stopPropagation(); + const wasHidden = els.toDropdown.classList.contains("hidden"); + closeAll(); + if (wasHidden) { + openDropdown(els.toDropdown, els.toChevron); + els.toSearch.focus(); +} +}); + +document.addEventListener("click", closeAll); +els.fromDropdown.addEventListener("click", e => e.stopPropagation()); +els.toDropdown.addEventListener("click", e => e.stopPropagation()); + +els.fromSearch.addEventListener("input", e => { + const q = e.target.value.toLowerCase(); + renderList(els.fromList, selectFrom, tokens.filter(t => t.toLowerCase().includes(q))); + markActive(els.fromList, fromToken); +}); +els.toSearch.addEventListener("input", e => { + const q = e.target.value.toLowerCase(); + renderList(els.toList, selectTo, tokens.filter(t => t.toLowerCase().includes(q))); + markActive(els.toList, toToken); +}); + +// ── Errors ──────────────────────────────────────────────────────────────────── +const showErr = (el, msg) => { + el.textContent = msg; el.classList.remove("hidden"); +}; +const clearErr = (el) => { + el.textContent = ""; el.classList.add("hidden"); +}; + +els.inputAmount.addEventListener("input", () => { + clearErr(els.amountError); + compute(); +}); + +// ── Swap direction ──────────────────────────────────────────────────────────── +els.swapDirBtn.addEventListener("click", () => { + if (!fromToken || !toToken) return; + const [a, b, out] = [fromToken, toToken, els.outputAmount.value]; + selectFrom(b); + selectTo(a); + if (out) { els.inputAmount.value = parseFloat(out.replace(/,/g, "")); compute(); } +}); + +// ── Submit ──────────────────────────────────────────────────────────────────── +els.confirmBtn.addEventListener("click", () => { + clearErr(els.amountError); + clearErr(els.globalError); + + const val = parseFloat(els.inputAmount.value); + if (!fromToken || !toToken) + return showErr(els.globalError, "Please select both tokens."); + if (fromToken === toToken) + return showErr(els.globalError, "Cannot swap a token for itself."); + if (!els.inputAmount.value || isNaN(val) || val <= 0) { + showErr(els.amountError, "Enter a valid amount greater than 0."); + return els.inputAmount.focus(); + } + + els.confirmBtn.disabled = true; + els.spinner.classList.remove("hidden"); + els.btnText.textContent = "Processing…"; + + setTimeout(() => { + const msg = `Swapped ${fmt(val)} ${fromToken} → ${els.outputAmount.value} ${toToken}`; + els.confirmBtn.disabled = false; + els.spinner.classList.add("hidden"); + els.btnText.textContent = "CONFIRM SWAP"; + els.inputAmount.value = els.outputAmount.value = ""; + showToast(msg); + }, 1800); +}); + +// ── Theme ──────────────────────────────────────────────────────────────────── +els.themeToggle.addEventListener("click", () => { + const isDark = document.documentElement.classList.toggle("dark"); + els.iconSun.classList.toggle("hidden", !isDark); + els.iconMoon.classList.toggle("hidden", isDark); +}); + +// ── Toast ───────────────────────────────────────────────────────────────────── +function showToast(msg) { + els.toastMsg.textContent = msg; + els.toast.classList.replace("opacity-0", "opacity-100"); + els.toast.classList.replace("translate-y-20", "translate-y-0"); + setTimeout(() => { + els.toast.classList.replace("opacity-100", "opacity-0"); + els.toast.classList.replace("translate-y-0", "translate-y-20"); + }, 3500); +} + +init(); diff --git a/src/problem2/style.css b/src/problem2/style.css index 915af91c72..08e33c08c2 100644 --- a/src/problem2/style.css +++ b/src/problem2/style.css @@ -1,8 +1,25 @@ -body { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - min-width: 360px; - font-family: Arial, Helvetica, sans-serif; +/* Remove number input spinners */ +input[type=number]::-webkit-outer-spin-button, +input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; } +input[type=number] { -moz-appearance: textfield; } + +/* Custom scrollbar for token dropdowns */ +.dropdown-scroll::-webkit-scrollbar { width: 4px; } +.dropdown-scroll::-webkit-scrollbar-track { background: transparent; } +.dropdown-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,.15); border-radius: 2px; } +.dark .dropdown-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); } + +/* Active token in dropdown */ +li.active-token { background-color: rgba(99,102,241,.12); } + +/* Disabled confirm button */ +.confirm-btn:disabled { + background: #e2e8f0; + cursor: not-allowed; + color: #94a3b8; } +.dark .confirm-btn:disabled { + background: #1e293b; + color: #475569; +} + diff --git a/src/problem3/ANALYSIS.md b/src/problem3/ANALYSIS.md new file mode 100644 index 0000000000..5ee5c26076 --- /dev/null +++ b/src/problem3/ANALYSIS.md @@ -0,0 +1,95 @@ +# Problem 3 — Code Review: `WalletPage` + +## Issues & Fixes + +| # | Issue | Fix | +|---|-------|-----| +| 1 | `lhsPriority` used but never declared → `ReferenceError` | Rename to `balancePriority` | +| 2 | Filter keeps `amount <= 0` (inverted logic) | Change to `amount > 0` | +| 3 | `getPriority` re-created on every render | Move outside the component | +| 4 | `blockchain: any` — no type safety | Use a `Blockchain` union type | +| 5 | `prices` in `useMemo` deps but unused inside it | Remove from dependency array | +| 6 | `formattedBalances` computed but never used (dead code) | Merge into the same `useMemo` chain | +| 7 | `rows` maps `sortedBalances` but accesses `.formatted` (doesn't exist) | Map the formatted array instead | +| 8 | `key={index}` on a sorted/filtered list | Use `key={balance.currency}` | +| 9 | `sort` comparator returns `undefined` when priorities are equal | Return `0` explicitly (or use subtraction) | +| 10 | `children` destructured from props but never rendered | Remove from destructuring | +| 11 | `filter` → `sort` → `map` = 3 array passes + 2 intermediate allocations | Combine `filter` + `map` into one `reduce` → 2 passes total | + +## Note: `useMemo` and referential stability of `balances` + +`useMemo` uses `Object.is` (reference equality) to compare dependencies. Since `balances` is an array, the memo only skips recomputation if `useWalletBalances()` returns the **same array reference** between renders. + +If the hook returns a new array every render, `useMemo` recomputes every render — making it useless. To guard against this: + +```tsx +// Stabilize the reference if useWalletBalances() is not internally memoized +const stableBalances = useMemo(() => balances, [JSON.stringify(balances)]); +``` + +> The ideal fix is ensuring `useWalletBalances` returns a stable reference internally. + +## Refactored Version + +```tsx +type Blockchain = 'Osmosis' | 'Ethereum' | 'Arbitrum' | 'Zilliqa' | 'Neo'; + +interface WalletBalance { + currency: string; + amount: number; + blockchain: Blockchain; +} + +interface FormattedWalletBalance extends WalletBalance { + formatted: string; +} + +// O(1) lookup table — moved outside component, no re-creation on render +const BLOCKCHAIN_PRIORITY: Record = { + Osmosis: 100, + Ethereum: 50, + Arbitrum: 30, + Zilliqa: 20, + Neo: 20, +}; + +const getPriority = (blockchain: Blockchain): number => + BLOCKCHAIN_PRIORITY[blockchain] ?? -99; + +interface Props extends BoxProps {} + +const WalletPage: React.FC = (props: Props) => { + const { ...rest } = props; + const balances = useWalletBalances(); + const prices = usePrices(); + + // reduce: filter + format in one O(n) pass, then sort O(n log n) + const sortedAndFormattedBalances = useMemo((): FormattedWalletBalance[] => { + const formatted = balances.reduce((acc, balance) => { + if (getPriority(balance.blockchain) > -99 && balance.amount > 0) { + acc.push({ ...balance, formatted: balance.amount.toFixed() }); + } + return acc; + }, []); + + return formatted.sort((lhs, rhs) => + getPriority(rhs.blockchain) - getPriority(lhs.blockchain) + ); + }, [balances]); // prices intentionally excluded — not used in this memo + + const rows = sortedAndFormattedBalances.map((balance: FormattedWalletBalance) => { + const usdValue = prices[balance.currency] * balance.amount; + return ( + + ); + }); + + return
    {rows}
    ; +}; +``` diff --git a/src/problem3/main.tsx b/src/problem3/main.tsx new file mode 100644 index 0000000000..d8dfada6dd --- /dev/null +++ b/src/problem3/main.tsx @@ -0,0 +1,59 @@ +type Blockchain = 'Osmosis' | 'Ethereum' | 'Arbitrum' | 'Zilliqa' | 'Neo'; + +interface WalletBalance { + currency: string; + amount: number; + blockchain: Blockchain; +} + +interface FormattedWalletBalance extends WalletBalance { + formatted: string; +} + +const BLOCKCHAIN_PRIORITY: Record = { + Osmosis: 100, + Ethereum: 50, + Arbitrum: 30, + Zilliqa: 20, + Neo: 20, +}; + +const getPriority = (blockchain: Blockchain): number => + BLOCKCHAIN_PRIORITY[blockchain] ?? -99; + +const WalletPage: React.FC = (props: BoxProps) => { + const { ...rest } = props; + const balances = useWalletBalances(); + const prices = usePrices(); + + const sortedAndFormattedBalances = useMemo((): FormattedWalletBalance[] => { + // Single reduce pass: filter + format combined → O(n), then sort → O(n log n) + const formatted = balances.reduce((acc: FormattedWalletBalance[], balance: WalletBalance) => { + if (getPriority(balance.blockchain) > -99 && balance.amount > 0) { + acc.push({ ...balance, formatted: balance.amount.toFixed() }); + } + return acc; + }, []); + + return formatted.sort((lhs: WalletBalance, rhs: WalletBalance) => + getPriority(rhs.blockchain) - getPriority(lhs.blockchain) + ); + }, [balances]); + + const rows = sortedAndFormattedBalances.map((balance: FormattedWalletBalance) => { + const usdValue = prices[balance.currency] * balance.amount; + return ( + + ); + }); + + return
    {rows}
    ; +}; + +export default WalletPage; \ No newline at end of file