diff --git a/.gitignore b/.gitignore index 5431a602..c74d23f0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ src/components/*/* !src/components/angular-touch/angular*.js !src/components/angular-touch/angular*.map !src/components/angular-bootstrap/*.js +!src/components/angular-storage/*.js !src/components/bootstrap-css/css !src/components/bootstrap-css/img diff --git a/package.json b/package.json index f9fac7a7..a39220d9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "start": "node localserver", - "test": "./node_modules/.bin/karma start --single-run --browsers Firefox --reporters dots,coverage" + "test": "./node_modules/.bin/karma start --single-run --reporters dots,coverage" }, "repository": { "type": "git", @@ -30,6 +30,7 @@ }, "dependencies": { "basic-auth-connect": "~1.0.0", + "lockfile": "~1.0.3", "uuid": "^3.1.0", "express": "^4.10.4", "express-session": "^1.5.0", diff --git a/server_modules/file_system.js b/server_modules/file_system.js index a704a6f2..146e780f 100644 --- a/server_modules/file_system.js +++ b/server_modules/file_system.js @@ -70,6 +70,20 @@ exports.readJsonFile = function(file) { return exports.readFile(file).then(parseData); }; +exports.filesInDir = function(path) { + path = exports.resolve(path); + + return Q.promise(function(resolve, reject) { + fs.readdir(path, (err, files) => { + if(err) { + reject(err); + } else { + resolve(files); + } + }); + }); +}; + exports.route = function(app) { //reading the "file system" diff --git a/server_modules/lock.js b/server_modules/lock.js new file mode 100644 index 00000000..2b00dacc --- /dev/null +++ b/server_modules/lock.js @@ -0,0 +1,33 @@ +//This module wraps lockfile with promises. +var lockfile = require('lockfile'); + +module.exports = function(filename, options) { + this.filename = filename; + this.options = options || {}; + + this.lock = function() { + var self = this; + + return new Promise(function(resolve, reject) { + lockfile.lock('scores.json.lock', self.options, function(err) { + if(err && err.code !== 'EEXIST') { + reject(err); + } + + resolve(); + }); + }); + }; + + this.unlock = function() { + return new Promise(function(resolve, reject) { + lockfile.unlock('scores.json.lock', function(err) { + if(err && err.code !== 'EEXIST') { + reject(err); + } + + resolve(); + }); + }); + }; +} diff --git a/server_modules/scores.js b/server_modules/scores.js index ba65806b..d0430e2e 100644 --- a/server_modules/scores.js +++ b/server_modules/scores.js @@ -1,6 +1,9 @@ +var Lock = require('./lock'); var utils = require('./utils'); var fileSystem = require('./file_system'); +var log = require('./log').log; var Q = require('q'); +var id = require('uuid/v4'); function filterPublished(score) { return score.published; @@ -15,6 +18,45 @@ function reduceToMap(key) { } } +/** + * Atomically change scores file. + * Action callback is called with the current contents of the scores file, and is expected + * to return the new contents (or a Promise for it). + * A lock is acquired and held during the entire operation. + * @param action Callback that receives current scores.json object, must return new contents (or Promise for it) + * @return Promise for updated scores object + */ +function changeScores(action) { + var path = fileSystem.getDataFilePath('scores.json'); + var lock = new Lock('scores.json.lock', { retries: 5, retryWait: 100 }); + + console.log(lock.options); + + return lock.lock() + .then(() => fileSystem.readJsonFile(path)) + .catch((err) => { //Ignoring all file not found errors, and just returning empty scores.json + console.log("catching"); + if(err.message === 'file not found') { + console.log("hells yeah!"); + return { version:3, scores: [], sheets: [] }; + } else { + console.log("ho no! " + err.message); + throw err; + } + }) + .then(action) + .then(scores => { + return fileSystem.writeFile(path, JSON.stringify(scores)) + .then(() => { + return lock.unlock(); + }).catch((err) => { + return lock.unlock(); + }).then(function() { + return scores; + }); + }); +} + exports.route = function(app) { //get all, grouped by round @@ -47,4 +89,150 @@ exports.route = function(app) { }).catch(utils.sendError(res)).done(); }); + //save a new score + app.post('/scores/create',function(req,res) { + var body = JSON.parse(req.body); + var scoresheet = body.scoresheet; + var score = body.score; + + fileSystem.writeFile(fileSystem.getDataFilePath("scoresheets/" + score.file), req.body) + .then(changeScores(function(result) { + if(typeof(score.id) === 'undefined') { + score.id = id(); + } + result.scores.push(score); + result.sheets.push(score.file) + return result; + })) + .then(function(scores) { + res.json(scores).end(); + }).catch(utils.sendError(res)).done(); + + }); + + //delete a score at an id + app.post('/scores/delete/:id',function(req,res) { + changeScores(function(result) { + var index = result.scores.findIndex((score) => score.id === req.params.id); + if(index === -1) { + throw new Error(`Could not find score with id ${req.params.id}`); + } + result.scores.splice(index, 1); + return result; + }).then(function(scores) { + res.json(scores).end(); + }).catch(utils.sendError(res)).done(); + }); + + //edit a score at an id + app.post('/scores/update/:id',function(req,res) { + var score = JSON.parse(req.body); + changeScores(function(result) { + var index = result.scores.findIndex((score) => score.id === req.params.id); + if(index === -1) { + throw new Error(`Could not find score with id ${req.params.id}`); + } + result.scores[index] = score; + return result; + }).then(function(scores) { + res.json(scores).end(); + }).catch(utils.sendError(res)).done(); + }); + + }; + +// For backward compatibility + +changeScores(function(scores) { + if(typeof(scores.version) === 'undefined') { + scores.forEach(score => score.id = id()) + return { + version: 3, + scores: scores, + sheets: [] + } + + } else if(scores.version === 3) { + return scores; + + } else if(scores.version === 2) { + scores.scores.forEach(score => score.id = id()) + scores.version = 3; + return scores; + + } else { + throw new Error('Unkown scores version'); + } + +}); + +// Polling for sheets automatically on server load + +function sanitizeScore(score) { + // Passthrough for already valid inputs + if (typeof score === "number") + return score; + switch (score) { + case "dnc": + case "dsq": + case null: + return score; + } + // Accept numbers stored as strings + var n = parseInt(score, 10); + if (String(n) === score) + return n; + // Try to convert some spellings of accepted strings + if (typeof score === "string") { + var s = score.toLowerCase(); + switch (s) { + case "dnc": + case "dsq": + return s; + case "": + return null; + } + } + // Pass through the rest + log.warn("Invalid score " + score); + return score; +} + +function loadScoresheetScore(filename) { + return fileSystem.readJsonFile(fileSystem.getDataFilePath("scoresheets/" + filename)).then(function(entry) { + return { + file: (entry.file !== undefined && entry.file !== null) ? String(entry.file) : "", + teamNumber: parseInt((entry.teamNumber !== undefined) ? entry.teamNumber : entry.team.number, 10), + stageId: String((entry.stageId !== undefined) ? entry.stageId : entry.stage.id), + round: parseInt(entry.round, 10), + score: sanitizeScore(entry.score), // can be Number, null, "dnc", etc. + originalScore: parseInt(entry.originalScore !== undefined ? entry.originalScore : entry.score, 10), + edited: entry.edited !== undefined ? String(entry.edited) : undefined, // timestamp, e.g. "Wed Nov 26 2014 21:11:43 GMT+0100 (CET)" + published: !!entry.published, + table: entry.table + }; + }); +} + +changeScores(function(scores) { + return fileSystem.filesInDir('data/scoresheets').then(function(files) { + var promises = [] + + for(var i = 0; i < files.length; i++) { + if(!scores.sheets.includes(files[i])) { + var promise = loadScoresheetScore(files[i]).then(function(score) { + scores.scores.push(score); + scores.sheets.push(files[i]); + }).catch(function(err) { + log.error(`Error reading scoresheet ${files[i]}: ${err}`); + }); + promises.push(promise); + } + } + + return Q.all(promises).spread(function() { + return scores; + }); + }); +}); diff --git a/spec/controllers/ExportRankingDialiogControllerSpec.js b/spec/controllers/ExportRankingDialiogControllerSpec.js index 8a146949..66e41f95 100644 --- a/spec/controllers/ExportRankingDialiogControllerSpec.js +++ b/spec/controllers/ExportRankingDialiogControllerSpec.js @@ -14,7 +14,7 @@ describe('ExportRankingDialogController',function() { angular.mock.inject(function($controller,$rootScope,$q,_$timeout_) { $scope = $rootScope.$new(); $timeout = _$timeout_; - scoresMock = createScoresMock($q,fakeScoreboard); + scoresMock = createScoresMock(fakeScoreboard); handshakeMock = createHandshakeMock($q); stagesMock = createStagesMock(); controller = $controller('ExportRankingDialogController', { diff --git a/spec/mocks/independenceMock.js b/spec/mocks/independenceMock.js new file mode 100644 index 00000000..d88d7d75 --- /dev/null +++ b/spec/mocks/independenceMock.js @@ -0,0 +1,6 @@ +var createIndependenceMock = function() { + return { + act: jasmine.createSpy('independenceActSpy').and.returnValue(Promise.resolve()), + pendingActions: jasmine.createSpy('independencePendingActionsSpy') + }; +}; diff --git a/spec/mocks/messageMock.js b/spec/mocks/messageMock.js index db24365d..9d05191e 100644 --- a/spec/mocks/messageMock.js +++ b/spec/mocks/messageMock.js @@ -1,3 +1,6 @@ var createMessageMock = function() { - return {}; -} \ No newline at end of file + return { + send: jasmine.createSpy('messageSendSpy'), + on: jasmine.createSpy('messageOnSpy') + }; +} diff --git a/spec/mocks/scoresMock.js b/spec/mocks/scoresMock.js index c9002e76..f2c1d4f2 100644 --- a/spec/mocks/scoresMock.js +++ b/spec/mocks/scoresMock.js @@ -1,4 +1,4 @@ -function createScoresMock($q,scoreboard) { +function createScoresMock(scoreboard) { scoreboard = scoreboard || {}; return { scores: [{ @@ -9,12 +9,12 @@ function createScoresMock($q,scoreboard) { index: 1 }], scoreboard: scoreboard, - remove: jasmine.createSpy('scoreRemoveSpy'), load: jasmine.createSpy('scoreLoadSpy'), - pollSheets: jasmine.createSpy('scorePollSheetsSpy').and.returnValue($q.when()), - update: jasmine.createSpy('scoreUpdateSpy'), + clear: jasmine.createSpy('scoreClearSpy'), + create: jasmine.createSpy('scoreCreateSpy').and.returnValue(Promise.resolve()), + delete: jasmine.createSpy('scoreDeleteSpy'), + update: jasmine.createSpy('scoreUpdateSpy').and.returnValue(Promise.resolve()), _update: jasmine.createSpy('score_UpdateSpy'), - save: jasmine.createSpy('scoreSaveSpy'), getRankings: jasmine.createSpy('getRankings').and.returnValue({ scoreboard: scoreboard }) diff --git a/spec/services/ng-scoresSpec.js b/spec/services/ng-scoresSpec.js index 1ffa2729..4a7bd665 100644 --- a/spec/services/ng-scoresSpec.js +++ b/spec/services/ng-scoresSpec.js @@ -30,6 +30,7 @@ describe('ng-scores',function() { var mockScore; var mockTeam; var fsMock; + var independenceMock; beforeEach(function() { fsMock = createFsMock({ @@ -37,9 +38,13 @@ describe('ng-scores',function() { "stages.json": [rawMockStage], "teams.json": [dummyTeam] }); + independenceMock = createIndependenceMock(); + angular.mock.module(module.name); angular.mock.module(function($provide) { $provide.value('$fs', fsMock); + $provide.value('$message', createMessageMock()); + $provide.value('$independence', independenceMock); }); angular.mock.inject(["$scores", "$stages", "$teams", "$q", function(_$scores_, _$stages_, _$teams_,_$q_) { $scores = _$scores_; @@ -80,9 +85,11 @@ describe('ng-scores',function() { }); } - describe('init',function() { - it('should load mock score initially',function() { - expect(filteredScores()).toEqual([mockScore]); + describe('load',function() { + it('shuold read scores.json', function() { + $scores.load().then(function() { + expect(fsMock.read).toHaveBeenCalledWith('scores.json'); + }); }); }); @@ -94,154 +101,51 @@ describe('ng-scores',function() { }); }); - describe('save',function() { - it('should write scores to scores.json',function() { - return $scores.save().then(function() { - expect(fsMock.write).toHaveBeenCalledWith( - 'scores.json', - { - version: 2, - scores: [rawMockScore], - sheets: [] - } - ); - }); - }); - - it('should log an error if writing fails',function() { - fsMock.write.and.returnValue(Q.reject('write err')); - return $scores.save().then(function() { - expect(logMock).toHaveBeenCalledWith('scores write error','write err'); + describe('create',function() { + it('should call independence act',function() { + $scores.create(mockScore).then(function() { + expect(independenceMock.act).toHaveBeenCalled(); + expect(independenceMock.act.calls.mostRecent().args[0]).toBe('scores'); + expect(independenceMock.act.calls.mostRecent().args[1]).toBe('/scores/create'); }); }); }); - describe('load',function() { - it('should load from scores.json',function() { - return $scores.load().then(function() { - expect(fsMock.read).toHaveBeenCalledWith('scores.json'); - expect(filteredScores()).toEqual([mockScore]); - }); - }); - - it('should log an error if loading fails',function() { - fsMock.read.and.returnValue(Q.reject('read err')); - return $scores.load().then(function() { - expect(logMock).toHaveBeenCalledWith('scores read error','read err'); + describe('delete',function() { + it('should call independence act',function() { + var id = '1df9'; + $scores.delete({ id: id }).then(function() { + expect(independenceMock.act).toHaveBeenCalled(); + expect(independenceMock.act.calls.mostRecent().args[0]).toBe('scores'); + expect(independenceMock.act.calls.mostRecent().args[1]).toBe(`/scores/delete/${id}`); }); }); }); - describe('remove',function() { - it('should remove the provided index', function() { - expect(filteredScores()).toEqual([mockScore]); - $scores.remove(0); - expect(filteredScores()).toEqual([]); - }); - }); - - describe('add',function() { - beforeEach(function() { - $scores.clear(); - expect(filteredScores()).toEqual([]); - }); - it('should add a score to the list', function() { - $scores.add(mockScore); - expect(filteredScores()).toEqual([mockScore]); - }); - it('should allow duplicates', function() { - // Duplicate scores are 'allowed' during adding, but - // are rejected in scoreboard computation. - $scores.add(mockScore); - $scores.add(mockScore); - expect(filteredScores()).toEqual([mockScore, mockScore]); - expect($scores.validationErrors.length).toBeGreaterThan(0); - }); - it('should accept numeric scores as strings', function() { - var tmp = angular.copy(mockScore); - tmp.score = String(tmp.score); - $scores.add(tmp); - // Note: the 'accepted' score should really be a number, not a string - expect($scores.scores[0].score).toEqual(150); - expect($scores.validationErrors.length).toEqual(0); - }); - it('should accept and convert different casing for DNC', function() { - var tmp = angular.copy(mockScore); - tmp.score = "DnC"; - $scores.add(tmp); - expect($scores.scores[0].score).toEqual("dnc"); - expect($scores.validationErrors.length).toEqual(0); - }); - it('should accept and convert different casing for DSQ', function() { - var tmp = angular.copy(mockScore); - tmp.score = "DsQ"; - $scores.add(tmp); - expect($scores.scores[0].score).toEqual("dsq"); - expect($scores.validationErrors.length).toEqual(0); - }); - it('should reject but convert an empty score', function() { - var tmp = angular.copy(mockScore); - tmp.score = ""; - $scores.add(tmp); - expect($scores.scores[0].score).toEqual(null); - expect($scores.validationErrors.length).toEqual(1); - }); - it('should store the edited date of a score as string',function() { - var tmp = angular.copy(mockScore); - tmp.edited = new Date(2015,1,7); - $scores.add(tmp); - expect(typeof $scores.scores[0].edited).toBe('string'); - }); - }); - - describe('update', function() { - beforeEach(function() { - $scores.clear(); - $scores.add(mockScore); - }); - it('should mark modified scores', function() { - mockScore.score++; - // Simply changing the added score shouldn't matter... - expect($scores.scores[0].score).toEqual(150); - // ... but updating it should - $scores.update(0, mockScore); - expect($scores.scores[0].originalScore).toEqual(150); - expect($scores.scores[0].score).toEqual(151); - expect($scores.scores[0].modified).toBeTruthy(); - expect($scores.scores[0].edited).toBeTruthy(); - }); - it('should accept numeric scores as strings',function() { - mockScore.score = "151"; - $scores.update(0, mockScore); - // Note: the 'accepted' score should really be a number, not a string - expect($scores.scores[0].originalScore).toEqual(150); - expect($scores.scores[0].score).toEqual(151); - }); - it('should throw an error if a score out of range is edited',function() { - var f = function() { - $scores.update(-1,mockScore); - }; - expect(f).toThrowError('unknown score index: -1'); - }); - it('should throw an error if a score out of range is edited',function() { - var f = function() { - $scores.update(1,mockScore); - }; - expect(f).toThrowError('unknown score index: 1'); + describe('update',function() { + it('should call independence act',function() { + var id = '1df9'; + $scores.update({ id: id }).then(function() { + expect(independenceMock.act).toHaveBeenCalled(); + expect(independenceMock.act.calls.mostRecent().args[0]).toBe('scores'); + expect(independenceMock.act.calls.mostRecent().args[1]).toBe(`/scores/update/${id}`); + }); }); }); describe('scoreboard', function() { var board; + beforeEach(function() { board = $scores.scoreboard; }); + function fillScores(input, allowErrors) { $scores.beginupdate(); $scores.clear(); input.map(function(score) { - $scores.add(score); - }); + $scores._addRawScore(score); + }); $scores.endupdate(); if (!allowErrors) { $scores.scores.forEach(function(score) { @@ -405,133 +309,6 @@ describe('ng-scores',function() { expect($scores.scores[0].error).toEqual(jasmine.any($scores.UnknownTeamError)); expect($scores.validationErrors.length).toEqual(1); }); - - it("should allow resolving error", function() { - fillScores([ - { team: team1, stage: mockStage, round: 1, score: 10 }, - { team: team1, stage: mockStage, round: 1, score: 20 }, - ], true); - expect($scores.validationErrors.length).toBeGreaterThan(0); - $scores.update(1, { team: team1, stage: mockStage, round: 2, score: 20 }); - expect($scores.validationErrors.length).toEqual(0); - }); - }); - - describe("pollSheets", function() { - var importedScore; - var mockFiles; - var mockDirs; - - beforeEach(function() { - importedScore = { - file: "sheet_1.json", - team: mockTeam, - stage: mockStage, - round: 1, - score: 456, - originalScore: 456 - }; - mockFiles = { - "scores.json": { version: 2, scores: [], sheets: [] }, - "scoresheets/sheet_1.json": { teamNumber: 123, stageId: "test", round: 1, score: 456 } - }; - mockDirs = { - "scoresheets": ["sheet_1.json"], - }; - fsMock._setFiles(mockFiles); - fsMock._setDirs(mockDirs); - $scores.clear(); - }); - - it("should pick up a new sheet", function() { - return $scores.pollSheets().then(function() { - expect(filteredScores()).toEqual([importedScore]); - }); - }); - - it("should ignore already processed sheets", function() { - return $scores.pollSheets().then(function() { - expect($scores.scores.length).toEqual(1); - return $scores.pollSheets(); - }).then(function() { - expect($scores.scores.length).toEqual(1); - }); - }); - - it("should ignore already processed sheets across loads", function() { - mockFiles["scores.json"] = { version: 2, scores: [], sheets: ["sheet_1.json"] }; - return $scores.load().then(function() { - return $scores.pollSheets(); - }).then(function() { - expect($scores.scores.length).toEqual(0); - }); - }); - - it("should remember processed sheets", function() { - return $scores.pollSheets().then(function() { - expect(fsMock.write).toHaveBeenCalledWith( - 'scores.json', - { - version: 2, - scores: [{ - file: "sheet_1.json", - teamNumber: 123, - stageId: "test", - round: 1, - score: 456, - originalScore: 456, - published: false, - edited: undefined, - table: undefined, - }], - sheets: ["sheet_1.json"] - } - ); - }); - }); - - describe('clicking the button twice should not poll twice (#172)',function() { - it('should not add the same sheet twice',function() { - return $q.all([ - $scores.pollSheets(), - $scores.pollSheets() - ]).then(function() { - expect($scores.scores.length).toEqual(1); - }); - }); - }); - - describe('error recovery',function() { - it('should continue with no sheets when a 404 is returned',function() { - fsMock.list.and.returnValue(Q.reject({status:404})); - $scores.save = jasmine.createSpy('save'); - return $scores.pollSheets().then(function() { - expect(fsMock.write).not.toHaveBeenCalled(); - expect($scores.save).not.toHaveBeenCalled(); - }); - }); - - it('throw an error if an http error is received',function() { - fsMock.list.and.returnValue(Q.reject({status:500,responseText:'server error',statusText:'foo'})); - return $scores.pollSheets().catch(function(err) { - expect(err.message).toEqual('error 500 (foo): server error'); - }); - }); - - it('should rethrow the error if something just goes wrong',function() { - fsMock.list.and.returnValue(Q.reject(new Error('squeek'))); - return $scores.pollSheets().catch(function(err) { - expect(err.message).toEqual('squeek'); - }); - }); - - it('should throw an unknown error if strange stuff is returned',function() { - fsMock.list.and.returnValue(Q.reject('darn')); - return $scores.pollSheets().catch(function(err) { - expect(err.message).toEqual('unknown error: darn'); - }); - }); - }); - }); + }); }); diff --git a/spec/views/rankingSpec.js b/spec/views/rankingSpec.js index f93b6c12..d0a24715 100644 --- a/spec/views/rankingSpec.js +++ b/spec/views/rankingSpec.js @@ -16,7 +16,7 @@ describe('ranking', function() { angular.mock.module(module.name); angular.mock.inject(function($controller, $rootScope,$q) { $scope = $rootScope.$new(); - scoresMock = createScoresMock($q); + scoresMock = createScoresMock(); handshakeMock = createHandshakeMock($q); stagesMock = createStagesMock(); messageMock = createMessageMock(); diff --git a/spec/views/scoresSpec.js b/spec/views/scoresSpec.js index 19d97c6f..c945bfbb 100644 --- a/spec/views/scoresSpec.js +++ b/spec/views/scoresSpec.js @@ -12,7 +12,7 @@ describe('scores', function() { $scope = $rootScope.$new(); $window = _$window_; $q = _$q_; - scoresMock = createScoresMock($q); + scoresMock = createScoresMock(); teamsMock = createTeamsMock(); stagesMock = createStagesMock(); controller = $controller('scoresCtrl', { @@ -58,77 +58,62 @@ describe('scores', function() { describe('removeScore',function() { it('should remove a score',function() { - $scope.removeScore(1); - expect(scoresMock.remove).toHaveBeenCalledWith(1); - expect(scoresMock.save).toHaveBeenCalledWith(); + let score = $scope.scores[0]; + $scope.removeScore(score); + expect(scoresMock.delete).toHaveBeenCalledWith(score); }); }); describe('editScore',function() { it('should edit a score',function() { - $scope.editScore(0); - expect($scope.scores[0].$editing).toBe(true); + let score = $scope.scores[0]; + $scope.editScore(score); + expect(score.$editing).toBe(true); }); }); describe('publishScore',function() { it('should publish a score and save it',function() { - $scope.publishScore(0); - expect(scoresMock.update).toHaveBeenCalledWith(0, {score: 1, index: 0, published: true}); - expect(scoresMock.save).toHaveBeenCalled(); + let score = $scope.scores[0]; + $scope.publishScore(score); + expect(score.published).toBe(true); + expect(scoresMock.update).toHaveBeenCalledWith(score); }); }); describe('unpublishScore',function() { it('should unpublish a score and save it',function() { - $scope.unpublishScore(0); - expect(scoresMock.update).toHaveBeenCalledWith(0, {score: 1, index: 0, published: false}); - expect(scoresMock.save).toHaveBeenCalled(); + let score = $scope.scores[0]; + $scope.unpublishScore(score); + expect(score.published).toBe(false); + expect(scoresMock.update).toHaveBeenCalledWith(score); }); }); describe('finishEditScore',function() { it('should call update and save',function() { - $scope.editScore(0); - $scope.finishEditScore(0); - expect(scoresMock.update).toHaveBeenCalledWith(0, {score: 1, index: 0, $editing: true}); - expect(scoresMock.save).toHaveBeenCalled(); + let score = $scope.scores[0]; + $scope.editScore(score); + $scope.finishEditScore(score); + expect(scoresMock.update).toHaveBeenCalledWith(score); }); it('should alert if an error is thrown from scores',function() { scoresMock.update.and.throwError('update error'); - $scope.editScore(0); - $scope.finishEditScore(0); + let score = $scope.scores[0]; + $scope.editScore(score); + $scope.finishEditScore(score); expect($window.alert).toHaveBeenCalledWith('Error updating score: Error: update error'); }); }); describe('cancelEditScore',function() { it('should call _update to reset the scores',function() { - $scope.editScore(0); - $scope.cancelEditScore(); + let score = $scope.scores[0]; + $scope.editScore(score); + $scope.cancelEditScore(score); + expect(score.$editing).toBe(false); expect(scoresMock._update).toHaveBeenCalled(); }); }); - describe('pollSheets',function() { - xit('should call pollSheets of scores',function() { - $scope.pollSheets(); - expect(scoresMock.pollSheets).toHaveBeenCalled(); - }); - - it('should alert on fail',function() { - scoresMock.pollSheets.and.returnValue($q.reject(new Error('foo'))); - $scope.pollSheets(); - expect(scoresMock.pollSheets).toHaveBeenCalled(); - $scope.$digest(); - expect($window.alert).toHaveBeenCalledWith('failed to poll sheets: Error: foo'); - }); - }); - - describe('refresh',function() { - it('should call load of scores',function() { - $scope.refresh(); - expect(scoresMock.load).toHaveBeenCalled(); - }); - }); }); diff --git a/spec/views/scoresheetSpec.js b/spec/views/scoresheetSpec.js index 698d809f..e5a2193c 100644 --- a/spec/views/scoresheetSpec.js +++ b/spec/views/scoresheetSpec.js @@ -14,6 +14,7 @@ describe('scoresheet',function() { }; var dummyStage = { id: "qualifying", name: "Voorrondes", rounds: 3 }; var fsMock = createFsMock({"settings.json": []}); + var scoresMock; var settingsMock, handshakeMock, challengeMock; beforeEach(function() { @@ -25,6 +26,7 @@ describe('scoresheet',function() { settingsMock = createSettingsMock($q,'settings'); handshakeMock = createHandshakeMock($q); challengeMock = createChallengeMock(); + scoresMock = createScoresMock(); $scope = $rootScope.$new(); $window = { Date: function() { @@ -42,6 +44,7 @@ describe('scoresheet',function() { '$handshake': handshakeMock, '$teams': {}, '$challenge': challengeMock, + '$scores': scoresMock, '$window': $window }); }); @@ -558,39 +561,20 @@ describe('scoresheet',function() { $scope.referee = 'foo'; $scope.signature = [1,2,3,4]; return $scope.save().then(function() { - expect(fsMock.write.calls.mostRecent().args[0]).toEqual('scoresheets/score_qualifying_round1_table7_team123_abcdef01.json'); - expect(fsMock.write.calls.mostRecent().args[1]).toEqual({ - uniqueId: "abcdef01", - team: dummyTeam, - stage: dummyStage, + expect(scoresMock.create).toHaveBeenCalledWith({ + uniqueId: 'abcdef01', + team: { number: '123', name: 'foo' }, + stage: { id: 'qualifying', name: 'Voorrondes', rounds: 3 }, round: 1, table: 7, referee: 'foo', - signature: [1,2,3,4], - score: 0 + signature: [ 1, 2, 3, 4 ], + score: 0, + file: 'score_qualifying_round1_table7_team123_abcdef01.json' }); expect($window.alert).toHaveBeenCalledWith('Thanks for submitting a score of 0 points for team (123) foo in Voorrondes 1.'); }); }); - it('should alert a message if scoresheet cannot be saved', function() { - $scope.team = dummyTeam; - $scope.field = {}; - $scope.stage = dummyStage; - $scope.round = 1; - $scope.table = 7; - var oldId = $scope.uniqueId; - fsMock.write.and.returnValue(Q.reject(new Error('argh'))); - var firstFilename; - return $scope.save().catch(function() { - expect($window.alert).toHaveBeenCalledWith('Error submitting score: Error: argh'); - firstFilename = fsMock.write.calls.mostRecent().args[0]; - // verify that filename stays the same - return $scope.save(); - }).catch(function() { - var secondFilename = fsMock.write.calls.mostRecent().args[0]; - expect(secondFilename).toBe(firstFilename); - }); - }); }); describe('openDesciptionModal',function() { diff --git a/src/components/angular-storage/angular-storage.js b/src/components/angular-storage/angular-storage.js new file mode 100644 index 00000000..6cab8d58 --- /dev/null +++ b/src/components/angular-storage/angular-storage.js @@ -0,0 +1,237 @@ +(function (root, factory) { + 'use strict'; + + if (typeof define === 'function' && define.amd) { + define(['angular'], factory); + } else if (root.hasOwnProperty('angular')) { + // Browser globals (root is window), we don't register it. + factory(root.angular); + } else if (typeof exports === 'object') { + module.exports = factory(require('angular')); + } +}(this , function (angular) { + 'use strict'; + + // In cases where Angular does not get passed or angular is a truthy value + // but misses .module we can fall back to using window. + angular = (angular && angular.module ) ? angular : window.angular; + + + function isStorageSupported($window, storageType) { + + // Some installations of IE, for an unknown reason, throw "SCRIPT5: Error: Access is denied" + // when accessing window.localStorage. This happens before you try to do anything with it. Catch + // that error and allow execution to continue. + + // fix 'SecurityError: DOM Exception 18' exception in Desktop Safari, Mobile Safari + // when "Block cookies": "Always block" is turned on + var supported; + try { + supported = $window[storageType]; + } + catch(err) { + supported = false; + } + + // When Safari (OS X or iOS) is in private browsing mode, it appears as though localStorage and sessionStorage + // is available, but trying to call .setItem throws an exception below: + // "QUOTA_EXCEEDED_ERR: DOM Exception 22: An attempt was made to add something to storage that exceeded the quota." + if(supported) { + var key = '__' + Math.round(Math.random() * 1e7); + try { + $window[storageType].setItem(key, key); + $window[storageType].removeItem(key, key); + } + catch(err) { + supported = false; + } + } + + return supported; + } + + /** + * @ngdoc overview + * @name ngStorage + */ + + return angular.module('ngStorage', []) + + /** + * @ngdoc object + * @name ngStorage.$localStorage + * @requires $rootScope + * @requires $window + */ + + .provider('$localStorage', _storageProvider('localStorage')) + + /** + * @ngdoc object + * @name ngStorage.$sessionStorage + * @requires $rootScope + * @requires $window + */ + + .provider('$sessionStorage', _storageProvider('sessionStorage')); + + function _storageProvider(storageType) { + var providerWebStorage = isStorageSupported(window, storageType); + + return function () { + var storageKeyPrefix = 'ngStorage-'; + + this.setKeyPrefix = function (prefix) { + if (typeof prefix !== 'string') { + throw new TypeError('[ngStorage] - ' + storageType + 'Provider.setKeyPrefix() expects a String.'); + } + storageKeyPrefix = prefix; + }; + + var serializer = angular.toJson; + var deserializer = angular.fromJson; + + this.setSerializer = function (s) { + if (typeof s !== 'function') { + throw new TypeError('[ngStorage] - ' + storageType + 'Provider.setSerializer expects a function.'); + } + + serializer = s; + }; + + this.setDeserializer = function (d) { + if (typeof d !== 'function') { + throw new TypeError('[ngStorage] - ' + storageType + 'Provider.setDeserializer expects a function.'); + } + + deserializer = d; + }; + + this.supported = function() { + return !!providerWebStorage; + }; + + // Note: This is not very elegant at all. + this.get = function (key) { + return providerWebStorage && deserializer(providerWebStorage.getItem(storageKeyPrefix + key)); + }; + + // Note: This is not very elegant at all. + this.set = function (key, value) { + return providerWebStorage && providerWebStorage.setItem(storageKeyPrefix + key, serializer(value)); + }; + + this.remove = function (key) { + providerWebStorage && providerWebStorage.removeItem(storageKeyPrefix + key); + } + + this.$get = [ + '$rootScope', + '$window', + '$log', + '$timeout', + '$document', + + function( + $rootScope, + $window, + $log, + $timeout, + $document + ){ + + // The magic number 10 is used which only works for some keyPrefixes... + // See https://github.com/gsklee/ngStorage/issues/137 + var prefixLength = storageKeyPrefix.length; + + // #9: Assign a placeholder object if Web Storage is unavailable to prevent breaking the entire AngularJS app + // Note: recheck mainly for testing (so we can use $window[storageType] rather than window[storageType]) + var isSupported = isStorageSupported($window, storageType), + webStorage = isSupported || ($log.warn('This browser does not support Web Storage!'), {setItem: angular.noop, getItem: angular.noop, removeItem: angular.noop}), + $storage = { + $default: function(items) { + for (var k in items) { + angular.isDefined($storage[k]) || ($storage[k] = angular.copy(items[k]) ); + } + + $storage.$sync(); + return $storage; + }, + $reset: function(items) { + for (var k in $storage) { + '$' === k[0] || (delete $storage[k] && webStorage.removeItem(storageKeyPrefix + k)); + } + + return $storage.$default(items); + }, + $sync: function () { + for (var i = 0, l = webStorage.length, k; i < l; i++) { + // #8, #10: `webStorage.key(i)` may be an empty string (or throw an exception in IE9 if `webStorage` is empty) + (k = webStorage.key(i)) && storageKeyPrefix === k.slice(0, prefixLength) && ($storage[k.slice(prefixLength)] = deserializer(webStorage.getItem(k))); + } + }, + $apply: function() { + var temp$storage; + + _debounce = null; + + if (!angular.equals($storage, _last$storage)) { + temp$storage = angular.copy(_last$storage); + angular.forEach($storage, function(v, k) { + if (angular.isDefined(v) && '$' !== k[0]) { + webStorage.setItem(storageKeyPrefix + k, serializer(v)); + delete temp$storage[k]; + } + }); + + for (var k in temp$storage) { + webStorage.removeItem(storageKeyPrefix + k); + } + + _last$storage = angular.copy($storage); + } + }, + $supported: function() { + return !!isSupported; + } + }, + _last$storage, + _debounce; + + $storage.$sync(); + + _last$storage = angular.copy($storage); + + $rootScope.$watch(function() { + _debounce || (_debounce = $timeout($storage.$apply, 100, false)); + }); + + // #6: Use `$window.addEventListener` instead of `angular.element` to avoid the jQuery-specific `event.originalEvent` + $window.addEventListener && $window.addEventListener('storage', function(event) { + if (!event.key) { + return; + } + + // Reference doc. + var doc = $document[0]; + + if ( (!doc.hasFocus || !doc.hasFocus()) && storageKeyPrefix === event.key.slice(0, prefixLength) ) { + event.newValue ? $storage[event.key.slice(prefixLength)] = deserializer(event.newValue) : delete $storage[event.key.slice(prefixLength)]; + + _last$storage = angular.copy($storage); + + $rootScope.$apply(); + } + }); + + $window.addEventListener && $window.addEventListener('beforeunload', function() { + $storage.$apply(); + }); + + return $storage; + } + ]; + }; + } + +})); \ No newline at end of file diff --git a/src/js/services/ng-independence.js b/src/js/services/ng-independence.js new file mode 100644 index 00000000..d9e3c8eb --- /dev/null +++ b/src/js/services/ng-independence.js @@ -0,0 +1,70 @@ +/** + * Independence storage: backup for when the server is down. Save the actions now - + * use them later when the server's back up. + */ +define('services/ng-independence',[ + 'services/ng-services' +],function(module) { + "use strict"; + + return module.service('$independence', ['$q','$localStorage', '$http', + function($q,$localStorage,$http) { + function IndependentActionStroage() {} + + function actAheadOfServer(key, url, data) { + $localStorage[`action_${key}_${Date.now()}`] = JSON.stringify({ url: url, data: data }); + } + + IndependentActionStroage.prototype.act = function(token, url, data, fallback) { + var self = this; + return $http.post(url, data).then(function(res) { + self.sendSavedActionsToServer(token); + return res; + }).catch(function(err) { + actAheadOfServer(token, url, data); + if(fallback) { + fallback(); + } + }); + }; + + IndependentActionStroage.prototype.sendSavedActionsToServer = function(token) { + if(this._sendingSavedActionsToServer) return; + this._sendingSavedActionsToServer = true; + + var self = this; + let promises = []; + + for(let key in $localStorage) { + var _break = false; + + if(key.startsWith(`action_${token}`)) { + let action = JSON.parse($localStorage[key]); + + let promise = self.act(key, action.url, action.data).then(function() { + delete $localStorage[key]; + }, function() { + _break = true; + }); + + promises.push(promise); + } + if(_break) break; + } + if(promises.length === 0) { + self._sendingSavedActionsToServer = false; + return; + } + + $q.all(promises).then(function() { + self._sendingSavedActionsToServer = false; + }); + }; + + IndependentActionStroage.prototype.pendingActions = function(token) { + return Object.keys($localStorage).filter((k) => k.startsWith(`action_${token}`)).length; + }; + + return new IndependentActionStroage(); + }]); +}); diff --git a/src/js/services/ng-message.js b/src/js/services/ng-message.js index c12324cf..c478b50d 100644 --- a/src/js/services/ng-message.js +++ b/src/js/services/ng-message.js @@ -12,6 +12,8 @@ define('services/ng-message',[ '$http','$settings','$q', function($http,$settings,$q) { var ws; + var listeners = []; + var token = parseInt(Math.floor(0x100000*(Math.random())), 16); function init() { if (ws) { @@ -38,9 +40,19 @@ define('services/ng-message',[ log("socket close"); }; ws.onmessage = function(msg) { - log("socket message",msg); - // var data = JSON.parse(msg.data); - // handleMessage(data); + var data = JSON.parse(msg.data); + var headers = JSON.parse(msg.headers); + var topic = data.topic; + + msg.from = headers["scoring-token"];; + msg.fromMe = msg.from === token; + + listeners.filter((listener) => { + return (typeof(listener.topic) === 'string' && topic === listener.topic) || + (listener.topic instanceof RegExp && topic.matches(listener.topic)); + }).forEach(function(listener) { + listener.handler(data, msg); + }); }; return def.promise; }); @@ -50,13 +62,18 @@ define('services/ng-message',[ return { send: function(topic,data) { return init().then(function(ws) { + data = data || {}; ws.send(JSON.stringify({ type: "publish", node: ws.node, topic: topic, - data: data + data: data, + headers: { "scoring-token": token } })); }); + }, + on: function(topic, handler) { + listeners.push({ topic: topic, handler: handler }); } }; } diff --git a/src/js/services/ng-scores.js b/src/js/services/ng-scores.js index 0bea8f7e..f76a0e53 100644 --- a/src/js/services/ng-scores.js +++ b/src/js/services/ng-scores.js @@ -12,11 +12,11 @@ define('services/ng-scores',[ // Current file version for scores. // Increment when adding/removing 'features' to stored scores. - var SCORES_VERSION = 2; + var SCORES_VERSION = 3; return module.service('$scores', - ['$rootScope', '$fs', '$stages', '$q', '$teams', - function($rootScope, $fs, $stages, $q, $teams) { + ['$rootScope', '$fs', '$stages', '$q', '$teams', '$message','$independence', + function($rootScope, $fs, $stages, $q, $teams, $message, $independence) { // Replace placeholders in format string. // Example: format("Frobnicate {0} {1} {2}", "foo", "bar") @@ -129,7 +129,6 @@ define('services/ng-scores',[ this._updating = 0; this._initialized = null; // Promise - this._pollingSheets = null; // Promise this.init(); } @@ -153,28 +152,17 @@ define('services/ng-scores',[ return self.load(); }); } + $message.on('scores:reload', function(data, msg) { + if(msg.fromMe){ + return; + } + self.load(); + }); + return this._initialized; }; - Scores.prototype.clear = function() { - this._rawScores = []; - this._update(); - }; - - Scores.prototype.save = function() { - var data = { - version: 2, - scores: this._rawScores, - sheets: Object.keys(this._sheets), - }; - return $fs.write('scores.json', data).then(function() { - log('scores saved'); - }, function(err) { - log('scores write error', err); - }); - }; - Scores.prototype.load = function() { var self = this; return $fs.read('scores.json').then(function(res) { @@ -201,7 +189,7 @@ define('services/ng-scores',[ } self.clear(); scores.forEach(function(score) { - self.add(score); + self._addRawScore(score); }); self._sheets = {}; sheetNames.forEach(function(name) { self._sheets[name] = true; }); @@ -214,20 +202,10 @@ define('services/ng-scores',[ }); }; - Scores.prototype.remove = function(index) { - // TODO: this function used to remove an associated - // score sheet file. - // However, as creating that scoresheet was not - // the concern of this class, I (Martin) decided - // that removing it should not be its concern either. - // Note that e.g. the clear() method also did not - // remove 'obsolete' scoresheet files. - // Additionally note that a scoresheet may be the digital - // representation of a 'physical' scoresheet, something - // with a signature even, and may indeed be a very different - // beast than 'merely' a score entry. - this._rawScores.splice(index, 1); - this._update(); + Scores.prototype.clear = function() { + this._rawScores = []; + this._sheets = {}; + this.scores = []; }; /** @@ -290,27 +268,30 @@ define('services/ng-scores',[ }; } - Scores.prototype.add = function(score) { - // Create a copy of the score, in case the - // original score is being modified... - this._rawScores.push(sanitizeEntry(score)); - this._update(); + Scores.prototype.create = function(scoresheet) { + var self = this; + + var score = sanitizeEntry(scoresheet); + return $independence.act('scores','/scores/create',{ scoresheet: scoresheet, score: score }, function() { + self._rawScores.push(score); + }).then((res) => self._update(res)); }; - /** - * Update score at given index. - * This differs from e.g. remove(index); add(score); in that - * it ensures that only allowed changes are made, and marks the - * the score as modified. - */ - Scores.prototype.update = function(index, score) { - if (index < 0 || index >= this._rawScores.length) { - throw new RangeError("unknown score index: " + index); - } - var newScore = sanitizeEntry(score); - newScore.edited = (new Date()).toString(); - this._rawScores.splice(index, 1, newScore); - this._update(); + Scores.prototype.delete = function(score) { + var self = this; + + return $independence.act('scores','/scores/delete/' + score.id, {}, function() { + self._rawScores.splice(self.scores.findIndex(s => s.id === score.id), 1); + }).then((res) => self._update(res)); + }; + + Scores.prototype.update = function(score) { + var self = this; + + score.edited = (new Date()).toString(); + return $independence.act('scores','/scores/update/' + score.id, score, function() { + self._rawScores[self.scores.findIndex(s => s.id === score.id)] = score; + }).then((res) => self._update(res)); }; Scores.prototype.beginupdate = function() { @@ -327,90 +308,21 @@ define('services/ng-scores',[ } }; - /** - * Poll storage for any new score sheets. - * Ignore already processed sheets, add a new score entry for each - * new sheet. - * FIXME: this is a temporary hack to get basic multi-user scoring - * working very quickly. Functionality like this should be moved to - * a server-instance and/or be distributed. The reason for integrating - * it directly in $scores for now, is that this reduces the change of - * having the state of processed sheets getting out of sync with the - * list of scores. - */ - Scores.prototype.pollSheets = function() { - var self = this; - // Prevent (accidentally) performing the check in parallel - if (self._pollingSheets) { - return self._pollingSheets; - } - - self._pollingSheets = $fs.list("scoresheets/").catch(function(err) { - // Ignore the fact that there are no sheets at all yet - if (err.status === 404) { - return []; - } - // Convert to 'normal' errors in case of XHR response - if (err.status && err.responseText) { - throw new Error(format("error {0} ({1}): {2}", - err.status, err.statusText, - err.responseText - )); - } - // Otherwise, pass the error on - if (err instanceof Error) { - throw err; - } - // Fallback - throw new Error("unknown error: " + String(err)); - }).then(function(filenames) { - var promises = []; - // Walk over all sheets, find the 'new' ones - filenames.forEach(function(filename) { - if (filename in self._sheets) { - return; - } - // Retrieve the new sheet - var p = $fs.read("scoresheets/" + filename).then(function(sheet) { - // Convert to score entry and add to list - var score = { - file: filename, - teamNumber: sheet.teamNumber !== undefined ? sheet.teamNumber : sheet.team.number, - stageId: sheet.stageId !== undefined ? sheet.stageId : sheet.stage.id, - round: sheet.round, - score: sheet.score, - table: sheet.table - }; - self.add(score); - // Mark as processed - self._sheets[filename] = true; - log(format("Added new scoresheet: stage {0}, round {1}, team {2}, score {3}", - score.stageId, score.round, score.teamNumber, score.score - )); - }); - promises.push(p); - }); - // Make sure to wait for all sheets to be processed - // before resolving the promise. - return $q.all(promises).finally(function() { - // Always save scores if there was work to do, - // even in case of errors, as some scores may still - // have been added successfully. - if (promises.length > 0) { - return self.save(); - } - }); - }).finally(function() { - self._pollingSheets = null; - }); - return self._pollingSheets; + // Making this function visible only for testing + Scores.prototype._addRawScore = function(score) { + this._rawScores.push(sanitizeEntry(score)); }; - Scores.prototype._update = function() { + Scores.prototype._update = function(response) { if (this._updating > 0) { return; } + if(response) { + this._rawScores = response.scores.map(sanitizeEntry); + this._sheets = response.sheets; + } + var self = this; var results = this.getRankings(); @@ -440,6 +352,7 @@ define('services/ng-scores',[ } }); $rootScope.$broadcast('validationError', this.validationErrors); + $message.send('scores:reload'); }; /** diff --git a/src/js/views/scores.js b/src/js/views/scores.js index 574ab79c..8893d575 100644 --- a/src/js/views/scores.js +++ b/src/js/views/scores.js @@ -20,58 +20,44 @@ define('views/scores',[ $scope.rev = (String($scope.sort) === String(col)) ? !$scope.rev : !!defaultSort; $scope.sort = col; }; - $scope.removeScore = function(index) { - $scores.remove(index); - return $scores.save(); + $scope.removeScore = function(scoreId) { + return $scores.delete(scoreId); }; - $scope.editScore = function(index) { - var score = $scores.scores[index]; + $scope.editScore = function(score) { score.$editing = true; }; - $scope.publishScore = function(index) { - var score = $scores.scores[index]; + $scope.publishScore = function(score) { score.published = true; saveScore(score); }; - $scope.unpublishScore = function(index) { - var score = $scores.scores[index]; + $scope.unpublishScore = function(score) { score.published = false; saveScore(score); }; - $scope.finishEditScore = function(index) { + $scope.finishEditScore = function(score) { // The score entry is edited 'inline', then used to // replace the entry in the scores list and its storage. // Because scores are always 'sanitized' before storing, // the $editing flag is automatically discarded. - var score = $scores.scores[index]; - saveScore(score); - }; - - function saveScore(score) { try { - $scores.update(score.index, score); - $scores.save(); + saveScore(score); } catch(e) { - $window.alert("Error updating score: " + e); + alert(`Error updating score: ${e}`); } - } - - $scope.cancelEditScore = function() { - $scores._update(); }; - $scope.pollSheets = function() { - return $scores.pollSheets().catch(function(err) { - log("pollSheets() failed", err); - $window.alert("failed to poll sheets: " + err); + function saveScore(score) { + $scores.update(score).catch(function(err) { + $window.alert("Error updating score: " + err); }); - }; + } - $scope.refresh = function() { - $scores.load(); + $scope.cancelEditScore = function(score) { + score.$editing = false; + $scores._update(); }; } ]); diff --git a/src/js/views/scoresheet.js b/src/js/views/scoresheet.js index 1e18838a..db87ee2c 100644 --- a/src/js/views/scoresheet.js +++ b/src/js/views/scoresheet.js @@ -23,8 +23,8 @@ define('views/scoresheet',[ ]); return module.controller(moduleName + 'Ctrl', [ - '$scope','$fs','$stages','$settings','$challenge','$window','$q','$teams','$handshake', - function($scope,$fs,$stages,$settings,$challenge,$window,$q,$teams,$handshake) { + '$scope','$fs','$stages','$scores','$settings','$challenge','$window','$q','$teams','$handshake', + function($scope,$fs,$stages,$scores,$settings,$challenge,$window,$q,$teams,$handshake) { log('init scoresheet ctrl'); // Set up defaults @@ -242,7 +242,7 @@ define('views/scoresheet',[ data.signature = $scope.signature; data.score = $scope.score(); - var fn = [ + data.file = [ 'score', data.stage.id, 'round' + data.round, @@ -251,17 +251,13 @@ define('views/scoresheet',[ data.uniqueId ].join('_')+'.json'; - return $fs.write("scoresheets/" + fn,data).then(function() { - log('result saved'); - $scope.clear(); - $window.alert('Thanks for submitting a score of ' + - data.score + - ' points for team (' + data.team.number + ') ' + data.team.name + - ' in ' + data.stage.name + ' ' + data.round + '.' - ); - }, function(err) { - $window.alert('Error submitting score: ' + String(err)); - throw err; + return $scores.create(data).then(function() { + $window.alert(`Thanks for submitting a score of ${data.score}` + + ` points for team (${data.team.number})` + + ` ${data.team.name} in ${data.stage.name} ${data.round}.`); + }).catch(function() { + $window.alert(`Error submitting score to the server. +The score will be saved locally until contact with the server is resotred`); }); }; diff --git a/src/views/pages/scores.html b/src/views/pages/scores.html index 0e21587c..5a7443cd 100644 --- a/src/views/pages/scores.html +++ b/src/views/pages/scores.html @@ -7,10 +7,6 @@

- - -
-

Showing {{scores.length}} scores.

@@ -76,39 +72,39 @@