commit
94b504b854
@ -0,0 +1,11 @@ |
||||
module.exports = { |
||||
"extends": "standard", |
||||
"overrides": [ |
||||
{ |
||||
files: "*.test.js", |
||||
rules: { |
||||
"no-unused-expressions": "off" |
||||
} |
||||
} |
||||
] |
||||
}; |
@ -0,0 +1,73 @@ |
||||
# Logs |
||||
logs |
||||
*.log |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
|
||||
# Runtime data |
||||
pids |
||||
*.pid |
||||
*.seed |
||||
*.pid.lock |
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover |
||||
lib-cov |
||||
|
||||
# Coverage directory used by tools like istanbul |
||||
coverage |
||||
|
||||
# nyc test coverage |
||||
.nyc_output |
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) |
||||
.grunt |
||||
|
||||
# Bower dependency directory (https://bower.io/) |
||||
bower_components |
||||
|
||||
# node-waf configuration |
||||
.lock-wscript |
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html) |
||||
build/Release |
||||
|
||||
# Dependency directories |
||||
node_modules/ |
||||
jspm_packages/ |
||||
|
||||
# TypeScript v1 declaration files |
||||
typings/ |
||||
|
||||
# Optional npm cache directory |
||||
.npm |
||||
|
||||
# Optional eslint cache |
||||
.eslintcache |
||||
|
||||
# Optional REPL history |
||||
.node_repl_history |
||||
|
||||
# Output of 'npm pack' |
||||
*.tgz |
||||
|
||||
# Yarn Integrity file |
||||
.yarn-integrity |
||||
|
||||
# dotenv environment variables file |
||||
.env |
||||
|
||||
# parcel-bundler cache (https://parceljs.org/) |
||||
.cache |
||||
|
||||
# next.js build output |
||||
.next |
||||
|
||||
# nuxt.js build output |
||||
.nuxt |
||||
|
||||
# vuepress build output |
||||
.vuepress/dist |
||||
|
||||
# Serverless directories |
||||
.serverless |
@ -0,0 +1,25 @@ |
||||
#!/usr/bin/env node
|
||||
|
||||
const program = require('commander') |
||||
const CustomerFinder = require('./lib/CustomerFinder.js') |
||||
const CustomerListFromURL = require('./lib/CustomerListFromURL.js') |
||||
|
||||
program |
||||
.version('0.0.1') |
||||
.option('--customers [url]', 'URL for customers list [by default uses the URL provided in email ;)].', 'https://s3.amazonaws.com/intercom-take-home-test/customers.txt') |
||||
.option('--home [home]', '"Home" location - distances to customers will be calculated against it [Default is [-6.257664, 53.339428]].', [-6.257664, 53.339428]) |
||||
.option('--no-orm', 'Disables fetching distance via OpenRouteMap.') |
||||
.option('--direct-routes', 'Fall back to simple calculation if ORM tells there are no routes.') |
||||
.option('-d, --distance [meters]', 'Maximum exclusive distance in meters [100000]', 100000) |
||||
.parse(process.argv) |
||||
|
||||
const customerListFromURL = new CustomerListFromURL(program.customers) |
||||
const customerFinder = new CustomerFinder(customerListFromURL, program.home, { noORM: !program.orm, fallbackOnInfinity: program.directRoutes }) |
||||
|
||||
console.log('Processing...') |
||||
customerFinder.findNearestCustomers(program.distance).then(customers => { |
||||
console.log('Fitting candidates for invitation:') |
||||
customers.forEach(customer => { |
||||
console.log('- ' + customer.name) |
||||
}) |
||||
}) |
@ -0,0 +1,58 @@ |
||||
const orm = new (require('./OpenRouteMapDistanceResolver.js'))() |
||||
const sd = new (require('./SimpleDistanceResolver.js'))() |
||||
|
||||
class CustomerFinder { |
||||
/** |
||||
* @param {Type of customerListProvider} customerListProvider - provider that would give list of customers |
||||
* @param {Type of homeLocation} homeLocation - the location where event would take place |
||||
* @param {Object} [options] - optional arguments |
||||
* @param {Boolean} [options.noORM] - disable fetching distance via ORM |
||||
* @param {Boolean} [options.fallbackOnInfinity] - disable falling back to simple calculation in case ORM couldn't find routes |
||||
*/ |
||||
constructor (customerListProvider, homeLocation, options) { |
||||
this._customers = customerListProvider.getList() |
||||
this._home = homeLocation |
||||
options = options || {} |
||||
this._noORM = options.noORM || false |
||||
this._fallbackOnInfinity = options.fallbackOnInfinity || true |
||||
} |
||||
|
||||
/** |
||||
* Asynchronously fetches the distance between "home" and target customer location |
||||
* unless noORM is true, it tries to fetch route length from ORM and falls back to |
||||
* simple greater-circle length calculation |
||||
* @param {Customer} customer - customer from data |
||||
* @param {Boolean} noORM - disable fetching from ORM |
||||
* @returns {Promise<Number>} Promise of a distance in meters |
||||
*/ |
||||
_getCustomerDistance (customer, noORM) { |
||||
const dr = noORM ? sd : orm |
||||
return dr.getDistance(this._home, [customer.longitude, customer.latitude]) |
||||
.then(distance => (distance === Number.POSITIVE_INFINITY && this._fallbackOnInfinity) |
||||
? this._getCustomerDistance(customer, true) |
||||
: distance) |
||||
// probably should fall back for all following queries depending on response
|
||||
.catch(e => console.error(`Failed fetching length via ${dr.constructor.name}: ${e.message}, falling back... to simple calculation`) || |
||||
this._getCustomerDistance(customer, true)) |
||||
} |
||||
|
||||
/** |
||||
* Returns the list of customers within given limit |
||||
* @param {Number} distanceLimit - maximum (exclusive) distance between event location and customer location |
||||
* @returns {Promise<Customer[]>} filtered list of customers that match the distance criteria |
||||
*/ |
||||
findNearestCustomers (distanceLimit) { |
||||
return this._customers |
||||
.then(customers => customers |
||||
.map(async customer => ((await this._getCustomerDistance(customer, this._noORM) < distanceLimit)) ? customer : undefined) |
||||
) |
||||
.then(promises => Promise.all(promises)) |
||||
.then(customers => customers.filter(_ => _)) |
||||
} |
||||
} |
||||
|
||||
// just a test
|
||||
// (new CustomerFinder(new (require('./CustomerListFromURL.js'))('https://s3.amazonaws.com/intercom-take-home-test/customers.txt'), [-6.257664, 53.339428], { noORM: false })).findNearestCustomers(100 * 1000).then(_ => console.log(_.length))
|
||||
// this is what would be happening.
|
||||
|
||||
module.exports = CustomerFinder |
@ -0,0 +1,32 @@ |
||||
const rp = require('request-promise-native') |
||||
|
||||
class CustomerListFromURL { |
||||
constructor (url) { |
||||
this._url = url |
||||
} |
||||
|
||||
/** |
||||
* internal function that actually fetches data |
||||
* @returns {Promise<Response>} request-promise promise |
||||
*/ |
||||
_fetch () { |
||||
return rp(this._url) |
||||
} |
||||
|
||||
/** |
||||
* fetches list and processes it into usable JS array |
||||
* @returns {Customer[]} list of customers |
||||
*/ |
||||
getList () { |
||||
return this._fetch() |
||||
// The data in supplied URL isn't exactly JSON array, instead a list of JSON objects separated by newlines.
|
||||
.then((response) => response |
||||
.split('\n') // splitting by newlines
|
||||
.map(_ => _.trim()) // trimming strings
|
||||
.filter(_ => _) // filter out falsy items (undefineds, empty strings)
|
||||
.map(_ => JSON.parse(_)) |
||||
) |
||||
} |
||||
} |
||||
|
||||
module.exports = CustomerListFromURL |
@ -0,0 +1,27 @@ |
||||
require('chai').should() |
||||
describe('CustomerListFromURL', function () { |
||||
let customerListFromURL |
||||
|
||||
beforeEach(function () { |
||||
customerListFromURL = new (require('./CustomerListFromURL.js'))('') |
||||
// We mock the actual fetching of data.
|
||||
customerListFromURL._fetch = () => Promise.resolve(` |
||||
{"latitude": "52.986375", "user_id": 12, "name": "Christina McArdle", "longitude": "-6.043701"} |
||||
{"latitude": "51.92893", "user_id": 1, "name": "Alice Cahill", "longitude": "-10.27699"} |
||||
{"latitude": "51.8856167", "user_id": 2, "name": "Ian McArdle", "longitude": "-10.4240951"} |
||||
{"latitude": "52.3191841", "user_id": 3, "name": "Jack Enright", "longitude": "-8.5072391"} |
||||
`)
|
||||
}) |
||||
|
||||
it('getList should correctly parse the input', async function () { |
||||
const result = await customerListFromURL.getList() |
||||
result.should.be.an('array') |
||||
result.should.be.have.lengthOf(4) |
||||
result.should.have.deep.members([ |
||||
{latitude: '52.986375', user_id: 12, name: 'Christina McArdle', longitude: '-6.043701'}, |
||||
{latitude: '51.92893', user_id: 1, name: 'Alice Cahill', longitude: '-10.27699'}, |
||||
{latitude: '51.8856167', user_id: 2, name: 'Ian McArdle', longitude: '-10.4240951'}, |
||||
{latitude: '52.3191841', user_id: 3, name: 'Jack Enright', longitude: '-8.5072391'} |
||||
]) |
||||
}) |
||||
}) |
@ -0,0 +1,22 @@ |
||||
class DistanceResolver { |
||||
getDistance (A, B) { |
||||
A = this._sanitizeCoords(A) |
||||
B = this._sanitizeCoords(B) |
||||
} |
||||
|
||||
_sanitizeCoords (coords) { |
||||
if (!Array.isArray(coords)) throw new Error('Coordinates are not an array!') |
||||
if (coords.length !== 2) throw new Error('Coordinates are not in form of [lon, lat] array!') |
||||
let lon = coords[0] |
||||
let lat = coords[1] |
||||
if (typeof lon !== 'number') lon = Number(lon) |
||||
if (typeof lat !== 'number') lat = Number(lat) |
||||
if (Number.isNaN(lon)) throw new Error('Invalid longitude!') |
||||
if (Number.isNaN(lat)) throw new Error('Invalid latitude!') |
||||
if (Math.abs(lat) > 90) throw new Error('Latitude must be within [-90;90] degrees') |
||||
if (Math.abs(lon) > 180) throw new Error('Longitude must be within [-180;180] degrees') |
||||
return [lon, lat] |
||||
} |
||||
} |
||||
|
||||
module.exports = DistanceResolver |
@ -0,0 +1,26 @@ |
||||
require('chai').should() |
||||
describe('DistanceResolver', function () { |
||||
const dr = new (require('./DistanceResolver.js'))() |
||||
describe('getDistance should throw exception when', function () { |
||||
it('longitude is out of bounds', function () { |
||||
(() => dr.getDistance([0, 0], [181, 0])).should.throw() |
||||
}) |
||||
it('latitude is out of bounds', function () { |
||||
(() => dr.getDistance([0, 0], [180, -91])).should.throw() |
||||
}) |
||||
it('invalid strings', function () { |
||||
(() => dr.getDistance(['one hundred', 0], [0, 0])).should.throw() |
||||
}) |
||||
it('invalid arrays', function () { |
||||
(() => dr.getDistance([0, 0, 0], [0, 0])).should.throw() |
||||
}) |
||||
it('invalid input', function () { |
||||
(() => dr.getDistance('An array', [0, 0])).should.throw() |
||||
}) |
||||
}) |
||||
|
||||
it('_sanitizeCoords should convert string numbers to numbers', function () { |
||||
(() => dr._sanitizeCoords(['1', '-3'])).should.not.throw() |
||||
dr._sanitizeCoords(['1', '-3']).should.have.deep.members([1, -3]) |
||||
}) |
||||
}) |
@ -0,0 +1,33 @@ |
||||
const DistanceResovler = require('./DistanceResolver.js') |
||||
|
||||
/* |
||||
* This is a free key with 2500 requests per day restriction. Ideally this would |
||||
* have been stored in a separate location (not stored under VCS) like in CI |
||||
* deployment secrets storage. For simplicity's sake I left it here so that |
||||
* code will actually work. |
||||
*/ |
||||
const apiKey = '58d904a497c67e00015b45fc711d2a70bece433faf282bd510515061' |
||||
const rp = require('request-promise-native') |
||||
|
||||
class OpenRouteMapDistanceResolver extends DistanceResovler { |
||||
/** |
||||
* Gets (route) distance between point A and point B |
||||
* @param {Number[]} A - array [longitude, latitude] |
||||
* @param {Number[]} B - array [longitude, latitude] |
||||
* @returns {Return Promise<Number>} distance between points in meters |
||||
*/ |
||||
getDistance (A, B) { |
||||
// profile profile drive-car gives "can't find point" for some locations, i suppose the ones that are not reachable via car
|
||||
return rp(`https://api.openrouteservice.org/directions?api_key=${apiKey}&coordinates=${A.join(',')}%7C${B.join(',')}&profile=foot-walking`) |
||||
.then(JSON.parse) |
||||
// in case there are no routes between points assume there's really no route.
|
||||
// i'm not sure if that's how it works tho
|
||||
.then(response => response.routes.length === 0 ? Number.POSITIVE_INFINITY : response.routes[0].summary.distance) |
||||
} |
||||
} |
||||
|
||||
// Just a test
|
||||
// (new OpenRouteMapDistanceResolver()).getDistance([8.685755, 49.393505], [8.686141, 49.389643]).then(console.log)
|
||||
// Should be around 430
|
||||
|
||||
module.exports = OpenRouteMapDistanceResolver |
@ -0,0 +1,28 @@ |
||||
const DistanceResovler = require('./DistanceResolver.js') |
||||
|
||||
class SimpleDistanceResolver extends DistanceResovler { |
||||
/** |
||||
* Gets (great-circle) distance between point A and point B |
||||
* @param {Number[]} A - array [longitude, latitude] |
||||
* @param {Number[]} B - array [longitude, latitude] |
||||
* @returns {Return Promise<Number>} distance between points in meters |
||||
*/ |
||||
getDistance (A, B) { |
||||
super.getDistance(A, B) |
||||
const radConv = Math.PI / 180 |
||||
const earthRadius = 6371 * 1000 // kilometers to meters
|
||||
|
||||
const lonA = A[0] * radConv |
||||
const lonB = B[0] * radConv |
||||
const latA = A[1] * radConv |
||||
const latB = B[1] * radConv |
||||
|
||||
const lonD = Math.abs(lonA - lonB) |
||||
|
||||
const absAngle = Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonD)) |
||||
|
||||
return Promise.resolve(absAngle * earthRadius) |
||||
} |
||||
} |
||||
|
||||
module.exports = SimpleDistanceResolver |
@ -0,0 +1,28 @@ |
||||
require('chai').should() |
||||
describe('SimpleDistanceResolver', function () { |
||||
const simpleDistanceResolver = new (require('./SimpleDistanceResolver.js'))() |
||||
|
||||
describe('getDistance', async function () { |
||||
it('short distance', async function () { |
||||
// Some points on straight road and distance provided by OpenRouteService
|
||||
// so output of our function should relatively match the expectations
|
||||
const A = [8.685755, 49.393505] |
||||
const B = [8.686141, 49.389643] |
||||
const realDist = 432 |
||||
|
||||
const distance = await simpleDistanceResolver.getDistance(A, B) |
||||
distance.should.be.closeTo(realDist, 5) |
||||
}) |
||||
|
||||
it('long distance', async function () { |
||||
// Some points on some very long straight road in USA and distance provided by OpenRouteService
|
||||
// so output of our function should relatively match the expectations
|
||||
const A = [-99.358635, 35.84109] |
||||
const B = [-99.35885, 35.725646] |
||||
const realDist = 12834 |
||||
|
||||
const distance = await simpleDistanceResolver.getDistance(A, B) |
||||
distance.should.be.closeTo(realDist, 5) |
||||
}) |
||||
}) |
||||
}) |
La diferencia del archivo ha sido suprimido porque es demasiado grande
Cargar Diff
@ -0,0 +1,26 @@ |
||||
{ |
||||
"name": "partymaker", |
||||
"version": "0.0.1", |
||||
"description": "Test exercise for Intercom", |
||||
"main": "index.js", |
||||
"scripts": { |
||||
"test": "mocha --recursive lib/**/*.test.js" |
||||
}, |
||||
"author": "Roman Kostetskiy", |
||||
"license": "GPL-3.0-or-later", |
||||
"dependencies": { |
||||
"commander": "^2.16.0", |
||||
"request": "^2.87.0", |
||||
"request-promise-native": "^1.0.5" |
||||
}, |
||||
"devDependencies": { |
||||
"chai": "^4.1.2", |
||||
"eslint": "^5.1.0", |
||||
"eslint-config-standard": "^11.0.0", |
||||
"eslint-plugin-import": "^2.13.0", |
||||
"eslint-plugin-node": "^7.0.1", |
||||
"eslint-plugin-promise": "^3.8.0", |
||||
"eslint-plugin-standard": "^3.1.0", |
||||
"mocha": "^5.2.0" |
||||
} |
||||
} |
Cargando…
Referencia en una nueva incidencia