diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..2cf33a54fd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +dist +**/.env +**/.env.* +src/problem2/.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..f2692a18e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +package-lock.json +yarn.lock +src/problem2/.env +src/problem2/.env.local +dist/ +settings.json \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000000..0fe10232bc --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/problem1/sum_to_n.test.ts b/src/problem1/sum_to_n.test.ts new file mode 100644 index 0000000000..65066f02eb --- /dev/null +++ b/src/problem1/sum_to_n.test.ts @@ -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); + } + }); +}); diff --git a/src/problem1/sum_to_n.ts b/src/problem1/sum_to_n.ts new file mode 100644 index 0000000000..0860e9a9ff --- /dev/null +++ b/src/problem1/sum_to_n.ts @@ -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); +}; diff --git a/src/problem2/.env.example b/src/problem2/.env.example new file mode 100644 index 0000000000..b18d3ec939 --- /dev/null +++ b/src/problem2/.env.example @@ -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 diff --git a/src/problem2/docker/Dockerfile b/src/problem2/docker/Dockerfile new file mode 100644 index 0000000000..bc2ba02e6a --- /dev/null +++ b/src/problem2/docker/Dockerfile @@ -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;"] diff --git a/src/problem2/docker/nginx-default.conf b/src/problem2/docker/nginx-default.conf new file mode 100644 index 0000000000..77b6ca9de2 --- /dev/null +++ b/src/problem2/docker/nginx-default.conf @@ -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; +} diff --git a/src/problem2/index.html b/src/problem2/index.html index 4058a68bff..e6caa250ce 100644 --- a/src/problem2/index.html +++ b/src/problem2/index.html @@ -1,27 +1,20 @@ - + + - - Fancy Form - - - + + + Fancy Form — Swap + + + - - -
-
Swap
- - - - - - - -
- +
+ diff --git a/src/problem2/script.js b/src/problem2/script.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem2/src/App.tsx b/src/problem2/src/App.tsx new file mode 100644 index 0000000000..65566b48d5 --- /dev/null +++ b/src/problem2/src/App.tsx @@ -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(); + const methods = useForm({ + 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 ( + + + +
+ + + + {loading && } + {!!error && ( + invoke(PRICES_URL)} + /> + )} + {!loading && !Boolean(error) &&
} + + + +