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
40 changes: 22 additions & 18 deletions README.md
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
```
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@
"babel-jest": "^30.2.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10"
},
"dependencies": {
"@babel/runtime": "^7.28.4"
}
}
60 changes: 60 additions & 0 deletions src/api.js
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;
});
};
27 changes: 22 additions & 5 deletions src/app.js
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);
}
25 changes: 18 additions & 7 deletions src/index.js
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);
nearestShops.forEach((shop) => {
console.log(`${shop.name}, ${shop.distance}`);
});
} catch (error) {
throw new Error('Failed to get nearest shops:', error.message);
}
}

main();
main();
74 changes: 74 additions & 0 deletions src/utils.js
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);
};
39 changes: 34 additions & 5 deletions test/app.test.js
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');
});
});
57 changes: 57 additions & 0 deletions test/utils.test.js
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'
);
});
});
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down