-
Notifications
You must be signed in to change notification settings - Fork 8
feat: find 3 nearest coffee shops #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <x coordinate> <y coordinate> | ||
| ``` | ||
|
|
||
| ## 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 | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { retry } from './utils.js'; | ||
|
|
||
| const BASE_URL = 'https://api-challenge.agilefreaks.com/v1'; | ||
|
|
||
| /** | ||
| * Fetch an auth token. | ||
| * | ||
| * @returns {Promise<string>} 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<{id: number, name: string, x: string, y: string, created_at: string, updated_at: string}>>} 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; | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<position>} | ||
| */ | ||
| 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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is the purpose of
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the getNearestShops function returns a promise, I need to use await so I can use the actual result of the function, not the promise itself |
||
| nearestShops.forEach((shop) => { | ||
| console.log(`${shop.name}, ${shop.distance}`); | ||
| }); | ||
| } catch (error) { | ||
| throw new Error('Failed to get nearest shops:', error.message); | ||
| } | ||
|
Comment on lines
+13
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how would you handle error differently here and would you do it at a different level?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe here I could just log the errors that are caught in the lower functions with a console.error() and not throw a new one since main is the top level function. |
||
| } | ||
|
|
||
| main(); | ||
| main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<{id: number, name: string, x: string, y: string, created_at: string, updated_at: string}>>} 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); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this condition necessary here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, i think this is necessary for the case where the input is invalid. For example: yarn start 144. The y coordinate was not provided in this case so the if case would catch it.