diff --git a/.gitignore b/.gitignore index 57195033..3340b430 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.DS_Store node_modules/ package-lock.json +stats.csv \ No newline at end of file diff --git a/README.md b/README.md index 38593962..3474708a 100755 --- a/README.md +++ b/README.md @@ -1,84 +1,150 @@ Assignment 2 - Short Stack: Basic Two-tier Web Application using HTML/CSS/JS and Node.js === -Due: September 16th, by 11:59 AM. - -This assignment aims to introduce you to the concepts and practice involved in creating a prototype (i.e. not deployment ready) two-tiered web application. - -The baseline aims of this assignment involve creating an application that demonstrates the use of several specific pieces of HTML, CSS, JavaScript, and Node.js functionality. - -Baseline Requirements ---- - -Note that there is a very large range of application areas and possibilities that meet these baseline requirements. Make your application do something useful! A todo list, storing / retrieving high scores for a very simple game, have a little fun with it. - -Your application is required to implement the following functionalities: - -- a `Server` which not only serves files, but also maintains a tabular dataset with 3 or more fields related to your application -- a `Results` functionality which shows the entire dataset residing in the server's memory -- a `Form/Entry` functionality which allows a user to add, modify, or delete data items residing in the server's memory -- a `Server Logic` which, upon receiving new or modified "incoming" data, includes and uses a function that adds at least one additional derived field to this incoming data before integrating it with the existing dataset -- the `Derived field` for a new row of data must be computed based on fields already existing in the row. For example, a `todo` dataset with `task`, `priority`, and `creation_date` may generate a new field `deadline` by looking at `creation_date` and `priority` - -Your application is required to demonstrate the use of the following concepts: - -HTML: -- One or more [HTML Forms](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms), with any combination of form tags appropriate for the user input portion of the application -- A results page displaying all data currently available on the server. You will most likely use a `` tag for this, but `
+ + + + + + + + + + + + + + + + + + + + + + + + +
TotalAverage
Kills00
Assists00
Deaths00
- window.onload = function() { - const button = document.querySelector( 'button' ) - button.onclick = submit - } +
+ + + +
- + + + + + + + + + + + + + + +
ID #KillsAssistsDeathsK/D RatioA/D Ratio
+ + + diff --git a/public/js/scripts.js b/public/js/scripts.js index de052eae..35c54d91 100755 --- a/public/js/scripts.js +++ b/public/js/scripts.js @@ -1,3 +1,211 @@ -// Add some Javascript code here, to run on the front end. +/** + * Send an /add API HTTP request to add a new game's stats to the + * table. The stats are taken from the "add" form in index.html. + * The updated stats are then displayed in total_avg_results and + * results_list tables in index.html. + * + * @returns {boolean} true if server returned a 2O0 status code, + * false otherwise. + */ +function handle_add(){ + //The following source showed me how to extract values from a + //form: https://www.w3schools.com/jsref/coll_form_elements.asp + const input = document.getElementById("add"), + json = { + kills: input.elements[0].value, + assists: input.elements[1].value, + deaths: input.elements[2].value, + }, + body = JSON.stringify(json); -console.log("Welcome to assignment 2!") \ No newline at end of file + fetch( '/add', { + method:'POST', + body + }).then(function( response ) { + if(response.status === 200){ + updateResults(response); + return true; + } + }); + + return false; +} + +/** + * Send a /modify API HTTP request to modifies a game's stats by + * setting them to the values in the "modify" form in index.html. + * The updated stats are then displayed in total_avg_results and + * results_list tables in index.html. + * + * @returns {boolean} true if server returned a 2O0 status code, + * false otherwise. + */ +function handle_modify(){ + const input = document.getElementById("modify"), + json = { + id: input.elements[0].value, + kills: input.elements[1].value, + assists: input.elements[2].value, + deaths: input.elements[3].value, + }, + body = JSON.stringify(json); + + fetch( '/modify', { + method:'POST', + body + }).then(function( response ) { + if(response.status === 200){ + updateResults(response); + return true; + } + }); + + return false; +} + +/** + * Send a /delete API HTTP request to remove a game's stats from + * the table. The ID# of the game to remove are taken from the + * "delete" form in index.html The updated stats are then displayed + * in total_avg_results and results_list tables in index.html. + * + * @returns {boolean} true if server returned a 2O0 status code, + * false otherwise. + */ +function handle_delete(){ + const input = document.getElementById("delete"), + json = { + id: input.elements[0].value + }, + body = JSON.stringify(json); + + fetch( '/delete', { + method:'POST', + body + }).then(function( response ) { + if(response.status === 200){ + updateResults(response); + return true; + } + }); + + return false; +} + +function handle_clear(){ + fetch( '/clear', { + method:'GET', + }).then(function( response ) { + if(response.status === 200){ + updateResults(response); + return true; + } + }); + + return false; +} + +/** + * Send a /results API HTTP request to retrieve all the current + * stats stored in the server. The updated stats are then displayed + * in total_avg_results and results_list tables in index.html. + * + * @returns {boolean} true if server returned a 2O0 status code, + * false otherwise. + */ +function getLatestResults(){ + fetch( '/results', { + method:'GET' + }).then(function( response ) { + if(response.status === 200){ + updateResults(response); + return true; + } + }); +} + +/** + * Downloads all the data from both tables as a CSV file called + * "stats.csv". + * + * @returns {boolean} if stats.csv was successfully created + * and downloaded. + */ +function handle_csv(){ + /* + * This source explained to me that you can't just use the "Content-Disposition" + * header to download files from GET requests: + * https://stackoverflow.com/questions/26737883/content-dispositionattachment-not-triggering-download-dialog + * + * The top answer from the following source taught me how to download + * a file using fetch. It essentially says to download the response, + * get the blob with the file data, create a URL to it, and then create + * an element that, when clicked, downloads the object at the URL, + * which is our file. The code between lines 140-151 comes from this source, + * and the comments that start with "OA" are comments from the original post + * by that Original Author. The original post used arrow shorthand notation + * but I changed cause I didn't like it :) + * + * https://stackoverflow.com/questions/32545632/how-can-i-download-a-file-using-window-fetch + */ + fetch( '/csv', { + method:'GET' + }).then(function(response){ + return response.blob() + }) + .then(function(blob) { + let a = document.createElement("a"); + a.href = window.URL.createObjectURL(blob); + a.download = "stats.csv"; + document.body.appendChild(a);// OA: we need to append the element to the dom -> otherwise it will not work in firefox + a.click(); + a.remove();// OA: afterwards we remove the element again + return true; + }); + + + return false; +} + +/** + * Updates the contents of the total_avg_results and results_list + * tables in index.html with the data in response. + * + * @param response an HTTP response with the data to be displayed in + * the total_avg_results and results_list tables in index.html + */ +function updateResults(response){ + //Delete existing table and add a new, empty one. The following + //source gave me the idea of swapping the tbody element of the + //table, and showed me how to do it: + //https://stackoverflow.com/questions/7271490/delete-all-rows-in-an-html-table + let table = document.getElementById("results_list"); + let newBody = document.createElement("tbody"); + table.replaceChild(newBody, table.lastChild); + + //The following source showed me how to extract json from the HTTP + //response: https://developer.mozilla.org/en-US/docs/Web/API/Body/json + response.json().then(function(data) { + //The following source was used to learn how to insert a row into + //a table in JS: https://www.w3schools.com/jsref/met_table_insertrow.asp + let numRows = data.numRows; + let rows = data.rows; + for (let i = 0; i < numRows; i++) { + let newRow = newBody.insertRow(i); + newRow.insertCell(0).innerHTML = `${rows[i].id}`; + newRow.insertCell(1).innerHTML = `${rows[i].kills}`; + newRow.insertCell(2).innerHTML = `${rows[i].assists}`; + newRow.insertCell(3).innerHTML = `${rows[i].deaths}`; + newRow.insertCell(4).innerHTML = `${rows[i].kd_ratio}`; + newRow.insertCell(5).innerHTML = `${rows[i].ad_ratio}`; + } + + //Now updates the boxes holding the totals and averages + document.getElementById("total_kills").innerHTML = `${data.totals_avgs["total_kills"]}` + document.getElementById("avg_kills").innerHTML = `${data.totals_avgs["avg_kills"]}` + document.getElementById("total_assists").innerHTML = `${data.totals_avgs["total_assists"]}` + document.getElementById("avg_assists").innerHTML = `${data.totals_avgs["avg_assists"]}` + document.getElementById("total_deaths").innerHTML = `${data.totals_avgs["total_deaths"]}` + document.getElementById("avg_deaths").innerHTML = `${data.totals_avgs["avg_deaths"]}` + }); +} \ No newline at end of file diff --git a/server.improved.js b/server.improved.js index 26673fc0..002fdda8 100644 --- a/server.improved.js +++ b/server.improved.js @@ -6,66 +6,419 @@ const http = require( 'http' ), dir = 'public/', port = 3000 -const appdata = [ - { 'model': 'toyota', 'year': 1999, 'mpg': 23 }, - { 'model': 'honda', 'year': 2004, 'mpg': 30 }, - { 'model': 'ford', 'year': 1987, 'mpg': 14} -] +//Format: { "id": 0, "kills": 0, "assists": 0, "deaths": 0, "kd_ratio": 0, "ad_ratio": 0 }, +let appdata = []; +const DECIMAL_PRECISION = 2; +let id = 1;//Unique IDs to indicate rows to modify or delete +let numEntries = 0;//Length of appdata + +//Track running totals and averages of all three main stats +let totalKills = 0; +let totalAssists = 0; +let totalDeaths = 0; +let avgKills = 0; +let avgAssists = 0; +let avgDeaths = 0; + +/** + * Create the HTTP server and set the request handler to send GET + * and POST requests to their respective handlers. + * + * @type {Server} the HTTP server that will respond to all requests. + */ const server = http.createServer( function( request,response ) { - if( request.method === 'GET' ) { - handleGet( request, response ) - }else if( request.method === 'POST' ){ - handlePost( request, response ) - } + if(request.method === "GET") { + handleGet(request, response); + }else if(request.method === "POST"){ + handlePost(request, response); + } }) +/** + * Handle the HTTP GET request stored in request and stores the + * HTTP response in response. + * + * @param request the HTTP GET request to be processed + * @param response the HTTP response to store all response data in + */ const handleGet = function( request, response ) { - const filename = dir + request.url.slice( 1 ) + const filename = dir + request.url.slice( 1 ) - if( request.url === '/' ) { - sendFile( response, 'public/index.html' ) - }else{ - sendFile( response, filename ) - } + if(request.url === "/") { + sendFile(response, "public/index.html"); + }else if(request.url === "/results"){ + sendTable(response); + }else if(request.url === "/csv"){ + sendCSV(response); + }else if(request.url === "/clear"){ + clearStats(response); + }else{ + sendFile(response, filename); + } } +/** + * Handle the HTTP POST request stored in request and stores the + * HTTP response in response. + * + * @param request the HTTP POST request to be processed + * @param response the HTTP response to store all response data in + */ const handlePost = function( request, response ) { - let dataString = '' + let dataString = ''; + + request.on( 'data', function( data ) { + dataString += data; + }) - request.on( 'data', function( data ) { - dataString += data - }) + request.on( 'end', function() { + let data = JSON.parse(dataString); + //Convert everything to a Number now so all operations + //dont have to keep calling Number() + convertDataToNum(data); + console.log(data); - request.on( 'end', function() { - console.log( JSON.parse( dataString ) ) + //Call the proper function based on API call, then + //send the updated table information in response so + // index.html can display the updated table. + if(request.url === "/add") { + if(addItem(data)) { + sendTable(response) + }else{ + response.writeHead(400, "Add request failed", {'Content-Type': 'text/plain'}); + response.end(); + } - // ... do something with the data here!!! + }else if(request.url === "/modify"){ + if(modifyItem(data)) { + sendTable(response); + }else{ + response.writeHead(400, "Modify request failed", {'Content-Type': 'text/plain'}); + response.end(); + } - response.writeHead( 200, "OK", {'Content-Type': 'text/plain' }) - response.end() - }) + }else if(request.url === "/delete"){ + if(deleteItem(data)) + sendTable(response); + else{ + response.writeHead(400, "Delete request failed", {'Content-Type': 'text/plain'}); + response.end(); + } + + }else{ + //Not recognized + response.writeHead(401, "Invalid request type", {'Content-Type': 'text/plain'}); + response.end(); + } + }) } -const sendFile = function( response, filename ) { - const type = mime.getType( filename ) +/** + * Converts the stats given in the HTTP request to Numbers, and stores + * them back into data. + * + * @param data an object containing any of the following fields from the + * the HTTP request: "id", "kills", "assists", and "deaths" fields + */ +const convertDataToNum = function(data){ + if(data.hasOwnProperty("id")) + data.id = parseInt(data.id, 10); + + if(data.hasOwnProperty("kills")) + data.kills = parseInt(data.kills, 10); + + if(data.hasOwnProperty("assists")) + data.assists = parseInt(data.assists, 10); + + if(data.hasOwnProperty("deaths")) + data.deaths = parseInt(data.deaths, 10); +} + +/** + * Calculates the kill/death ratio and assist/death ratio based on the + * given set of kills, assists and deaths. + * + * @param kills number of kills from the game + * @param assists number of assists from the game + * @param deaths number of deaths from the game + */ +const calculateKDandAD = function(kills, assists, deaths){ + let kd, ad; + //We want to avoid divide by zero errors, but still allows for 0 deaths. + //If there are 0 deaths, FPS games traditionally treat K/D = # kill and + //A/D as assists + if(deaths === 0) { + kd = kills; + ad = assists; + }else{ + kd = parseFloat((kills / deaths).toFixed(DECIMAL_PRECISION)); + ad = parseFloat((assists / deaths).toFixed(DECIMAL_PRECISION)); + } + return { + kd_ratio: kd, + ad_ratio: ad + } +} + +/** + * Add the item stored in data into the appdata table. This set + * of stats is assigned an unique ID number as well. + * + * @param data an object with stats to add to the table. It is expected + * to have fields for "kills", "assists", "deaths", "kd_ratio", and + * "ad_ratio." + * @return {boolean} true on successful addition, false otherwise. + */ +const addItem = function(data){ + if(Number.isNaN(data.kills) || data.kills < 0 || + Number.isNaN(data.assists) || data.assists < 0 || + Number.isNaN(data.deaths) || data.deaths < 0) + return false; + + let ratios = calculateKDandAD(data.kills, data.assists, data.deaths); + appdata.push({ + "id": id, + "kills": data.kills, + "assists": data.assists, + "deaths": data.deaths, + "kd_ratio": ratios.kd_ratio, + "ad_ratio": ratios.ad_ratio + }) + id++; + numEntries++; + updateTotalsAvgs(data.kills, data.assists, data.deaths); + return true; +} + +/** + * Modify the row in the appdata table with the given id to instead + * have the stats stored in data. This set of stats will keep + * the unique ID number that was assigned to it when it was added. + * + * @param data an object with stats to add to the table. It is expected + * to have fields for "id", "kills", "assists", "deaths", "kd_ratio", + * and "ad_ratio." + * @return {boolean} true on successful modification, false otherwise. + */ +const modifyItem = function(data){ + let targetID = data.id; + for(let i = 0; i < numEntries; i++){ + if(appdata[i]["id"] === targetID){ + //Remove old values from running total + totalKills -= appdata[i]["kills"]; + totalAssists -= appdata[i]["assists"]; + totalDeaths -= appdata[i]["deaths"]; + + //Modify only the fields that were provided + if(!Number.isNaN(data.kills) && data.kills >= 0) + appdata[i]["kills"] = data.kills; + if(!Number.isNaN(data.assists) && data.assists >= 0) + appdata[i]["assists"] = data.assists; + if(!Number.isNaN(data.deaths) && data.deaths >= 0) + appdata[i]["deaths"] = data.deaths; + + //Recalculate derived fields + let ratios = calculateKDandAD(appdata[i]["kills"], appdata[i]["assists"], appdata[i]["deaths"]); + appdata[i]["kd_ratio"] = ratios.kd_ratio; + appdata[i]["ad_ratio"] = ratios.ad_ratio; + + updateTotalsAvgs(appdata[i]["kills"], appdata[i]["assists"], appdata[i]["deaths"]); + return true; + } + } + //Entry if given ID not found. + return false; +} + +/** + * Delete the row in the appdata table with the given id. + * + * @param data an object with the id of the row in appdata to remove. + * It is expected to have a field for "id". + * @return {boolean} true on successful deletion, false otherwise. + */ +const deleteItem = function(data){ + if(Number.isNaN(data.id) || data.id < 0) + return false; + + let targetID = data.id; + for(let i = 0; i < numEntries; i++){ + if(appdata[i]["id"] === targetID){ + numEntries--; + + totalKills -= appdata[i]["kills"]; + totalAssists -= appdata[i]["deaths"]; + totalDeaths -= appdata[i]["assists"]; + updateAvgs(); + + appdata.splice(i, 1); + return true; + } + } + //Entry if given ID not found. + return false; +} + +/** + * Wipe all the data stored on the server and reset count variables. + * Return an a json indicating an empty table so index.html knows to + * display and empty table. + * + * @param response an HTTP response that will be populate with an + * empty table to indicate that server data has been wiped. + */ +const clearStats = function(response){ + appdata = []; + numEntries = 0; + id = 0; + totalKills = 0; + totalAssists = 0; + totalDeaths = 0; + avgKills = 0; + avgAssists = 0; + avgDeaths = 0; + sendTable(response); +} + +/** + * Update the total and average kills, assists and deaths by taking into + * account the new set of kills, assists and deaths. + * + * @param kills number of kills from the game + * @param assists number of assists from the game + * @param deaths number of deaths from the game + */ +const updateTotalsAvgs = function(kills, assists, deaths){ + totalKills += kills; + totalAssists += assists; + totalDeaths += deaths; + updateAvgs(); +} + +/** + * Update the average kills, assists and deaths based on the current number + * of kills, assists and deaths. + */ +const updateAvgs = function(){ + if(numEntries <= 0){ + numEntries = 0; + avgKills = 0; + avgAssists = 0; + avgDeaths = 0; + } + avgKills = parseFloat((totalKills / numEntries).toFixed(DECIMAL_PRECISION)); + avgAssists = parseFloat((totalAssists / numEntries).toFixed(DECIMAL_PRECISION)); + avgDeaths = parseFloat((totalDeaths / numEntries).toFixed(DECIMAL_PRECISION)); +} + +/** + * Creates an HTTP response with a JSON object that contains all the data for the + * total_avg_results and result_list tables in index.html. This includes every + * row of appdata as well as total and average number of kills, assists and deaths. + * This JSON object is then stored in response and the headers are set. + * + * The format of the JSON object is as follows: + * { + * numRows: , + * rows: [ + * { "id": , "kills": , "assists": , "deaths": , "kd_ratio": , "ad_ratio": }, + * ... + * { "id": , "kills": , "assists": , "deaths": , "kd_ratio": , "ad_ratio": }, + * ], + * totals_avgs: { + * total_kills: + * avg_kills: + * total_assists: + * avg_assists: + * total_deaths: + * avg_deaths: + * } + * } + * + * @param response an HTTP response that will populated with a JSON object that + * contains every row of appdata as well as total and average number of kills, + * assists and deaths. + */ +const sendTable = function(response){ + let json = { + "numRows": numEntries, + "rows": [], + "totals_avgs": {}, + } + for(let i = 0; i < numEntries; i++){ + json["rows"].push(appdata[i]); + } + json["totals_avgs"] = { + "total_kills": totalKills, + "avg_kills": avgKills, + "total_assists": totalAssists, + "avg_assists": avgAssists, + "total_deaths": totalDeaths, + "avg_deaths": avgDeaths + } + let body = JSON.stringify(json); + response.writeHead(200, "OK", {"Content-Type": "text/plain"}); + response.end(body); +} - fs.readFile( filename, function( err, content ) { +/** + * Creates an HTTP response that contains the contents of a stats.csv file, + * which is a csv file that contains every row of appdata as well as total + * and average number of kills, assists and deaths. This response is then + * stored in response and the headers are set. + * + * @param response an HTTP response that will be populated the data for stats.csv. + */ +const sendCSV = function(response){ + /* + * The following link from node.js documentation taught how to + * close and flush write streams: https://nodejs.org/api/stream.html + */ + let file = fs.createWriteStream("stats.csv"); + file.write(",Total,Average\n"); + file.write(`Kills,${totalKills},${avgKills}\n`); + file.write(`Assists,${totalAssists},${avgAssists}\n`); + file.write(`Deaths,${totalDeaths},${avgDeaths}\n\n`); + + file.write("ID #,Kills,Assists,Deaths,K/D Ratio,A/D Ratio\n"); + for(let i = 0; i < numEntries; i++){ + file.write(`${appdata[i]["id"]}, ${appdata[i]["kills"]}, ${appdata[i]["assists"]}, ${appdata[i]["deaths"]}, ${appdata[i]["kd_ratio"]}, ${appdata[i]["ad_ratio"]}\n`); + } + file.on("finish", function(){ + //Whole file has now been written, so send. + sendFile(response, "stats.csv"); + }); + file.end(); +} + +/** + * Creates an HTTP response that contains the contents of the file located, + * at filename. This response is then stored in response and + * the headers are set. + * + * @param response an HTTP response that will be populated with the data for + * filename. + * @param filename the path to the file to send in response. + */ +const sendFile = function( response, filename ) { + const type = mime.getType( filename ) - // if the error = null, then we've loaded the file successfully - if( err === null ) { + fs.readFile( filename, function( err, content ) { - // status code: https://httpstatuses.com - response.writeHeader( 200, { 'Content-Type': type }) - response.end( content ) + // if the error = null, then we've loaded the file successfully + if( err === null ) { - }else{ + // status code: https://httpstatuses.com + response.writeHead( 200, "OK", { "Content-Type": type }); + response.end( content ) - // file not found, error code 404 - response.writeHeader( 404 ) - response.end( '404 Error: File Not Found' ) + }else{ - } + // file not found, error code 404 + response.writeHead( 404, "File Not Found"); + response.end("404 Error: File Not Found"); + } }) }