Skip to content

Commit 909637f

Browse files
committed
feat: find 3 nearest coffee shops
- Fetch token and coffee shops with exponential backoff - Handle errors - Calculate distance from user to coffe shops with Euclidean formula - Add tests
1 parent 89d286f commit 909637f

File tree

9 files changed

+299
-33
lines changed

9 files changed

+299
-33
lines changed

README.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
1-
# Javascript Interview Starting Point
1+
# Closest coffee shops finder
22

3-
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.
3+
This app finds the 3 closest coffee shops from the user's position.
4+
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:
45

5-
## Steps
6+
```
7+
yarn start <x coordinate> <y coordinate>
8+
```
69

7-
- Fork this repo
8-
- Clone your fork
9-
- Finish the exercise
10-
- Push your best work
10+
## Example input
11+
12+
```
13+
yarn start 47.6 -122.4
14+
```
15+
16+
## Example output
17+
18+
```
19+
Starbucks Seattle2, 0.0645
20+
Starbucks Seattle, 0.0861
21+
Starbucks SF, 10.0793
22+
```
1123

1224
## Commands
1325

1426
```
15-
yarn run start # Run the main script
16-
dev # Start development mode
17-
test # Test the code
18-
````
19-
## Tools
2027
21-
- Write modern JS with [babel/preset-env](https://www.npmjs.com/package/@babel/preset-env)
22-
- Test your code with [jest](https://www.npmjs.com/package/jest)
28+
yarn run start # Run the main script
29+
dev # Start development mode
30+
test # Test the code
31+
32+
```
2333

24-
---
34+
```
2535
26-
Good luck!
36+
```

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,8 @@
2222
"babel-jest": "^30.2.0",
2323
"jest": "^30.2.0",
2424
"nodemon": "^3.1.10"
25+
},
26+
"dependencies": {
27+
"@babel/runtime": "^7.28.4"
2528
}
2629
}

src/api.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { retry } from './utils.js';
2+
3+
const BASE_URL = 'https://api-challenge.agilefreaks.com/v1';
4+
5+
/**
6+
* Fetch an auth token.
7+
*
8+
* @returns {Promise<string>} auth token
9+
*/
10+
export const fetchAuthToken = async () =>
11+
retry(async () => {
12+
const response = await fetch(`${BASE_URL}/tokens`, {
13+
method: 'POST',
14+
});
15+
16+
if (!response.ok) {
17+
throw new Error(
18+
`Could not fetch token from ${response.url}. Response status: ${response.status} ${response.statusText}.`
19+
);
20+
}
21+
22+
const data = await response.json();
23+
const token = data.token;
24+
25+
if (!token) {
26+
throw new Error('Token not found!');
27+
}
28+
29+
return token;
30+
});
31+
32+
/**
33+
* Fetch an array of coffee shops using an auth token.
34+
*
35+
* @param {string} token - auth token
36+
* @returns {Promise<Array<{id: number, name: string, x: string, y: string, created_at: string, updated_at: string}>>} array of coffee shops
37+
*/
38+
export const fetchCoffeeShops = async (token) => {
39+
if (!token) {
40+
throw new Error('A token must be provided!');
41+
}
42+
43+
return retry(async () => {
44+
const response = await fetch(`${BASE_URL}/coffee_shops?token=${token}`);
45+
46+
if (!response.ok) {
47+
throw new Error(
48+
`Could not fetch coffee shops from ${response.url}. Response status: ${response.status} ${response.statusText}.`
49+
);
50+
}
51+
52+
const data = await response.json();
53+
54+
if (!Array.isArray(data) || data.length === 0) {
55+
throw new Error('No coffee shops found!');
56+
}
57+
58+
return data;
59+
});
60+
};

src/app.js

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
1+
import { getDistance, getData, isPositionValid } from './utils.js';
2+
13
/**
4+
* Get the 3 nearest coffee shops from the user's position
5+
*
26
* @param {Object} position
37
* @param {Number} position.x
48
* @param {Number} position.y
5-
*
9+
*
610
* @returns {Array<position>}
711
*/
8-
export function getNearestShops(position) {
9-
// code
10-
11-
return [];
12+
export async function getNearestShops(position) {
13+
if (!isPositionValid(position)) {
14+
throw new Error('x and y coordinates must be prrovided as numbers');
15+
}
16+
17+
const coffeeShops = await getData();
18+
19+
// extend coffee shops with distance from user position
20+
const extendedCoffeeShops = coffeeShops.map((shop) => ({
21+
...shop,
22+
distance: getDistance(position, { x: Number(shop.x), y: Number(shop.y) }),
23+
}));
24+
25+
// sort ascending by distance and return the 3 nearest shops
26+
return extendedCoffeeShops
27+
.sort((a, b) => a.distance - b.distance)
28+
.slice(0, 3);
1229
}

src/index.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import { getNearestShops } from './app.js';
2+
import { isPositionValid } from './utils.js';
23

3-
function main(params) {
4-
const position = {
5-
x: process.argv[2],
6-
y: process.argv[3],
7-
};
4+
async function main() {
5+
const x = Number(process.argv[2]);
6+
const y = Number(process.argv[3]);
7+
const position = { x, y };
88

9-
getNearestShops(position);
9+
if (!isPositionValid(position)) {
10+
throw new Error('x and y coordinates must be prrovided as numbers');
11+
}
12+
13+
try {
14+
const nearestShops = await getNearestShops(position);
15+
nearestShops.forEach((shop) => {
16+
console.log(`${shop.name}, ${shop.distance}`);
17+
});
18+
} catch (error) {
19+
throw new Error('Failed to get nearest shops:', error.message);
20+
}
1021
}
1122

12-
main();
23+
main();

src/utils.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { fetchAuthToken, fetchCoffeeShops } from './api.js';
2+
3+
/**
4+
* Retry callback function with exponential backoff.
5+
* Used when fetching data fails
6+
*
7+
* @param {Function} callback - async function to retry
8+
* @param {number} maxRetries - maximum number of retries (default 3)
9+
* @param {number} initialDelay - initial delay in ms (default 1000)
10+
* @returns {Promise}
11+
*/
12+
export const retry = async (callback, maxRetries = 3, initialDelay = 1000) => {
13+
let lastError;
14+
15+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
16+
try {
17+
return await callback();
18+
} catch (error) {
19+
lastError = error;
20+
21+
if (attempt < maxRetries) {
22+
const delay = initialDelay * Math.pow(2, attempt);
23+
24+
console.error(
25+
`Attempt ${attempt + 1} failed. Retrying in ${
26+
delay / 1000
27+
} seconds...`
28+
);
29+
30+
// wait before next retry
31+
await new Promise((resolve) => setTimeout(resolve, delay));
32+
}
33+
}
34+
}
35+
36+
throw lastError;
37+
};
38+
39+
/**
40+
* Calculate the distance between the user and a coffee shop using the Euclidean distance formula
41+
*
42+
* @param {{x: number, y: number}} userPosition
43+
* @param {{x: number, y: number}} shopPosition
44+
* @returns {number} distance between user and coffee shop rounded to 4 decimals
45+
*/
46+
export const getDistance = (userPosition, shopPosition) => {
47+
const distX = userPosition.x - shopPosition.x;
48+
const distY = userPosition.y - shopPosition.y;
49+
50+
return Math.sqrt(distX * distX + distY * distY).toFixed(4);
51+
};
52+
53+
/**
54+
* Get the coffe shops data
55+
*
56+
* @returns {Promise<Array<{id: number, name: string, x: string, y: string, created_at: string, updated_at: string}>>} array of coffee shops
57+
*/
58+
export const getData = async () => {
59+
const token = await fetchAuthToken();
60+
const coffeeShops = await fetchCoffeeShops(token);
61+
62+
return coffeeShops;
63+
};
64+
65+
/**
66+
* Check if position has x and y coordinates as numbers
67+
*
68+
* @param {{x: number, y: number}} position
69+
*/
70+
export const isPositionValid = (position) => {
71+
const { x, y } = position;
72+
73+
return !isNaN(x) && !isNaN(y);
74+
};

test/app.test.js

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,39 @@
11
import { getNearestShops } from '../src/app';
2+
import { getData } from '../src/utils.js';
3+
4+
jest.mock('../src/utils.js', () => {
5+
const actual = jest.requireActual('../src/utils.js');
6+
return {
7+
...actual,
8+
getData: jest.fn(),
9+
isPositionValid: jest.fn(() => true),
10+
};
11+
});
212

313
describe('App', () => {
4-
it('should return an array when the input is valid', () => {
5-
expect(Array.isArray(getNearestShops({
6-
lat: 0,
7-
lng: 0,
8-
}))).toBe(true);
14+
// mock getData response
15+
getData.mockResolvedValue([
16+
{ id: 1, name: 'shop 1', x: '1', y: '10' },
17+
{ id: 2, name: 'shop 2', x: '254', y: '-13' },
18+
{ id: 3, name: 'shop 3', x: '34.12', y: '546.23' },
19+
{ id: 4, name: 'shop 4', x: '-12.63', y: '3.45' },
20+
{ id: 5, name: 'shop 5', x: '12', y: '-3' },
21+
]);
22+
23+
test('should return an array when the input is valid', async () => {
24+
const result = await getNearestShops({ x: 0, y: 0 });
25+
expect(Array.isArray(result)).toBe(true);
26+
});
27+
28+
test('should return an array of 3 shops', async () => {
29+
const result = await getNearestShops({ x: 123, y: -43 });
30+
expect(result).toHaveLength(3);
31+
});
32+
33+
test('should return shop 1, shop 2, shop 5', async () => {
34+
const result = await getNearestShops({ x: 1, y: 1 });
35+
expect(result[0].name).toBe('shop 1');
36+
expect(result[1].name).toBe('shop 5');
37+
expect(result[2].name).toBe('shop 4');
938
});
1039
});

test/utils.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { getDistance, isPositionValid, retry } from '../src/utils.js';
2+
3+
describe('Utils', () => {
4+
// isPositionValid
5+
test('isPositionValid returns true for valid numbers', () => {
6+
expect(isPositionValid({ x: 23, y: 44 })).toBe(true);
7+
});
8+
9+
test('isPositionValid returns false for invalid x coordinate', () => {
10+
expect(isPositionValid({ x: 'dqwef', y: 2 })).toBe(false);
11+
});
12+
13+
test('isPositionValid returns false for invalid y coordinate', () => {
14+
expect(isPositionValid({ x: 52, y: 'cdd6dc' })).toBe(false);
15+
});
16+
17+
test('isPositionValid returns false for invalid x and y', () => {
18+
expect(isPositionValid({ x: '2sd3gr', y: 'cdddc' })).toBe(false);
19+
});
20+
21+
// getDistance
22+
test('getDistance returns correct distance with 4 decimals', () => {
23+
const result = getDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
24+
expect(result).toBe('5.0000');
25+
});
26+
27+
// retry
28+
test('retry returns result on first successful try', async () => {
29+
const callback = jest.fn().mockResolvedValue('success');
30+
const result = await retry(callback);
31+
32+
expect(result).toBe('success');
33+
expect(callback).toHaveBeenCalledTimes(1);
34+
});
35+
36+
test('retry retries until success', async () => {
37+
const callback = jest
38+
.fn()
39+
.mockRejectedValueOnce(new Error('fail 1'))
40+
.mockRejectedValueOnce(new Error('fail 2'))
41+
.mockResolvedValueOnce('success');
42+
const result = await retry(callback, 3, 20);
43+
44+
expect(result).toBe('success');
45+
expect(callback).toHaveBeenCalledTimes(3);
46+
});
47+
48+
test('retry throws error after max retries', async () => {
49+
const callback = jest
50+
.fn()
51+
.mockRejectedValue(new Error('failed more than max retries'));
52+
53+
await expect(retry(callback, 3, 20)).rejects.toThrow(
54+
'failed more than max retries'
55+
);
56+
});
57+
});

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,11 @@
878878
"@babel/types" "^7.4.4"
879879
esutils "^2.0.2"
880880

881+
"@babel/runtime@^7.28.4":
882+
version "7.28.4"
883+
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
884+
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
885+
881886
"@babel/template@^7.27.1", "@babel/template@^7.27.2":
882887
version "7.27.2"
883888
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"

0 commit comments

Comments
 (0)