diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..76dc4de --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,73 @@ +'use strict'; + +var request = require('request'); + +module.exports = function (grunt) { + // show elapsed time at the end + require('time-grunt')(grunt); + // load all grunt tasks + require('load-grunt-tasks')(grunt); + + var reloadPort = 35729, files; + + grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), + develop: { + server: { + file: 'app.js' + } + }, + watch: { + options: { + nospawn: true, + livereload: reloadPort + }, + js: { + files: [ + 'app.js', + 'app/**/*.js', + 'config/*.js' + ], + tasks: ['develop', 'delayed-livereload'] + }, + css: { + files: [ + 'public/css/*.css' + ], + options: { + livereload: reloadPort + } + }, + views: { + files: [ + 'app/views/*.ejs', + 'app/views/**/*.ejs' + ], + options: { livereload: reloadPort } + } + } + }); + + grunt.config.requires('watch.js.files'); + files = grunt.config('watch.js.files'); + files = grunt.file.expand(files); + + grunt.registerTask('delayed-livereload', 'Live reload after the node server has restarted.', function () { + var done = this.async(); + setTimeout(function () { + request.get('http://localhost:' + reloadPort + '/changed?files=' + files.join(','), function(err, res) { + var reloaded = !err && res.statusCode === 200; + if (reloaded) + grunt.log.ok('Delayed live reload successful.'); + else + grunt.log.error('Unable to make a delayed live reload.'); + done(reloaded); + }); + }, 500); + }); + + grunt.registerTask('default', [ + 'develop', + 'watch' + ]); +}; diff --git a/README.md b/README.md index 078dc11..5a511d7 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,4 @@ -# GSTV BE Coding Exercise +Due to really limited time because of my current job requirements to be on call 24/7. I was only able to make a quick pass at this as I was dealing with a production server deployment for our scoring system. I was able to get the drop down display logic and day of week functionality partially going. You can see that I worked in an MVC pattern with different kinds of environment based configurations for deployments. I also completed the mongoose model structure of the time slot data. And began to leverage it for data save and search. Normally I would do all of this in angular and have useage of more verbs like PUT, DELETE, with JSON responses. But I wanted to get something up and running fast so I just did it old fashion POST style for the DOM creation. There was not enough time to finish the validation and complete the entire workflow but it was heading in the right direction. The drop down hour display logic was a fun challenge and that is in there. There are bugs in the View/Edit Page logic (It's really just incomplete). But at least it gives you an idea of some of my capabilities. I wish I had more time to work on this but I am completely slammed right now. My sincerest apologies and I hope that my attempt at it proves how eager I am to possibly work with you guys. -1. [Exercise Overview](#exercise-overview) -1. [System Requirements](#system-requirements) -1. [Version Control](#version-control) -1. [JavaScript](#javascript) - -## Exercise Overview -The site - an individual gas station - is the most atomic piece of the GSTV business model - it is at the core of everything we do. Our hardware is installed at the site, advertisers purchase impressions at a site level and schedules are generated on a per-site basis. Thus, keeping accurate information about a site is essential to maintaining business operations. - -We are asking you to build out the ability to create, edit and view hours for a given site. We are focusing on the way you approach the services, and structure the Mongo documents - there is no expectation of a polished UI. **We want you to focus on the server-side aspects of implementing these requirements.** - -This data is used to help us know when to turn on and off hardware, how many times a video asset is expected to play and at its most basic level, whether the site is open when we try to call them. - -A site may have multiple open and close times for a single day. For example they may be open in the morning, but close mid-afternoon and reopen for the after work rush hour. In many cases stations will be open past midnight, but business owners do not necessarily think of this as the next day. - -[Taco Bell](http://s3-media2.fl.yelpcdn.com/bphoto/bzl1SoxoBR-ggedVDlECAA/ls.jpg) is a perfect example of business hours vs. chronological hours - they may be open until 4am on Sunday, but you still think of it as Saturday night. - -#### Creating and Editing Hours -Edit & Create Site hours have the same business rules. The main difference is that on edit the dropdowns and values are prepopulated, while in add they are blank. - -- Format - - For each day of the week - - Day Label - - Full name - - Each day may have one or many time slots - - For each Time Slot - - Open Time - - Dropdown - - Values are in 30 minute increments - - Values are in AM/PM format - - Values - - Start at Midnight and end 11:30 PM - - Close Time - - Dropdown - - Values are in 30 minute increments - - Values are in AM/PM format - - Values - - Start at 12:30 AM and end at 6:00 AM (next day) - - All values past 11:30 PM (values between Midnight and 6:00 AM) should have the text (next day) at the end - - Remove Button - - Open 24 Hours Button - - Add Button - - Close Button - - Submit Button -- Functionality - - Remove Button - - Removes the selected time slot - - If Open 24 Hours - - Removes Open 24 Hours message - - Displays one empty time slot - - Add Button - - Adds an additional time slot to the selected day - - Open 24 Hours Button - - Removes all the time slots if any exist - - Hides Open 24 Hours Button - - Message - - Open 24 Hours - - Close Button - - User is returned to the spot where they opened the wizard and the view not reflect any changes - - Submit Button - - FE Validates values - - Null Validation - - If a start time is entered a close time is required - - If a close time is entered a start time is required - - Submit fails - - Message - - Unable to Create/Update: {itemName} is required. - - Overlap/Duplicate Validation - - If a timeslot for a given day overlaps any other time slots on the same day - - Submit fails - - Message - - Unable to Create/Update: there is at least one overlapping timeslot - - Time Slot Format Validation - - If the start time falls after the end time - - Message - - Unable to Create/Update: The start time must be before the end time - - If the end time falls before the start time - - Message - - Unable to Create/Update: The start time must be before the end time - - If the start time falls on the end time - - Message - - Unable to Create/Update: The start time may not be the same date as the end time - - If FE validation passes - - Submit changes to the BE - - BE validates values - - Null Validation - - If any required items are null - - Submit fails - - Message - - Unable to Create/Update: {itemName} is required. - - Unchanged Data Validation - - If any required items are null - - Submit fails - - Message - - Unable to Update: {itemName} {itemValue} has not been changed. - - Duplicate Validation - - If any required items are null - - Submit fails - - Message - - Unable to Create/Update: {itemName} {itemValue} already exists. - - Malformed Data Validation - - If any required items are null - - Submit fails - - Message - - Unable to Create/Update: {itemName} {itemValue} does not match the expected format. - - Time Slot Format Validation - - If the start time falls after the end time - - Message - - Unable to Create/Update: The start time must be before the end time - - If the end time falls before the start time - - Message - - Unable to Create/Update: The start time must be before the end time - - If the start time falls on the end time - - Message - - Unable to Create/Update: The start time may not be the same date as the end time - - If BE validation passes - - Update Data - - User is returned to the spot where they opened the wizard and the view will reflect changes from the wizard. - -#### Viewing Hours -- Format - - Normal State - - For current date/time - - Open or Closed - - Rules - - If current date and time is within a defined open period, then Open - - If current date and time is not within a defined open period, then Closed - - For each day - - Day Label - - Full name - - If not Open 24 hours - - For each time slot - - Open Time - - Close Time - - If Open 24 hours - - Message - - Open 24 hours - - If Hours are Null - - Message - - Closed - - Edit Site Hours Button - - Empty State - - Message - - There are no site hours for {Site ID}. - - Create Site Hours Button -- Functionality - - Edit Site Hours Button - - Links to Creating/Editing Hours - - Create Site Hours Button - - Links to Creating/Editing Hours - -#### Extra Credit -##### Future Date/Time Open or Closed -Our field operations team often goes to sites for maintenance or upgrades. While creating their schedules it would be helpful to know if a site will be open on a given date and time in the future. - -##### Timezones -GSTV has sites across the US. People from the Detroit main office may be calling sites in California. For that reason it is important to know the open times based upon a given timezone. - -##### Daylight Savings Time -GSTV has sites in Arizona. Arizona does not participate in daylight savings time. For that reason it is important to know the open times based upon a site’s participation in daylight savings. - -## System Requirements -* Node.js `^4.0.0` -* MongoDB `^3.0.0` - -## Version Control -### GitFlow and GithubFlow -We use [GitFlow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow/) on a daily basis - this allows us to build quality control into our development, QA and deployment process. - -We are asking that you use a modified [Github Flow](https://guides.github.com/introduction/flow/) - sometimes referred to as a [feature branch workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/feature-branch-workflow) - methodology instead of GitFlow. Conceptually, GitFlow and Github flow are similar. - -Please fork our repository and use a feature branch workflow while developing your functionality. When you are ready to submit your work make a [pull request against our repository](https://help.github.com/articles/using-pull-requests/). - -## JavaScript -### Standards -We have a work in progress [style guide](https://github.com/davezuko/gstv-javascript-standards) that you can refer to. We don't expect you to strictly adhere to these standards, but they may help provide insight into how our JavaScript is generally structured. - -### Unit Testing -Please feel free to create unit tests - we use [Mocha](https://github.com/mochajs/mocha). +All the best, +Robert Rudas \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..712ac32 --- /dev/null +++ b/app.js @@ -0,0 +1,25 @@ + + +var express = require('express'), + config = require('./config/config'), + glob = require('glob'), + mongoose = require('mongoose'); + +mongoose.connect(config.db); +var db = mongoose.connection; +db.on('error', function () { + throw new Error('unable to connect to database at ' + config.db); +}); + +var models = glob.sync(config.root + '/app/models/*.js'); +models.forEach(function (model) { + require(model); +}); +var app = express(); + +require('./config/express')(app, config); + +app.listen(config.port, function () { + console.log('Express server listening on port ' + config.port); +}); + diff --git a/app/controllers/home.js b/app/controllers/home.js new file mode 100644 index 0000000..1df2f33 --- /dev/null +++ b/app/controllers/home.js @@ -0,0 +1,329 @@ +'use strict'; + +var express = require('express'), + router = express.Router(), + mongoose = require('mongoose'), + TimeSlot = mongoose.model('TimeSlot'), + moment = require('moment'); + + + +function getOpenTimeDropDownList(time){ + var increment = 30; + var momentTime = moment("12:00 AM", ["h:mm A"]); + var result = ''; + var counter = 47; + + + for (var i=0; i < counter; i++){ + var val = momentTime.add(increment, "minutes").format("h:mm A"); + var selected = ''; + + if (val === time){ + selected = 'selected'; + } + + result = result + ''; + } + + + return result; + +} + +function getCloseTimeDropDownList(time){ + var increment = 30; + var momentTime = moment("12:30 AM", ["h:mm A"]); + var result = ''; + var counter = 60; + + + for (var i=0; i < counter; i++){ + var val = momentTime.add(increment, "minutes").format("h:mm A"); + var extraMessage = ''; + var selected = ''; + + if (val === time){ + selected = 'selected'; + } + + if (i > 45){ + extraMessage = ' (next day)'; + } + + result = result + ''; + } + + + + + return result; + +} + +function castDateValue(date){ + var result; + if (isNaN(date)){ + result = moment(date); + } + else{ + result = moment(parseInt(date)); + } + + return result; +} + +function getWeeklyTimeSlotData(siteId, date, callback){ + var query = TimeSlot.findOne({ 'siteId': parseInt(siteId), 'dateTime': date.valueOf()}); + + query.select('openTimes closeTimes timeSlotCount'); + + query.exec(function (err, timeSlot) { + if (err) { + callback(err); + } + else{ + callback(null, timeSlot); + } + + }); +} + +module.exports = function (app) { + app.use('/', router); +}; + +router.get('/', function (req, res, next) { + res.render('index', { + title: 'Welcome' + }); + +}); + +router.post('/view', function (req, res, next) { + + var siteId = req.body.siteid; + var date = castDateValue(req.body.date); + + var begin = moment(date).startOf('isoweek').isoWeekday(1); + var main = ""; + + getWeeklyTimeSlotData(siteId, date, function(err, timeSlot){ + if (err){ + return next(err); + } + else{ + + var hiddenFields = ''; + + if (timeSlot){ + + for (var i=0; i<7; i++) { + hiddenFields = ''; + + main += '

' + begin.format('ddd D-M-Y') + '

'; + + if (begin.valueOf() === date.valueOf()){ + for (var j=0; j < timeSlot.timeSlotCount; j++){ + var openTime = timeSlot.openTimes[j]; + var closeTime = timeSlot.closeTimes[j]; + + + main += '
Open Time: ' + openTime + '
'; + main += '
Close Time: ' + closeTime + '
'; + main += '
' + hiddenFields; + main += '
'; + } + } + else{ + main += 'Closed'; + main += '
' + hiddenFields; + main += '
'; + } + + + begin.add('d', 1); + } + + } + else{ + hiddenFields = ''; + main += "There are no site hours for site id: " + siteId + "
"; + main += '
' + hiddenFields; + main += '
'; + } + + + res.render('viewonly', { + title: 'View Weekly Site Hours', + siteid: siteId, + main: main + }); + } + }); + + + +}); + +router.post('/edit', function (req, res, next) { + + var siteId = req.body.siteid; + var date = castDateValue(req.body.date); + var begin = moment(date).startOf('isoweek').isoWeekday(1); + + var main = ""; + + getWeeklyTimeSlotData(siteId, date, function(err, timeSlot){ + if (err){ + return next(err); + } + else{ + + var hiddenFields = ''; + + if (timeSlot){ + + for (var i=0; i<7; i++) { + + hiddenFields = ''; + + main += '

' + begin.format('ddd D-M-Y') + '

'; + + if (begin.valueOf() === date.valueOf()) { + + for (var j=0; j < timeSlot.timeSlotCount; j++){ + var openTime = timeSlot.openTimes[j]; + var closeTime = timeSlot.closeTimes[j]; + + + main += '
Open Time:
'; + main += '
Close Time:
'; + } + + + + } + + main += '
' + hiddenFields; + main += '
'; + + main += '
' + hiddenFields; + main += '
'; + + main += '
' + hiddenFields; + main += '
'; + + begin.add('d', 1); + + } + + } + else{ + + + + for (var j=0; j<7; j++) { + hiddenFields = ''; + + main += '

' + begin.format('ddd D-M-Y') + '

'; + + main += '
' + hiddenFields; + main += '
'; + + main += '
' + hiddenFields; + main += '
'; + + begin.add('d', 1); + } + + } + + res.render('edit', { + title: 'Edit Weekly Site Hours', + siteid: siteId, + main: main + }); + } + }); + + +}); + +router.post('/delete', function (req, res, next) { + res.render('success', { + title: 'You successfully deleted the time slot', + date: req.body.date, + siteid: req.body.siteid + }); + +}); + +router.post('/24hours', function (req, res, next) { + res.render('success', { + title: 'You successfully set to 24 hours', + date: req.body.date, + siteid: req.body.siteid + }); + +}); + +router.post('/update', function (req, res, next) { + res.render('success', { + title: 'You successfully updated the time slot', + date: req.body.date, + siteid: req.body.siteid + }); + +}); + +router.post('/add', function (req, res, next) { + + var siteId = parseInt(req.body.siteid); + var date = parseInt(req.body.date); + + + TimeSlot.findOne({siteId: siteId, dateTime:date},function(err,result){ + var newTimeSlot = ''; + var successResponseObject = { + title: 'You successfully added a time slot', + date: req.body.date, + siteid: req.body.siteid + }; + + if (err){ + console.log(err); + } + else{ + if(result!=null){ + result.timeSlotCount = result.timeSlotCount + 1; + TimeSlot.update({siteId: siteId, dateTime:date},result,{upsert:true},function(err){ + if (err){ + console.log(err); + } + else{ + res.render('success', successResponseObject); + } + + }); + } + else{ + newTimeSlot = new TimeSlot({ siteId: siteId, dateTime:date, openTimes:[], closeTimes:[], timeSlotCount: 1,isTwentFourHours: false }); + newTimeSlot.save(function(err){ + if (err){ + console.log(err); + } + else{ + res.render('success', successResponseObject); + } + }); + } + } + + + }); + +}); + + + diff --git a/app/models/timeSlot.js b/app/models/timeSlot.js new file mode 100644 index 0000000..db7b963 --- /dev/null +++ b/app/models/timeSlot.js @@ -0,0 +1,16 @@ +'use strict'; + +var mongoose = require('mongoose'), + Schema = mongoose.Schema; + +var TimeSlotSchema = new Schema({ + siteId: Number, + dateTime: Number, + openTimes: Array, + closeTimes: Array, + timeSlotCount: Number, + isTwentFourHours: Boolean +}); + +mongoose.model('TimeSlot', TimeSlotSchema); + diff --git a/app/views/edit.ejs b/app/views/edit.ejs new file mode 100644 index 0000000..4fb61d2 --- /dev/null +++ b/app/views/edit.ejs @@ -0,0 +1,14 @@ +<% include header %> + +

<%-title %>

+ +
+

Site Id: <%-siteid%>

+
+ + <%-main%> + + + + +<% include footer %> \ No newline at end of file diff --git a/app/views/error.ejs b/app/views/error.ejs new file mode 100644 index 0000000..c2d4fa3 --- /dev/null +++ b/app/views/error.ejs @@ -0,0 +1,7 @@ +<% include header %> + + <%- message %> + <%- error.status %> + <%- error.stack %> + +<% include footer %> \ No newline at end of file diff --git a/app/views/footer.ejs b/app/views/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/app/views/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/views/header.ejs b/app/views/header.ejs new file mode 100644 index 0000000..6b11aa3 --- /dev/null +++ b/app/views/header.ejs @@ -0,0 +1,12 @@ + + + + + + <%- title %> + + <% if (ENV_DEVELOPMENT) { %> + + <% } %> + + diff --git a/app/views/index.ejs b/app/views/index.ejs new file mode 100644 index 0000000..c7f2c9c --- /dev/null +++ b/app/views/index.ejs @@ -0,0 +1,18 @@ +<% include header %> + +

<%-title %>

+ +
+
+ Enter date of week in the following format: YYYY-MM-DD +
+ Enter the numeric site id: +
+ +
+
+ + + + +<% include footer %> \ No newline at end of file diff --git a/app/views/success.ejs b/app/views/success.ejs new file mode 100644 index 0000000..5f9d9b0 --- /dev/null +++ b/app/views/success.ejs @@ -0,0 +1,16 @@ +<% include header %> + +

<%-title %>

+ +
+
+ + + +
+
+ + + + +<% include footer %> \ No newline at end of file diff --git a/app/views/viewonly.ejs b/app/views/viewonly.ejs new file mode 100644 index 0000000..4fb61d2 --- /dev/null +++ b/app/views/viewonly.ejs @@ -0,0 +1,14 @@ +<% include header %> + +

<%-title %>

+ +
+

Site Id: <%-siteid%>

+
+ + <%-main%> + + + + +<% include footer %> \ No newline at end of file diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..82658a3 --- /dev/null +++ b/bower.json @@ -0,0 +1,9 @@ +{ + "name": "node-coding-exercise", + "version": "0.0.1", + "ignore": [ + "**/.*", + "node_modules", + "components" + ] +} diff --git a/config/config.js b/config/config.js new file mode 100644 index 0000000..0d34ef5 --- /dev/null +++ b/config/config.js @@ -0,0 +1,34 @@ +var path = require('path'), + rootPath = path.normalize(__dirname + '/..'), + env = process.env.NODE_ENV || 'development'; + +var config = { + development: { + root: rootPath, + app: { + name: 'node-coding-exercise' + }, + port: 3000, + db: 'mongodb://localhost/gstvDev' + }, + + test: { + root: rootPath, + app: { + name: 'node-coding-exercise' + }, + port: 3000, + db: 'mongodb://localhost/gstvTest' + }, + + production: { + root: rootPath, + app: { + name: 'node-coding-exercise' + }, + port: 3000, + db: 'mongodb://localhost/gstvProd' + } +}; + +module.exports = config[env]; diff --git a/config/express.js b/config/express.js new file mode 100644 index 0000000..4cd130a --- /dev/null +++ b/config/express.js @@ -0,0 +1,61 @@ +var express = require('express'); +var glob = require('glob'); + +var favicon = require('serve-favicon'); +var logger = require('morgan'); +var cookieParser = require('cookie-parser'); +var bodyParser = require('body-parser'); +var compress = require('compression'); +var methodOverride = require('method-override'); + +module.exports = function(app, config) { + var env = process.env.NODE_ENV || 'development'; + app.locals.ENV = env; + app.locals.ENV_DEVELOPMENT = env == 'development'; + + app.set('views', config.root + '/app/views'); + app.set('view engine', 'ejs'); + + // app.use(favicon(config.root + '/public/img/favicon.ico')); + app.use(logger('dev')); + app.use(bodyParser.json()); + app.use(bodyParser.urlencoded({ + extended: true + })); + app.use(cookieParser()); + app.use(compress()); + app.use(express.static(config.root + '/public')); + app.use(methodOverride()); + + var controllers = glob.sync(config.root + '/app/controllers/*.js'); + controllers.forEach(function (controller) { + require(controller)(app); + }); + + app.use(function (req, res, next) { + var err = new Error('Not Found'); + err.status = 404; + next(err); + }); + + if(app.get('env') === 'development'){ + app.use(function (err, req, res, next) { + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: err, + title: 'error' + }); + }); + } + + app.use(function (err, req, res, next) { + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: {}, + title: 'error' + }); + }); + +}; diff --git a/package.json b/package.json index 74d77ee..e9e0d10 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,30 @@ { "name": "node-coding-exercise", - "version": "1.0.0", - "description": "GSTV coding exercise for BE candidates", - "main": "index.js", + "version": "0.0.1", + "private": true, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node app.js" }, - "repository": { - "type": "git", - "url": "git+https://github.com/GasStationTV/node-coding-exercise.git" + "dependencies": { + "async": "^1.5.2", + "body-parser": "^1.13.3", + "compression": "^1.5.2", + "cookie-parser": "^1.3.3", + "ejs": "^2.3.1", + "express": "^4.13.3", + "glob": "^5.0.3", + "method-override": "^2.3.0", + "moment": "^2.11.1", + "mongoose": "^4.1.2", + "morgan": "^1.6.1", + "serve-favicon": "^2.3.0" }, - "keywords": [ - "Node" - ], - "author": "Aaron Olson (http://www.gstv.com)", - "license": "ISC", - "bugs": { - "url": "https://github.com/GasStationTV/node-coding-exercise/issues" - }, - "homepage": "https://github.com/GasStationTV/node-coding-exercise#readme" + "devDependencies": { + "grunt": "^0.4.5", + "grunt-develop": "^0.4.0", + "grunt-contrib-watch": "^0.6.1", + "request": "^2.60.0", + "time-grunt": "^1.2.1", + "load-grunt-tasks": "^3.2.0" + } } diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..30e047d --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,8 @@ +body { + padding: 50px; + font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; +} + +a { + color: #00B7FF; +} \ No newline at end of file