Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
.git
dist
**/.env
**/.env.*
src/problem2/.env
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
package-lock.json
yarn.lock
src/problem2/.env
src/problem2/.env.local
dist/
settings.json
33 changes: 33 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "code-challenge",
"private": true,
"type": "module",
"scripts": {
"test": "vitest",
"dev:problem2": "vite --config src/problem2/vite.config.ts",
"build:problem2": "vite build --config src/problem2/vite.config.ts",
"preview:problem2": "vite preview --config src/problem2/vite.config.ts",
"docker:build:problem2": "docker build -f src/problem2/docker/Dockerfile -t code-challenge-problem2 .",
"docker:run:problem2": "docker run --rm -p 8080:80 code-challenge-problem2"
},
"devDependencies": {
"@chakra-ui/cli": "^3.35.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"vite": "^7.3.2",
"vitest": "^3.0.5"
},
"dependencies": {
"@chakra-ui/react": "^3.35.0",
"@emotion/react": "^11.14.0",
"next-themes": "^0.4.6",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.75.0",
"react-icons": "^5.6.0"
}
}
63 changes: 63 additions & 0 deletions src/problem1/sum_to_n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { sum_to_n_a, sum_to_n_b, sum_to_n_c } from "./sum_to_n.js";

const implementations = [
["sum_to_n_a", sum_to_n_a],
["sum_to_n_b", sum_to_n_b],
["sum_to_n_c", sum_to_n_c],
] as const;

describe.each(implementations)("%s", (_, sum) => {
it("matches 1 + … + n for small n", () => {
expect(sum(5)).toBe(15);
expect(sum(1)).toBe(1);
expect(sum(10)).toBe(55);
});

it("returns 0 for n < 1", () => {
expect(sum(0)).toBe(0);
expect(sum(-1)).toBe(0);
expect(sum(-100)).toBe(0);
});

it("agrees with naive reference for 0..100", () => {
for (let n = 0; n <= 100; n++) {
let ref = 0;
for (let i = 1; i <= n; i++) ref += i;
expect(sum(n)).toBe(ref);
}
});

it("rejects non-number at runtime (TS types are erased in JS)", () => {
expect(() => sum("5" as unknown as number)).toThrow(TypeError);
expect(() => sum("5" as unknown as number)).toThrow(/n must be a number/);

expect(() => sum(null as unknown as number)).toThrow(TypeError);
expect(() => sum(undefined as unknown as number)).toThrow(TypeError);

expect(() => sum(true as unknown as number)).toThrow(TypeError);
expect(() => sum({} as unknown as number)).toThrow(TypeError);
expect(() => sum([] as unknown as number)).toThrow(TypeError);
});

it("rejects NaN and infinities", () => {
expect(() => sum(NaN)).toThrow(/finite/);
expect(() => sum(Number.POSITIVE_INFINITY)).toThrow(/finite/);
expect(() => sum(Number.NEGATIVE_INFINITY)).toThrow(/finite/);
});

it("rejects non-integer finite numbers", () => {
expect(() => sum(1.5)).toThrow(/integer/);
expect(() => sum(-2.25)).toThrow(/integer/);
});
});

describe("implementations agree", () => {
it("for a range of n", () => {
for (let n = -5; n <= 200; n++) {
const a = sum_to_n_a(n);
expect(sum_to_n_b(n)).toBe(a);
expect(sum_to_n_c(n)).toBe(a);
}
});
});
36 changes: 36 additions & 0 deletions src/problem1/sum_to_n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
function asSummationIndex(n: unknown): number {
if (typeof n !== "number") {
throw new TypeError("n must be a number");
}
if (!Number.isFinite(n)) {
throw new TypeError("n must be finite (not NaN or ±Infinity)");
}
if (!Number.isInteger(n)) {
throw new TypeError("n must be an integer");
}
return n;
}

// Iterative. Time: O(n). Space: O(1).
export const sum_to_n_a = (n: number): number => {
const k = asSummationIndex(n);
let total = 0;
for (let i = 1; i <= k; i++) {
total += i;
}
return total;
};

// Closed form n(n+1)/2. Time: O(1). Space: O(1).
export const sum_to_n_b = (n: number): number => {
const k = asSummationIndex(n);
if (k < 1) return 0;
return (k * (k + 1)) / 2;
};

// Recursive. Time: O(n). Space: O(n) call stack (auxiliary).
export const sum_to_n_c = (n: number): number => {
const k = asSummationIndex(n);
if (k < 1) return 0;
return k + sum_to_n_c(k - 1);
};
4 changes: 4 additions & 0 deletions src/problem2/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copy to `.env` in this folder (`src/problem2/with-react/`). Vite only exposes variables prefixed with `VITE_`.
#
# VITE_PRICES_URL=https://interview.switcheo.com/prices.json
# VITE_TOKEN_ICONS_BASE_URL=https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens
22 changes: 22 additions & 0 deletions src/problem2/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1

# Build the Problem 2 (Vite + React) static bundle.
FROM node:22-alpine AS builder
WORKDIR /app

COPY package.json ./
RUN npm install

COPY tsconfig.json vitest.config.ts ./
COPY src ./src

RUN npm run build:problem2

# Serve prebuilt assets with nginx.
FROM nginx:1.27-alpine AS runtime

COPY src/problem2/docker/nginx-default.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist/problem2-with-react /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
13 changes: 13 additions & 0 deletions src/problem2/docker/nginx-default.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;

location / {
try_files $uri $uri/ /index.html;
}

gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
}
31 changes: 12 additions & 19 deletions src/problem2/index.html
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
<html>
<!DOCTYPE html>
<html lang="en">

<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fancy Form</title>

<!-- You may add more stuff here -->
<link href="style.css" rel="stylesheet" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fancy Form — Swap</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@500&display=swap"
rel="stylesheet" />
</head>

<body>

<!-- You may reorganise the whole HTML, as long as your form achieves the same effect. -->
<form onsubmit="return !1">
<h5>Swap</h5>
<label for="input-amount">Amount to send</label>
<input id="input-amount" />

<label for="output-amount">Amount to receive</label>
<input id="output-amount" />

<button>CONFIRM SWAP</button>
</form>
<script src="script.js"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

</html>
Empty file removed src/problem2/script.js
Empty file.
86 changes: 86 additions & 0 deletions src/problem2/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Box, Container } from "@chakra-ui/react";
import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { ErrorPanel } from "./components/ErrorPanel";
import { Footer } from "./components/Footer";
import { Form } from "./components/form";
import { Header } from "./components/Header";
import { LoadPanel } from "./components/LoadPanel";
import { PRICES_URL } from "./consts";
import { useFetch } from "./hooks/useFetch";
import type { TFormValues, TPrice } from "./models";

const shellMaxW = { base: "min(440px, 100%)", md: "min(600px, 100%)" };

export const App = () => {
const { invoke, data, error, loading } = useFetch<TPrice[]>();
const methods = useForm<TFormValues>({
defaultValues: {
from: undefined,
to: undefined,
amountIn: "",
data: [],
isSubmitting: false,
},
values: {
data: [
...new Map(
data
?.toSorted((a, b) => Number(b.date) - Number(a.date))
.map?.((x) => [x.currency, { ...x, date: Date.parse(x.date) }]) ??
[]
).values(),
],
from: (data ?? [])?.[0]?.currency,
to: (data ?? [])?.[1]?.currency,
amountIn: "",
isSubmitting: false,
},
mode: "onChange",
reValidateMode: "onChange",
});

useEffect(() => {
invoke(PRICES_URL);
}, []);

return (
<FormProvider {...methods}>
<Container
minH="100vh"
w="full"
minW="360px"
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
gap="7"
color="swap.ink"
fontFamily="body"
colorScheme="light"
maxW="full"
bg="white"
>
<Box w="full" maxW={shellMaxW}>
<Header />
</Box>

<Box position="relative" w="full" maxW={shellMaxW}>
{loading && <LoadPanel />}
{!!error && (
<ErrorPanel
title="Error"
detail="Couldn't load the prices"
onRetry={() => invoke(PRICES_URL)}
/>
)}
{!loading && !Boolean(error) && <Form />}
</Box>

<Box w="full" maxW={shellMaxW}>
<Footer />
</Box>
</Container>
</FormProvider>
);
};
34 changes: 34 additions & 0 deletions src/problem2/src/components/ErrorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Box, Text } from "@chakra-ui/react";
import { Button } from "../ui";

export type ErrorPanelProps = {
title: string;
detail: string;
onRetry: () => void;
};

export function ErrorPanel({ title, detail, onRetry }: ErrorPanelProps) {
return (
<Box
as="section"
id="error-panel"
borderRadius="swap-lg"
border="swap-def"
bg="#ffffff"
p="2rem 1.5rem"
textAlign="center"
boxShadow="swap-lg"
role="alert"
>
<Text mb="0.35rem" color="#dc2626" id="error-title">
{title}
</Text>
<Text mb="1.25rem" fontSize="0.9rem" color="#404040" id="error-detail">
{detail}
</Text>
<Button variant="secondary" id="retry-btn" onClick={onRetry}>
Try again
</Button>
</Box>
);
}
9 changes: 9 additions & 0 deletions src/problem2/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Text } from "@chakra-ui/react";

export function Footer() {
return (
<Text w="full" textAlign="center" fontSize="0.78rem" lineHeight="1.45" color="#737373">
Prices load from a public JSON file. No assets move; confirm runs a short wait then shows a status message.
</Text>
);
}
Loading