├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── __mocks__ ├── @google │ └── maps.js ├── convert-units.js └── uber-estimates-client.js ├── commitlint.config.js ├── package-lock.json ├── package.json └── src ├── data ├── DistanceUnit.js └── TimeUnit.js ├── executables ├── uber-price.js ├── uber-time.js └── uber.js ├── index.js └── services ├── AddressLocator.js ├── AddressLocator.test.js ├── UberService.js ├── UberService.test.js ├── __mocks__ └── AddressLocator.js ├── converters.js ├── converters.test.js ├── formatters.js ├── formatters.test.js ├── symbols.emojis.test.js ├── symbols.js ├── symbols.text.test.js └── tables ├── price ├── build.js └── build.test.js └── time ├── build.js └── build.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "env": { 6 | "production": { 7 | "presets": [ 8 | "minify" 9 | ] 10 | } 11 | }, 12 | "plugins": [ 13 | "@babel/plugin-transform-runtime" 14 | ], 15 | "ignore": [ 16 | "node_modules", 17 | "*.test.js" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/* 2 | build 3 | node_modules 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "jest": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | build/ 61 | 62 | .DS_Store 63 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | 3 | src/** 4 | test/** 5 | coverage/** 6 | 7 | npm-debug.log 8 | commitlint.config.js 9 | *.test.js 10 | **/__mocks__/** 11 | 12 | .DS_Store 13 | .eslintcache 14 | .travis.yml 15 | .babelrc 16 | .eslintignore 17 | .eslintrc 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - ~/.npm 5 | notifications: 6 | email: true 7 | node_js: 8 | - 'node' 9 | - 'lts/*' 10 | install: npm install 11 | before_install: 12 | - npm install -g greenkeeper-lockfile@1 13 | jobs: 14 | include: 15 | - stage: test 16 | script: 17 | - npm run build:prod 18 | - npm run lint 19 | - npm run test 20 | before_script: greenkeeper-lockfile-update 21 | after_script: greenkeeper-lockfile-upload 22 | after_success: npm run codecov 23 | - stage: deploy 24 | if: branch = master 25 | script: npm run travis-deploy-once "npm run semantic-release" 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uber CLI 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/jaebradley/uber-cli.svg)](https://greenkeeper.io/) 4 | [![Build Status](https://travis-ci.org/jaebradley/uber-cli.svg?branch=master)](https://travis-ci.org/jaebradley/uber-cli) 5 | [![codecov](https://codecov.io/gh/jaebradley/uber-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/jaebradley/uber-cli) 6 | [![npm](https://img.shields.io/npm/v/uber-cli.svg)](https://www.npmjs.com/package/uber-cli) 7 | [![npm](https://img.shields.io/npm/dt/uber-cli.svg)](https://www.npmjs.com/package/uber-cli) 8 | 9 | ## Introduction 10 | 11 | Clearly, I'm a lazy person (just look at what this tool does - it helps me 12 | figure out if I should order *a car to pick me up and drive me to where I want to go*). 13 | 14 | That being said, as a lazy person it pains me everytime open my phone, 15 | open the Uber app, type my destination, and see the estimated price, only for 16 | my inner, responsible, cost-cutting, fiduciary-self to end up taking the bus 17 | all the way home. 18 | 19 | I think we can all agree that it would be much more efficient to simply be disappointed 20 | before I open my phone at all. 21 | 22 | ## Install via NPM 23 | 24 | ```bash 25 | npm install uber-cli -g 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### Get Time-To-Pickup Estimates 31 | 32 | ```bash 33 | uber time 'pickup address here' 34 | ``` 35 | 36 | ![alt_text](http://imgur.com/9k16YDl.png) 37 | 38 | ### Get Price Estimates 39 | 40 | ```bash 41 | uber price -s 'start address' -e 'end address' 42 | ``` 43 | 44 | ![alt_text](http://imgur.com/2QLJCSw.png) 45 | 46 | ## A Note On Address Identification 47 | 48 | So the [Uber API identifies time](https://developer.uber.com/docs/riders/references/api/v1.2/estimates-time-get) and price estimates based on a coordinate and not an address. In order to support those 49 | that don't know their exact coordinates at any given time, I'm using the [Google Maps Geocoding API](https://developers.google.com/maps/documentation/geocoding/intro) to identify coordinates based on an input address. 50 | -------------------------------------------------------------------------------- /__mocks__/@google/maps.js: -------------------------------------------------------------------------------- 1 | const geocode = jest.fn(({ address }) => ({ 2 | asPromise: () => { 3 | if (address === 'jaebaebae') { 4 | return Promise.resolve({ 5 | json: { 6 | results: [{ 7 | formatted_address: 'formatted address', 8 | geometry: { 9 | location: { 10 | lat: 'latitude', 11 | lng: 'longitude', 12 | }, 13 | }, 14 | }], 15 | }, 16 | }); 17 | } 18 | 19 | return Promise.resolve({ json: { results: [] } }); 20 | }, 21 | })); 22 | 23 | const createClient = jest.fn(() => ({ geocode })); 24 | 25 | export default { createClient }; 26 | export { geocode }; 27 | -------------------------------------------------------------------------------- /__mocks__/convert-units.js: -------------------------------------------------------------------------------- 1 | const to = jest.fn(() => 1234); 2 | 3 | const from = jest.fn(() => ({ to })); 4 | 5 | const constructor = jest.fn(() => ({ from })); 6 | 7 | const convert = constructor; 8 | 9 | export default convert; 10 | 11 | export { 12 | to, 13 | from, 14 | }; 15 | -------------------------------------------------------------------------------- /__mocks__/uber-estimates-client.js: -------------------------------------------------------------------------------- 1 | const getArrivalTimes = jest.fn(() => ({ 2 | times: [ 3 | { 4 | localized_display_name: 'first localized display name', 5 | estimate: 'first estimate', 6 | }, 7 | { 8 | localized_display_name: 'second localized display name', 9 | estimate: 'second estimate', 10 | }, 11 | ], 12 | })); 13 | 14 | const getPrices = jest.fn(() => ({ 15 | prices: [ 16 | { 17 | localized_display_name: 'first localized display name', 18 | distance: 'first distance', 19 | duration: 'first duration', 20 | high_estimate: 'first high estimate', 21 | low_estimate: 'first low estimate', 22 | currency_code: 'first currency code', 23 | surgeMultiplier: undefined, 24 | }, 25 | { 26 | localized_display_name: 'second localized display name', 27 | distance: 'second distance', 28 | duration: 'second duration', 29 | high_estimate: 'second high estimate', 30 | low_estimate: 'second low estimate', 31 | currency_code: 'second currency code', 32 | surgeMultiplier: 'surgeMultiplier', 33 | }, 34 | ], 35 | })); 36 | 37 | const constructor = jest.fn(() => ({ 38 | getArrivalTimes, 39 | getPrices, 40 | })); 41 | 42 | const UberEstimatesClient = constructor; 43 | 44 | export default UberEstimatesClient; 45 | export { 46 | getArrivalTimes, 47 | getPrices, 48 | }; 49 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-angular'] }; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uber-cli", 3 | "description": "CLI for Uber price and time estimates", 4 | "version": "0.0.0-development", 5 | "author": "Jae Bradley", 6 | "bin": { 7 | "uber": "build/executables/uber.js" 8 | }, 9 | "dependencies": { 10 | "@babel/runtime": "^7.0.0-beta.56", 11 | "@google/maps": "^0.5.5", 12 | "cli-table2": "^0.2.0", 13 | "commander": "^2.17.0", 14 | "convert-units": "^2.3.4", 15 | "uber-estimates-client": "^2.0.0" 16 | }, 17 | "devDependencies": { 18 | "@babel/cli": "^7.0.0-beta.56", 19 | "@babel/core": "^7.0.0-beta.56", 20 | "@babel/plugin-transform-async-to-generator": "^7.0.0-beta.56", 21 | "@babel/plugin-transform-runtime": "^7.0.0-beta.56", 22 | "@babel/preset-env": "^7.0.0-beta.56", 23 | "@commitlint/cli": "^7.0.0", 24 | "@commitlint/config-angular": "^7.0.1", 25 | "@commitlint/prompt": "^7.0.0", 26 | "@commitlint/prompt-cli": "^7.0.0", 27 | "ajv": "^5.5.2", 28 | "babel-core": "^7.0.0-bridge.0", 29 | "babel-jest": "^23.4.2", 30 | "babel-preset-minify": "^0.4.3", 31 | "codecov": "^3.0.4", 32 | "eslint": "^4.19.1", 33 | "eslint-config-airbnb-base": "^13.0.0", 34 | "eslint-plugin-import": "^2.13.0", 35 | "husky": "^0.14.3", 36 | "jest": "^23.4.2", 37 | "semantic-release": "^15.9.5", 38 | "travis-deploy-once": "^5.0.2" 39 | }, 40 | "homepage": "https://github.com/jaebradley/uber-cli", 41 | "keywords": [ 42 | "uber", 43 | "uber cli", 44 | "uber price", 45 | "uber time" 46 | ], 47 | "jest": { 48 | "testEnvironment": "node", 49 | "testPathIgnorePatterns": [ 50 | "/build/", 51 | "/node_modules/" 52 | ], 53 | "collectCoverage": true 54 | }, 55 | "license": "MIT", 56 | "main": "./build/executables/uber.js", 57 | "preferGlobal": true, 58 | "repository": { 59 | "type": "git", 60 | "url": "https://github.com/jaebradley/uber-cli/tree/master" 61 | }, 62 | "scripts": { 63 | "codecov": "codecov", 64 | "commitmsg": "commitlint -e $GIT_PARAMS", 65 | "build": "babel src/ -d build/ --delete-dir-on-start", 66 | "build:prod": "BABEL_ENV=production npm run build", 67 | "lint": "eslint --ext .js .", 68 | "test": "jest", 69 | "prepublishOnly": "npm run build:prod", 70 | "gc": "commit", 71 | "semantic-release": "semantic-release", 72 | "travis-deploy-once": "travis-deploy-once" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/data/DistanceUnit.js: -------------------------------------------------------------------------------- 1 | const DistanceUnit = Object.freeze({ 2 | MILE: 'MILE', 3 | KILOMETER: 'KILOMETER', 4 | }); 5 | 6 | export default DistanceUnit; 7 | -------------------------------------------------------------------------------- /src/data/TimeUnit.js: -------------------------------------------------------------------------------- 1 | const TimeUnit = Object.freeze({ 2 | SECOND: 'SECOND', 3 | MINUTE: 'MINUTE', 4 | }); 5 | 6 | export default TimeUnit; 7 | -------------------------------------------------------------------------------- /src/executables/uber-price.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable no-console */ 4 | 5 | import program from 'commander'; 6 | 7 | import { buildPriceEstimates } from '..'; 8 | 9 | program 10 | .option('-s, --start ', 'specify start address') 11 | .option('-e, --end ', 'specify end address') 12 | .option('-u, --unit [unit]', 'specify distance unit') 13 | .parse(process.argv); 14 | 15 | const { start, end, unit } = program; 16 | 17 | buildPriceEstimates({ startAddress: start, endAddress: end, distanceUnitName: unit }) 18 | .catch(e => console.error('Could not get price estimates:\n', e)); 19 | -------------------------------------------------------------------------------- /src/executables/uber-time.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable no-console */ 4 | 5 | import program from 'commander'; 6 | import { buildTimeEstimates } from '..'; 7 | 8 | program 9 | .description('Get Time-To-Pickup Estimates') 10 | .arguments('
') 11 | .action((address) => { 12 | buildTimeEstimates(address) 13 | .catch(e => console.error('Could not get time estimates:\n', e)); 14 | }) 15 | .parse(process.argv); 16 | -------------------------------------------------------------------------------- /src/executables/uber.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import program from 'commander'; 4 | 5 | import pkg from '../../package.json'; 6 | 7 | program.version(pkg.version) 8 | .description('Figure out if you should order a car to pick you up and drive you to where you want to go') 9 | .command('price', 'get price estimate') 10 | .command('time', 'get time to pickup estimate') 11 | .parse(process.argv); 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import DistanceUnit from './data/DistanceUnit'; 2 | import UberService from './services/UberService'; 3 | import buildPriceEstimatesTable from './services/tables/price/build'; 4 | import buildTimeEstimatesTable from './services/tables/time/build'; 5 | 6 | const buildPriceEstimates = async ({ startAddress, endAddress, distanceUnitName }) => { 7 | if (typeof startAddress !== 'string' || typeof endAddress !== 'string') { 8 | throw new TypeError('Start and End addresses (-s \'
\' -e \'
\') are required.'); 9 | } 10 | 11 | const distanceUnit = distanceUnitName 12 | ? DistanceUnit[distanceUnitName.toUpperCase()] 13 | : DistanceUnit.MILE; 14 | const uberService = new UberService(); 15 | const estimates = await uberService.getPriceEstimates({ startAddress, endAddress }); 16 | console.log(buildPriceEstimatesTable({ estimates, presentationUnits: distanceUnit })); 17 | }; 18 | 19 | const buildTimeEstimates = async (address) => { 20 | if (typeof address !== 'string') { 21 | throw new TypeError('Address should be a string'); 22 | } 23 | 24 | const uberService = new UberService(); 25 | const estimates = await uberService.getTimeEstimates(address); 26 | console.log(buildTimeEstimatesTable({ 27 | estimates: estimates.estimates, 28 | location: estimates.location, 29 | })); 30 | }; 31 | 32 | export { 33 | buildPriceEstimates, 34 | buildTimeEstimates, 35 | }; 36 | -------------------------------------------------------------------------------- /src/services/AddressLocator.js: -------------------------------------------------------------------------------- 1 | import GoogleMapsClient from '@google/maps'; 2 | 3 | class AddressLocator { 4 | constructor() { 5 | this.googleMapsClient = GoogleMapsClient.createClient({ 6 | key: 'AIzaSyBfyXZ3kDp03V_o7_mak0wxVU4B2Zcl0Ak', 7 | Promise, 8 | }); 9 | } 10 | 11 | async getFirstLocation(address) { 12 | const { json } = await this.googleMapsClient.geocode({ address }).asPromise(); 13 | const { results: locations } = json; 14 | 15 | if (locations.length > 0) { 16 | const location = locations[0]; 17 | return { 18 | name: location.formatted_address, 19 | coordinate: { 20 | latitude: location.geometry.location.lat, 21 | longitude: location.geometry.location.lng, 22 | }, 23 | }; 24 | } 25 | 26 | throw new RangeError(`No locations for address: ${address}`); 27 | } 28 | } 29 | 30 | export default AddressLocator; 31 | -------------------------------------------------------------------------------- /src/services/AddressLocator.test.js: -------------------------------------------------------------------------------- 1 | import { geocode } from '@google/maps'; 2 | 3 | import AddressLocator from './AddressLocator'; 4 | 5 | jest.mock('@google/maps'); 6 | 7 | describe('AddressLocator', () => { 8 | beforeEach(() => { 9 | geocode.mockClear(); 10 | }); 11 | 12 | describe('#getFirstLocation', () => { 13 | it('gets first address', async () => { 14 | const address = 'jaebaebae'; 15 | const locator = new AddressLocator(); 16 | const firstLocation = await locator.getFirstLocation(address); 17 | expect(firstLocation.name).toEqual('formatted address'); 18 | expect(firstLocation.coordinate.latitude).toEqual('latitude'); 19 | expect(firstLocation.coordinate.longitude).toEqual('longitude'); 20 | expect(geocode).toHaveBeenCalledTimes(1); 21 | expect(geocode).toHaveBeenCalledWith({ address }); 22 | }); 23 | 24 | it('throws RangeError when no addresses are found', async () => { 25 | const address = 'address'; 26 | const locator = new AddressLocator(); 27 | try { 28 | await locator.getFirstLocation(address); 29 | } catch (e) { 30 | expect(e).toBeInstanceOf(RangeError); 31 | } 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/services/UberService.js: -------------------------------------------------------------------------------- 1 | import UberEstimatesClient from 'uber-estimates-client'; 2 | 3 | import AddressLocator from './AddressLocator'; 4 | import TimeUnit from '../data/TimeUnit'; 5 | import DistanceUnit from '../data/DistanceUnit'; 6 | 7 | export default class UberService { 8 | constructor() { 9 | this.uberEstimatesClient = new UberEstimatesClient({ serverToken: 'We0MNCaIpx00F_TUopt4jgL9BzW3bWWt16aYM4mh' }); 10 | this.addressLocator = new AddressLocator(); 11 | } 12 | 13 | async getTimeEstimates(address) { 14 | const location = await this.addressLocator.getFirstLocation(address); 15 | const timeEstimates = await this.uberEstimatesClient.getArrivalTimes({ 16 | start: location.coordinate, 17 | }); 18 | return { 19 | location, 20 | estimates: timeEstimates.times.map(estimate => ({ 21 | productName: estimate.localized_display_name, 22 | estimatedDuration: { 23 | length: estimate.estimate, 24 | unit: TimeUnit.SECOND, 25 | }, 26 | })), 27 | }; 28 | } 29 | 30 | async getPriceEstimates({ startAddress, endAddress }) { 31 | const [start, end] = await Promise.all([ 32 | this.addressLocator.getFirstLocation(startAddress), 33 | this.addressLocator.getFirstLocation(endAddress), 34 | ]); 35 | const estimates = await this.uberEstimatesClient.getPrices({ 36 | start: start.coordinate, 37 | end: end.coordinate, 38 | }); 39 | 40 | return { 41 | start, 42 | end, 43 | estimates: estimates.prices.map(estimate => ({ 44 | productName: estimate.localized_display_name, 45 | distance: { 46 | value: estimate.distance, 47 | unit: DistanceUnit.MILE, 48 | }, 49 | duration: { 50 | length: estimate.duration, 51 | unit: TimeUnit.SECOND, 52 | }, 53 | range: { 54 | high: estimate.high_estimate, 55 | low: estimate.low_estimate, 56 | currencyCode: estimate.currency_code, 57 | }, 58 | surgeMultiplier: estimate.surgeMultiplier ? estimate.surgeMultiplier : null, 59 | })), 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/services/UberService.test.js: -------------------------------------------------------------------------------- 1 | import UberEstimatesClient from 'uber-estimates-client'; 2 | 3 | import AddressLocator from './AddressLocator'; 4 | import UberService from './UberService'; 5 | 6 | jest.mock('uber-estimates-client'); 7 | jest.mock('./AddressLocator'); 8 | 9 | describe('UberService', () => { 10 | let service; 11 | 12 | beforeEach(() => { 13 | UberEstimatesClient.mockClear(); 14 | AddressLocator.mockClear(); 15 | service = new UberService(); 16 | }); 17 | 18 | describe('#constructor', () => { 19 | it('constructs service', () => { 20 | expect(service).toBeDefined(); 21 | expect(UberEstimatesClient).toHaveBeenCalledTimes(1); 22 | expect(AddressLocator).toHaveBeenCalledTimes(1); 23 | }); 24 | }); 25 | 26 | describe('#getTimeEstimates', () => { 27 | it('gets time estimates', async () => { 28 | const timeEstimates = await service.getTimeEstimates('firstjaebaebae'); 29 | expect(timeEstimates.location).toEqual({ coordinate: { latitude: 'firstjaebaebaelatitude', longitude: 'firstjaebaebaelongitude' } }); 30 | expect(timeEstimates.estimates[0].productName).toEqual('first localized display name'); 31 | }); 32 | }); 33 | 34 | describe('#getPriceEstimates', () => { 35 | it('gets price estimates', async () => { 36 | const priceEstimates = await service.getPriceEstimates({ 37 | startAddress: 'firstjaebaebae', 38 | endAddress: 'secondjaebaebae', 39 | }); 40 | expect(priceEstimates).toBeDefined(); 41 | expect(priceEstimates.start).toEqual({ coordinate: { latitude: 'firstjaebaebaelatitude', longitude: 'firstjaebaebaelongitude' } }); 42 | expect(priceEstimates.estimates[0].productName).toEqual('first localized display name'); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/services/__mocks__/AddressLocator.js: -------------------------------------------------------------------------------- 1 | const getFirstLocation = jest.fn((address) => { 2 | if (address === 'firstjaebaebae') { 3 | return Promise.resolve({ 4 | coordinate: { 5 | latitude: 'firstjaebaebaelatitude', 6 | longitude: 'firstjaebaebaelongitude', 7 | }, 8 | }); 9 | } 10 | 11 | if (address === 'secondjaebaebae') { 12 | return Promise.resolve({ 13 | coordinate: { 14 | latitude: 'secondjaebaebaelatitude', 15 | longitude: 'secondjaebaebaelongitude', 16 | }, 17 | }); 18 | } 19 | 20 | throw new Error(`Unknown address: ${address}`); 21 | }); 22 | 23 | const constructor = jest.fn(() => ({ getFirstLocation })); 24 | 25 | const AddressLocator = constructor; 26 | 27 | export default AddressLocator; 28 | export { getFirstLocation }; 29 | -------------------------------------------------------------------------------- /src/services/converters.js: -------------------------------------------------------------------------------- 1 | import convert from 'convert-units'; 2 | import TimeUnit from '../data/TimeUnit'; 3 | import DistanceUnit from '../data/DistanceUnit'; 4 | 5 | const DURATION_UNIT_ABBREVIATIONS = Object.freeze({ 6 | [TimeUnit.SECOND]: 's', 7 | [TimeUnit.MINUTE]: 'min', 8 | }); 9 | 10 | const DISTANCE_UNIT_ABBREVIATIONS = Object.freeze({ 11 | [DistanceUnit.MILE]: 'mi', 12 | [DistanceUnit.KILOMETER]: 'km', 13 | }); 14 | 15 | const convertDuration = ({ duration, toUnit }) => ({ 16 | length: convert(duration.length) 17 | .from(DURATION_UNIT_ABBREVIATIONS[duration.unit]) 18 | .to(DURATION_UNIT_ABBREVIATIONS[toUnit]), 19 | unit: toUnit, 20 | }); 21 | 22 | const convertDistance = ({ distance, toUnit }) => ({ 23 | value: (convert(distance.value) 24 | .from(DISTANCE_UNIT_ABBREVIATIONS[distance.unit]) 25 | .to(DISTANCE_UNIT_ABBREVIATIONS[toUnit])), 26 | unit: toUnit, 27 | }); 28 | 29 | export { 30 | convertDuration, 31 | convertDistance, 32 | DISTANCE_UNIT_ABBREVIATIONS, 33 | }; 34 | -------------------------------------------------------------------------------- /src/services/converters.test.js: -------------------------------------------------------------------------------- 1 | import convert, { to, from } from 'convert-units'; 2 | import TimeUnit from '../data/TimeUnit'; 3 | import DistanceUnit from '../data/DistanceUnit'; 4 | 5 | import { 6 | convertDuration, 7 | convertDistance, 8 | } from './converters'; 9 | 10 | jest.mock('convert-units'); 11 | 12 | describe('converters', () => { 13 | describe('#convertDuration', () => { 14 | it('converts from seconds to minutes', () => { 15 | const { length, unit } = convertDuration({ 16 | duration: { 17 | length: 1, 18 | unit: TimeUnit.SECOND, 19 | }, 20 | toUnit: TimeUnit.MINUTE, 21 | }); 22 | expect(convert).toHaveBeenCalledWith(1); 23 | expect(from).toHaveBeenCalledWith('s'); 24 | expect(to).toHaveBeenCalledWith('min'); 25 | expect(length).toEqual(1234); 26 | expect(unit).toEqual(TimeUnit.MINUTE); 27 | }); 28 | }); 29 | 30 | describe('#convertDistance', () => { 31 | it('converts from miles to kilometers', () => { 32 | const { value, unit } = convertDistance({ 33 | distance: { 34 | value: 1, 35 | unit: DistanceUnit.MILE, 36 | }, 37 | toUnit: DistanceUnit.KILOMETER, 38 | }); 39 | expect(convert).toHaveBeenCalledWith(1); 40 | expect(from).toHaveBeenCalledWith('mi'); 41 | expect(to).toHaveBeenCalledWith('km'); 42 | expect(value).toEqual(1234); 43 | expect(unit).toEqual(DistanceUnit.KILOMETER); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/services/formatters.js: -------------------------------------------------------------------------------- 1 | import { DISTANCE_UNIT_ABBREVIATIONS } from './converters'; 2 | import symbols from './symbols'; 3 | 4 | const formatSurgeMultiplier = surgeMultiplier => ( 5 | surgeMultiplier > 1 6 | ? `${surgeMultiplier}x ${symbols.SURGE_EXISTS}` 7 | : symbols.NOT_APPLICABLE 8 | ); 9 | 10 | const formatDistance = ({ value, unit }) => { 11 | // 2 decimal places 12 | const roundedDistanceValue = Math.round(value * 100) / 100; 13 | return `${roundedDistanceValue} ${DISTANCE_UNIT_ABBREVIATIONS[unit]}.`; 14 | }; 15 | 16 | const formatPrice = ({ price, currencyCode }) => ( 17 | Intl.NumberFormat('en-US', { 18 | style: 'currency', 19 | maximumFractionDigits: 0, 20 | minimumFractionDigits: 0, 21 | currency: currencyCode, 22 | }).format(price) 23 | ); 24 | 25 | const formatPriceRange = ({ low, high, currencyCode }) => `${formatPrice({ price: low, currencyCode })}-${formatPrice({ price: high, currencyCode })}`; 26 | 27 | const formatSeconds = (seconds) => { 28 | let value = seconds; 29 | 30 | if (value < 0) { 31 | throw new RangeError('Cannot generate formatted time for negative seconds'); 32 | } 33 | 34 | if (value === 0) { 35 | return '0 sec.'; 36 | } 37 | 38 | const days = Math.floor(value / 86400); 39 | value %= 86400; 40 | 41 | const hours = Math.floor(value / 3600); 42 | value %= 3600; 43 | 44 | const minutes = Math.floor(value / 60); 45 | value %= 60; 46 | 47 | let formattedTime = ''; 48 | if (days !== 0) { 49 | formattedTime += ` ${days} days`; 50 | } 51 | 52 | if (hours !== 0) { 53 | formattedTime += ` ${hours} hrs.`; 54 | } 55 | 56 | if (minutes !== 0) { 57 | formattedTime += ` ${minutes} min.`; 58 | } 59 | 60 | if (value !== 0) { 61 | formattedTime += ` ${value} sec.`; 62 | } 63 | 64 | // GAWD THIS IS SO FUCKING HACKY I HATE EVERYTHING 65 | return formattedTime.trim(); 66 | }; 67 | 68 | export { 69 | formatSurgeMultiplier, 70 | formatDistance, 71 | formatPriceRange, 72 | formatSeconds, 73 | }; 74 | -------------------------------------------------------------------------------- /src/services/formatters.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | formatSeconds, formatSurgeMultiplier, formatDistance, formatPriceRange, 3 | } from './formatters'; 4 | import DistanceUnit from '../data/DistanceUnit'; 5 | import symbols from './symbols'; 6 | 7 | jest.mock('./symbols', () => ({ 8 | SURGE_EXISTS: 'surge exists', 9 | NOT_APPLICABLE: 'not applicable', 10 | })); 11 | 12 | describe('formatters', () => { 13 | describe('#formatSurgeMultiplier', () => { 14 | it('returns formatted surge multiplier when multiplier is 2', () => { 15 | expect(formatSurgeMultiplier(2)).toEqual(`2x ${symbols.SURGE_EXISTS}`); 16 | }); 17 | 18 | it('does not format surge multiplier when multiplier is 1', () => { 19 | expect(formatSurgeMultiplier(1)).toEqual(symbols.NOT_APPLICABLE); 20 | }); 21 | 22 | it('does not format surge multiplier when multiplier is null', () => { 23 | expect(formatSurgeMultiplier(null)).toEqual(symbols.NOT_APPLICABLE); 24 | }); 25 | }); 26 | 27 | describe('#formatDistance', () => { 28 | it('returns 12 mi. for a value of 12', () => { 29 | expect(formatDistance({ value: 12, unit: DistanceUnit.MILE })).toEqual('12 mi.'); 30 | }); 31 | 32 | it('returns 12.35 mi. for a value of 12.345', () => { 33 | expect(formatDistance({ value: 12.345, unit: DistanceUnit.MILE })).toEqual('12.35 mi.'); 34 | }); 35 | }); 36 | 37 | describe('#formatPriceRange', () => { 38 | it('returns $1-$2 when low is 1, high is 2, and currencyCode is USD', () => { 39 | expect(formatPriceRange({ low: 1, high: 2, currencyCode: 'USD' })); 40 | }); 41 | 42 | it('returns $1-$2 when low is 1.23 and high is 2.34 and currencyCode is USD', () => { 43 | expect(formatPriceRange({ low: 1.23, high: 2.34, currencyCode: 'USD' })); 44 | }); 45 | }); 46 | 47 | describe('#formatSeconds', () => { 48 | it('throws RangeError when negative duration', () => { 49 | expect(() => formatSeconds(-1)).toThrow(RangeError); 50 | }); 51 | 52 | it('returns 0 sec.', () => { 53 | expect(formatSeconds(0)).toEqual('0 sec.'); 54 | }); 55 | 56 | it('returns 59 sec.', () => { 57 | expect(formatSeconds(59)).toEqual('59 sec.'); 58 | }); 59 | 60 | it('returns 1 min.', () => { 61 | expect(formatSeconds(60)).toEqual('1 min.'); 62 | }); 63 | 64 | it('returns 1 min. 1 sec.', () => { 65 | expect(formatSeconds(61)).toEqual('1 min. 1 sec.'); 66 | }); 67 | 68 | it('returns 59 min. 59 sec.', () => { 69 | expect(formatSeconds(3599)).toEqual('59 min. 59 sec.'); 70 | }); 71 | 72 | it('returns an hour', () => { 73 | expect(formatSeconds(3600)).toEqual('1 hrs.'); 74 | }); 75 | 76 | it('returns 23 hrs. 59 min. 59 sec.', () => { 77 | expect(formatSeconds(86399)).toEqual('23 hrs. 59 min. 59 sec.'); 78 | }); 79 | 80 | it('returns 1 days', () => { 81 | expect(formatSeconds(86400)).toEqual('1 days'); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/services/symbols.emojis.test.js: -------------------------------------------------------------------------------- 1 | describe('symbols', () => { 2 | const realProcess = global.process; 3 | const mockedProcess = { platform: 'darwin' }; 4 | let symbols; 5 | 6 | beforeEach(() => { 7 | global.process = mockedProcess; 8 | // eslint-disable-next-line 9 | symbols = require('./symbols'); 10 | }); 11 | 12 | afterEach(() => { 13 | global.process = realProcess; 14 | }); 15 | 16 | describe('text', () => { 17 | it('gets text', () => { 18 | expect(symbols.default).toEqual({ 19 | VEHICLE: '🚘', 20 | PRICE: '💸', 21 | TRIP_DISTANCE: '🔃', 22 | DURATION: '⏳', 23 | SURGE_MULTIPLIER: '💥', 24 | NOT_APPLICABLE: '🚫', 25 | SURGE_EXISTS: '😬', 26 | DESTINATION: '🔚', 27 | ORIGIN: '📍', 28 | MAXIMUM_DISTANCE: '💯', 29 | }); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/services/symbols.js: -------------------------------------------------------------------------------- 1 | const EMOJIS = Object.freeze({ 2 | VEHICLE: '🚘', 3 | PRICE: '💸', 4 | TRIP_DISTANCE: '🔃', 5 | DURATION: '⏳', 6 | SURGE_MULTIPLIER: '💥', 7 | NOT_APPLICABLE: '🚫', 8 | SURGE_EXISTS: '😬', 9 | DESTINATION: '🔚', 10 | ORIGIN: '📍', 11 | MAXIMUM_DISTANCE: '💯', 12 | }); 13 | 14 | const TEXT = Object.freeze({ 15 | VEHICLE: 'Vehicle', 16 | PRICE: 'Price', 17 | TRIP_DISTANCE: 'Distance', 18 | DURATION: 'Duration', 19 | SURGE_MULTIPLIER: ' *', 20 | NOT_APPLICABLE: 'N/A', 21 | SURGE_EXISTS: ':-(', 22 | DESTINATION: 'Destination', 23 | ORIGIN: 'Origin', 24 | MAXIMUM_DISTANCE: '100', 25 | }); 26 | 27 | const symbols = ['darwin', 'linux'].indexOf(process.platform) >= 0 ? EMOJIS : TEXT; 28 | 29 | export default symbols; 30 | -------------------------------------------------------------------------------- /src/services/symbols.text.test.js: -------------------------------------------------------------------------------- 1 | describe('symbols', () => { 2 | const realProcess = global.process; 3 | const mockedProcess = { platform: 'foobar' }; 4 | let symbols; 5 | 6 | beforeEach(() => { 7 | global.process = mockedProcess; 8 | // eslint-disable-next-line 9 | symbols = require('./symbols'); 10 | }); 11 | 12 | afterEach(() => { 13 | global.process = realProcess; 14 | }); 15 | 16 | describe('text', () => { 17 | it('gets text', () => { 18 | expect(symbols.default).toEqual({ 19 | VEHICLE: 'Vehicle', 20 | PRICE: 'Price', 21 | TRIP_DISTANCE: 'Distance', 22 | DURATION: 'Duration', 23 | SURGE_MULTIPLIER: ' *', 24 | NOT_APPLICABLE: 'N/A', 25 | SURGE_EXISTS: ':-(', 26 | DESTINATION: 'Destination', 27 | ORIGIN: 'Origin', 28 | MAXIMUM_DISTANCE: '100', 29 | }); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/services/tables/price/build.js: -------------------------------------------------------------------------------- 1 | import Table from 'cli-table2'; 2 | 3 | import TimeUnit from '../../../data/TimeUnit'; 4 | import symbols from '../../symbols'; 5 | import { 6 | formatSurgeMultiplier, 7 | formatDistance, 8 | formatPriceRange, 9 | formatSeconds, 10 | } from '../../formatters'; 11 | 12 | import { 13 | convertDuration, 14 | convertDistance, 15 | } from '../../converters'; 16 | 17 | const headers = [ 18 | symbols.VEHICLE, 19 | symbols.PRICE, 20 | symbols.TRIP_DISTANCE, 21 | symbols.DURATION, 22 | `${symbols.SURGE_EXISTS} Surge${symbols.SURGE_EXISTS}`, 23 | ].map(symbol => ({ content: symbol, hAlign: 'center' })); 24 | 25 | const buildRow = ({ estimate, presentationUnits }) => { 26 | const { 27 | productName, 28 | range, 29 | distance, 30 | duration, 31 | surgeMultiplier, 32 | } = estimate; 33 | 34 | const { length: seconds } = convertDuration({ duration, toUnit: TimeUnit.SECOND }); 35 | const convertedDistance = convertDistance({ distance, toUnit: presentationUnits }); 36 | 37 | return [ 38 | productName, 39 | formatPriceRange(range), 40 | formatDistance(convertedDistance), 41 | formatSeconds(seconds), 42 | formatSurgeMultiplier(surgeMultiplier), 43 | ]; 44 | }; 45 | 46 | const build = ({ estimates, presentationUnits }) => { 47 | estimates.estimates.sort(( 48 | firstEstimate, 49 | secondEstimate, 50 | ) => (firstEstimate.range.low - secondEstimate.range.low)); 51 | 52 | const { 53 | estimates: priceEstimates, 54 | start, 55 | end, 56 | } = estimates; 57 | 58 | const table = new Table(); 59 | table.push(headers); 60 | 61 | priceEstimates.forEach((estimate) => { 62 | if (estimate.productName.toUpperCase() !== 'TAXI') { 63 | table.push(buildRow({ estimate, presentationUnits })); 64 | } 65 | }); 66 | 67 | table.push([ 68 | { 69 | colSpan: 1, 70 | content: symbols.ORIGIN, 71 | hAlign: 'center', 72 | }, 73 | { 74 | colSpan: 4, 75 | content: start.name, 76 | }, 77 | ]); 78 | 79 | table.push([ 80 | { 81 | colSpan: 1, 82 | content: symbols.DESTINATION, 83 | hAlign: 'center', 84 | }, 85 | { 86 | colSpan: 4, 87 | content: end.name, 88 | }, 89 | ]); 90 | 91 | return table.toString(); 92 | }; 93 | 94 | export default build; 95 | -------------------------------------------------------------------------------- /src/services/tables/price/build.test.js: -------------------------------------------------------------------------------- 1 | import build from './build'; 2 | 3 | import TimeUnit from '../../../data/TimeUnit'; 4 | import DistanceUnit from '../../../data/DistanceUnit'; 5 | 6 | jest.unmock('convert-units'); 7 | 8 | let expected; 9 | let table; 10 | let estimates; 11 | 12 | const validateTable = (presentationUnits) => { 13 | console.log('expected table'); 14 | console.log(expected); 15 | 16 | table = build({ estimates, presentationUnits }); 17 | 18 | console.log('built table'); 19 | console.log(table); 20 | 21 | expect(table).toEqual(expected); 22 | }; 23 | 24 | describe('#build', () => { 25 | // tests use emojis and assumes process.platform = darwin 26 | // apologies in advance if this causes problems 27 | 28 | const realProcess = global.process; 29 | const mockedProcess = { platform: 'darwin' }; 30 | 31 | const distance = { 32 | value: 12.34, 33 | unit: DistanceUnit.MILE, 34 | }; 35 | const duration = { 36 | length: 5678, 37 | unit: TimeUnit.SECOND, 38 | }; 39 | const currencyCode = 'USD'; 40 | const start = { name: 'jae' }; 41 | const end = { name: 'baebae' }; 42 | const firstProduct = { 43 | productName: 'first product', 44 | range: { 45 | low: 10, 46 | high: 20, 47 | currencyCode, 48 | }, 49 | distance, 50 | duration, 51 | }; 52 | const secondProduct = { 53 | productName: 'second product', 54 | range: { 55 | low: 8, 56 | high: 16, 57 | currencyCode, 58 | }, 59 | distance, 60 | duration, 61 | }; 62 | const thirdProduct = { 63 | productName: 'third product', 64 | range: { 65 | low: 6, 66 | high: 12, 67 | currencyCode, 68 | }, 69 | distance, 70 | duration, 71 | }; 72 | const taxiProduct = { 73 | productName: 'TAXI', 74 | range: { 75 | low: 4, 76 | high: 8, 77 | currencyCode, 78 | }, 79 | distance, 80 | duration, 81 | }; 82 | 83 | const defaultEstimates = { 84 | estimates: [ 85 | firstProduct, 86 | secondProduct, 87 | thirdProduct, 88 | ], 89 | start, 90 | end, 91 | }; 92 | 93 | beforeEach(() => { 94 | global.process = mockedProcess; 95 | }); 96 | 97 | afterEach(() => { 98 | global.process = realProcess; 99 | }); 100 | 101 | it('should build sorted table', () => { 102 | estimates = defaultEstimates; 103 | expected = '┌────────────────┬─────────┬───────────┬────────────────────────┬──────────┐\n│ 🚘 │ 💸 │ 🔃 │ ⏳ │ 😬 Surge😬 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ third product │ $6-$12 │ 12.34 mi. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ second product │ $8-$16 │ 12.34 mi. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ first product │ $10-$20 │ 12.34 mi. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┴───────────┴────────────────────────┴──────────┤\n│ 📍 │ jae │\n├────────────────┼─────────────────────────────────────────────────────────┤\n│ 🔚 │ baebae │\n└────────────────┴─────────────────────────────────────────────────────────┘'; 104 | validateTable(DistanceUnit.MILE); 105 | }); 106 | 107 | it('should build table without TAXI product', () => { 108 | estimates = { 109 | estimates: [ 110 | firstProduct, 111 | secondProduct, 112 | thirdProduct, 113 | taxiProduct, 114 | ], 115 | start, 116 | end, 117 | }; 118 | 119 | expected = '┌────────────────┬─────────┬───────────┬────────────────────────┬──────────┐\n│ 🚘 │ 💸 │ 🔃 │ ⏳ │ 😬 Surge😬 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ third product │ $6-$12 │ 12.34 mi. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ second product │ $8-$16 │ 12.34 mi. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ first product │ $10-$20 │ 12.34 mi. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┴───────────┴────────────────────────┴──────────┤\n│ 📍 │ jae │\n├────────────────┼─────────────────────────────────────────────────────────┤\n│ 🔚 │ baebae │\n└────────────────┴─────────────────────────────────────────────────────────┘'; 120 | validateTable(DistanceUnit.MILE); 121 | }); 122 | 123 | it('should build table converting to kilometers', () => { 124 | estimates = defaultEstimates; 125 | expected = '┌────────────────┬─────────┬───────────┬────────────────────────┬──────────┐\n│ 🚘 │ 💸 │ 🔃 │ ⏳ │ 😬 Surge😬 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ third product │ $6-$12 │ 19.86 km. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ second product │ $8-$16 │ 19.86 km. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ first product │ $10-$20 │ 19.86 km. │ 1 hrs. 34 min. 38 sec. │ 🚫 │\n├────────────────┼─────────┴───────────┴────────────────────────┴──────────┤\n│ 📍 │ jae │\n├────────────────┼─────────────────────────────────────────────────────────┤\n│ 🔚 │ baebae │\n└────────────────┴─────────────────────────────────────────────────────────┘'; 126 | validateTable(DistanceUnit.KILOMETER); 127 | }); 128 | 129 | it('should build table with surge multiplier', () => { 130 | // probably a better way to do this but quick and dirty for now 131 | 132 | const firstProductWithSurge = { 133 | productName: 'first product with surge', 134 | range: { 135 | low: 10, 136 | high: 20, 137 | currencyCode, 138 | }, 139 | distance, 140 | duration, 141 | surgeMultiplier: 1.23, 142 | }; 143 | const secondProductWithSurge = { 144 | productName: 'second product with surge', 145 | range: { 146 | low: 8, 147 | high: 16, 148 | currencyCode, 149 | }, 150 | distance, 151 | duration, 152 | surgeMultiplier: 2.34, 153 | }; 154 | const thirdProductWithSurge = { 155 | productName: 'third product with surge', 156 | range: { 157 | low: 6, 158 | high: 12, 159 | currencyCode, 160 | }, 161 | distance, 162 | duration, 163 | surgeMultiplier: 3.45, 164 | }; 165 | estimates = { 166 | estimates: [ 167 | firstProductWithSurge, 168 | secondProductWithSurge, 169 | thirdProductWithSurge, 170 | ], 171 | start, 172 | end, 173 | }; 174 | 175 | expected = '┌───────────────────────────┬─────────┬───────────┬────────────────────────┬──────────┐\n│ 🚘 │ 💸 │ 🔃 │ ⏳ │ 😬 Surge😬 │\n├───────────────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ third product with surge │ $6-$12 │ 19.86 km. │ 1 hrs. 34 min. 38 sec. │ 3.45x 😬 │\n├───────────────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ second product with surge │ $8-$16 │ 19.86 km. │ 1 hrs. 34 min. 38 sec. │ 2.34x 😬 │\n├───────────────────────────┼─────────┼───────────┼────────────────────────┼──────────┤\n│ first product with surge │ $10-$20 │ 19.86 km. │ 1 hrs. 34 min. 38 sec. │ 1.23x 😬 │\n├───────────────────────────┼─────────┴───────────┴────────────────────────┴──────────┤\n│ 📍 │ jae │\n├───────────────────────────┼─────────────────────────────────────────────────────────┤\n│ 🔚 │ baebae │\n└───────────────────────────┴─────────────────────────────────────────────────────────┘'; 176 | validateTable(DistanceUnit.KILOMETER); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/services/tables/time/build.js: -------------------------------------------------------------------------------- 1 | import Table from 'cli-table2'; 2 | 3 | import TimeUnit from '../../../data/TimeUnit'; 4 | import { formatSeconds } from '../../formatters'; 5 | import { convertDuration } from '../../converters'; 6 | import symbols from '../../symbols'; 7 | 8 | const headers = [symbols.DURATION, symbols.VEHICLE] 9 | .map(symbol => ({ 10 | content: symbol, 11 | hAlign: 'center', 12 | })); 13 | 14 | const groupEstimatesByTime = (estimates) => { 15 | const rows = {}; 16 | 17 | estimates.forEach(({ estimatedDuration, productName }) => { 18 | const durationInSeconds = convertDuration({ 19 | duration: estimatedDuration, 20 | toUnit: TimeUnit.SECOND, 21 | }); 22 | const seconds = durationInSeconds.length; 23 | const formattedDuration = formatSeconds(seconds); 24 | let productsWithSameDuration = rows[formattedDuration]; 25 | 26 | if (!productsWithSameDuration) { 27 | productsWithSameDuration = []; 28 | } 29 | 30 | productsWithSameDuration.push(productName); 31 | rows[formattedDuration] = productsWithSameDuration; 32 | }); 33 | 34 | return rows; 35 | }; 36 | 37 | const buildRows = (estimates) => { 38 | estimates.sort(( 39 | firstEstimate, 40 | secondEstimate, 41 | ) => (firstEstimate.estimatedDuration.length - secondEstimate.estimatedDuration.length)); 42 | const groupedEstimates = groupEstimatesByTime(estimates); 43 | return Object.keys(groupedEstimates).map(key => [key, groupedEstimates[key].join(', ')]); 44 | }; 45 | 46 | const build = ({ estimates, location }) => { 47 | const table = new Table(); 48 | 49 | table.push([ 50 | { 51 | colSpan: 2, 52 | content: `${symbols.ORIGIN} ${location.name}`, 53 | hAlign: 'center', 54 | }, 55 | ]); 56 | table.push(headers); 57 | 58 | buildRows(estimates).forEach(row => table.push(row)); 59 | 60 | return table.toString(); 61 | }; 62 | 63 | export default build; 64 | -------------------------------------------------------------------------------- /src/services/tables/time/build.test.js: -------------------------------------------------------------------------------- 1 | import TimeUnit from '../../../data/TimeUnit'; 2 | 3 | import build from './build'; 4 | 5 | jest.unmock('convert-units'); 6 | 7 | describe('#build', () => { 8 | const realProcess = global.process; 9 | const mockedProcess = { platform: 'darwin' }; 10 | const location = { name: 'foobar' }; 11 | 12 | beforeEach(() => { 13 | global.process = mockedProcess; 14 | }); 15 | 16 | afterEach(() => { 17 | global.process = realProcess; 18 | }); 19 | 20 | // both tests use emojis and assumes process.platform = darwin 21 | // apologies in advance if this causes problems 22 | 23 | it('builds table for products that do not share same formatted duration', () => { 24 | const estimates = [ 25 | { 26 | estimatedDuration: { length: 120, unit: TimeUnit.SECOND }, 27 | productName: 'first product', 28 | }, 29 | { 30 | estimatedDuration: { length: 60, unit: TimeUnit.SECOND }, 31 | productName: 'second product', 32 | }, 33 | { 34 | estimatedDuration: { length: 0, unit: TimeUnit.SECOND }, 35 | productName: 'third product', 36 | }, 37 | ]; 38 | const expected = '┌─────────────────────────┐\n│ 📍 foobar │\n├────────┬────────────────┤\n│ ⏳ │ 🚘 │\n├────────┼────────────────┤\n│ 0 sec. │ third product │\n├────────┼────────────────┤\n│ 1 min. │ second product │\n├────────┼────────────────┤\n│ 2 min. │ first product │\n└────────┴────────────────┘'; 39 | expect(build({ estimates, location })).toEqual(expected); 40 | }); 41 | 42 | it('builds table for products that do share same formatted duration', () => { 43 | const estimates = [ 44 | { 45 | estimatedDuration: { length: 120, unit: TimeUnit.SECOND }, 46 | productName: 'first product', 47 | }, 48 | { 49 | estimatedDuration: { length: 120, unit: TimeUnit.SECOND }, 50 | productName: 'second product', 51 | }, 52 | { 53 | estimatedDuration: { length: 120, unit: TimeUnit.SECOND }, 54 | productName: 'third product', 55 | }, 56 | ]; 57 | const expected = '┌───────────────────────────────────────────────────────┐\n│ 📍 foobar │\n├────────┬──────────────────────────────────────────────┤\n│ ⏳ │ 🚘 │\n├────────┼──────────────────────────────────────────────┤\n│ 2 min. │ first product, second product, third product │\n└────────┴──────────────────────────────────────────────┘'; 58 | expect(build({ estimates, location })).toEqual(expected); 59 | }); 60 | }); 61 | --------------------------------------------------------------------------------