diff --git a/README.md b/README.md index d48120c..1b56086 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,30 @@ -# Javascript Interview Starting Point +# Closest coffee shops finder -This repo will serve as a starting point for your code challenge. Feel free to change anything in order to complete it: Add modules, other tests, new packages etc. +This app finds the 3 closest coffee shops from the user's position. +Shops are ordered from the closest to the farthest and the distance is a number with 4 decimals. The coffee shops are fetched from an api and the user location is manually added with the following command: -## Steps - -- Fork this repo -- Clone your fork -- Finish the exercise -- Push your best work +``` +yarn start +``` -## Commands +## Example input ``` -yarn run start # Run the main script - dev # Start development mode - test # Test the code -```` -## Tools +yarn start 47.6 -122.4 +``` -- Write modern JS with [babel/preset-env](https://www.npmjs.com/package/@babel/preset-env) -- Test your code with [jest](https://www.npmjs.com/package/jest) +## Example output ---- +``` +Starbucks Seattle2, 0.0645 +Starbucks Seattle, 0.0861 +Starbucks SF, 10.0793 +``` + +## Commands -Good luck! +``` +yarn run start # Run the main script +dev # Start development mode +test # Test the code +``` diff --git a/package.json b/package.json index ee65029..7bdf0a7 100644 --- a/package.json +++ b/package.json @@ -22,5 +22,8 @@ "babel-jest": "^30.2.0", "jest": "^30.2.0", "nodemon": "^3.1.10" + }, + "dependencies": { + "@babel/runtime": "^7.28.4" } } diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..2d2ce12 --- /dev/null +++ b/src/api.js @@ -0,0 +1,60 @@ +import { retry } from './utils.js'; + +const BASE_URL = 'https://api-challenge.agilefreaks.com/v1'; + +/** + * Fetch an auth token. + * + * @returns {Promise} auth token + */ +export const fetchAuthToken = async () => + retry(async () => { + const response = await fetch(`${BASE_URL}/tokens`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error( + `Could not fetch token from ${response.url}. Response status: ${response.status} ${response.statusText}.` + ); + } + + const data = await response.json(); + const token = data.token; + + if (!token) { + throw new Error('Token not found!'); + } + + return token; + }); + +/** + * Fetch an array of coffee shops using an auth token. + * + * @param {string} token - auth token + * @returns {Promise>} array of coffee shops + */ +export const fetchCoffeeShops = async (token) => { + if (!token) { + throw new Error('A token must be provided!'); + } + + return retry(async () => { + const response = await fetch(`${BASE_URL}/coffee_shops?token=${token}`); + + if (!response.ok) { + throw new Error( + `Could not fetch coffee shops from ${response.url}. Response status: ${response.status} ${response.statusText}.` + ); + } + + const data = await response.json(); + + if (!Array.isArray(data) || data.length === 0) { + throw new Error('No coffee shops found!'); + } + + return data; + }); +}; diff --git a/src/app.js b/src/app.js index 7e5b07e..fb780e6 100644 --- a/src/app.js +++ b/src/app.js @@ -1,12 +1,29 @@ +import { getDistance, getData, isPositionValid } from './utils.js'; + /** + * Get the 3 nearest coffee shops from the user's position + * * @param {Object} position * @param {Number} position.x * @param {Number} position.y - * + * * @returns {Array} */ -export function getNearestShops(position) { - // code - - return []; +export async function getNearestShops(position) { + if (!isPositionValid(position)) { + throw new Error('x and y coordinates must be prrovided as numbers'); + } + + const coffeeShops = await getData(); + + // extend coffee shops with distance from user position + const extendedCoffeeShops = coffeeShops.map((shop) => ({ + ...shop, + distance: getDistance(position, { x: Number(shop.x), y: Number(shop.y) }), + })); + + // sort ascending by distance and return the 3 nearest shops + return extendedCoffeeShops + .sort((a, b) => a.distance - b.distance) + .slice(0, 3); } diff --git a/src/index.js b/src/index.js index e1516e9..138fdaf 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,23 @@ import { getNearestShops } from './app.js'; +import { isPositionValid } from './utils.js'; -function main(params) { - const position = { - x: process.argv[2], - y: process.argv[3], - }; +async function main() { + const x = Number(process.argv[2]); + const y = Number(process.argv[3]); + const position = { x, y }; - getNearestShops(position); + if (!isPositionValid(position)) { + throw new Error('x and y coordinates must be prrovided as numbers'); + } + + try { + const nearestShops = await getNearestShops(position); + nearestShops.forEach((shop) => { + console.log(`${shop.name}, ${shop.distance}`); + }); + } catch (error) { + throw new Error('Failed to get nearest shops:', error.message); + } } -main(); \ No newline at end of file +main(); diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..437295f --- /dev/null +++ b/src/utils.js @@ -0,0 +1,74 @@ +import { fetchAuthToken, fetchCoffeeShops } from './api.js'; + +/** + * Retry callback function with exponential backoff. + * Used when fetching data fails + * + * @param {Function} callback - async function to retry + * @param {number} maxRetries - maximum number of retries (default 3) + * @param {number} initialDelay - initial delay in ms (default 1000) + * @returns {Promise} + */ +export const retry = async (callback, maxRetries = 3, initialDelay = 1000) => { + let lastError; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await callback(); + } catch (error) { + lastError = error; + + if (attempt < maxRetries) { + const delay = initialDelay * Math.pow(2, attempt); + + console.error( + `Attempt ${attempt + 1} failed. Retrying in ${ + delay / 1000 + } seconds...` + ); + + // wait before next retry + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw lastError; +}; + +/** + * Calculate the distance between the user and a coffee shop using the Euclidean distance formula + * + * @param {{x: number, y: number}} userPosition + * @param {{x: number, y: number}} shopPosition + * @returns {number} distance between user and coffee shop rounded to 4 decimals + */ +export const getDistance = (userPosition, shopPosition) => { + const distX = userPosition.x - shopPosition.x; + const distY = userPosition.y - shopPosition.y; + + return Math.sqrt(distX * distX + distY * distY).toFixed(4); +}; + +/** + * Get the coffe shops data + * + * @returns {Promise>} array of coffee shops + */ +export const getData = async () => { + const token = await fetchAuthToken(); + const coffeeShops = await fetchCoffeeShops(token); + + return coffeeShops; +}; + +/** + * Check if position has x and y coordinates as numbers + * + * @param {{x: number, y: number}} position + */ +export const isPositionValid = (position) => { + const { x, y } = position; + + return !isNaN(x) && !isNaN(y); +}; diff --git a/test/app.test.js b/test/app.test.js index 86931b0..55f13ed 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,10 +1,39 @@ import { getNearestShops } from '../src/app'; +import { getData } from '../src/utils.js'; + +jest.mock('../src/utils.js', () => { + const actual = jest.requireActual('../src/utils.js'); + return { + ...actual, + getData: jest.fn(), + isPositionValid: jest.fn(() => true), + }; +}); describe('App', () => { - it('should return an array when the input is valid', () => { - expect(Array.isArray(getNearestShops({ - lat: 0, - lng: 0, - }))).toBe(true); + // mock getData response + getData.mockResolvedValue([ + { id: 1, name: 'shop 1', x: '1', y: '10' }, + { id: 2, name: 'shop 2', x: '254', y: '-13' }, + { id: 3, name: 'shop 3', x: '34.12', y: '546.23' }, + { id: 4, name: 'shop 4', x: '-12.63', y: '3.45' }, + { id: 5, name: 'shop 5', x: '12', y: '-3' }, + ]); + + test('should return an array when the input is valid', async () => { + const result = await getNearestShops({ x: 0, y: 0 }); + expect(Array.isArray(result)).toBe(true); + }); + + test('should return an array of 3 shops', async () => { + const result = await getNearestShops({ x: 123, y: -43 }); + expect(result).toHaveLength(3); + }); + + test('should return shop 1, shop 2, shop 5', async () => { + const result = await getNearestShops({ x: 1, y: 1 }); + expect(result[0].name).toBe('shop 1'); + expect(result[1].name).toBe('shop 5'); + expect(result[2].name).toBe('shop 4'); }); }); diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..47c6817 --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,57 @@ +import { getDistance, isPositionValid, retry } from '../src/utils.js'; + +describe('Utils', () => { + // isPositionValid + test('isPositionValid returns true for valid numbers', () => { + expect(isPositionValid({ x: 23, y: 44 })).toBe(true); + }); + + test('isPositionValid returns false for invalid x coordinate', () => { + expect(isPositionValid({ x: 'dqwef', y: 2 })).toBe(false); + }); + + test('isPositionValid returns false for invalid y coordinate', () => { + expect(isPositionValid({ x: 52, y: 'cdd6dc' })).toBe(false); + }); + + test('isPositionValid returns false for invalid x and y', () => { + expect(isPositionValid({ x: '2sd3gr', y: 'cdddc' })).toBe(false); + }); + + // getDistance + test('getDistance returns correct distance with 4 decimals', () => { + const result = getDistance({ x: 0, y: 0 }, { x: 3, y: 4 }); + expect(result).toBe('5.0000'); + }); + + // retry + test('retry returns result on first successful try', async () => { + const callback = jest.fn().mockResolvedValue('success'); + const result = await retry(callback); + + expect(result).toBe('success'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('retry retries until success', async () => { + const callback = jest + .fn() + .mockRejectedValueOnce(new Error('fail 1')) + .mockRejectedValueOnce(new Error('fail 2')) + .mockResolvedValueOnce('success'); + const result = await retry(callback, 3, 20); + + expect(result).toBe('success'); + expect(callback).toHaveBeenCalledTimes(3); + }); + + test('retry throws error after max retries', async () => { + const callback = jest + .fn() + .mockRejectedValue(new Error('failed more than max retries')); + + await expect(retry(callback, 3, 20)).rejects.toThrow( + 'failed more than max retries' + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index d5d44b6..52b0b15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -878,6 +878,11 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" +"@babel/runtime@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== + "@babel/template@^7.27.1", "@babel/template@^7.27.2": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"