├── .nvmrc ├── .eslintignore ├── .gitignore ├── icon.ico ├── icon.icns ├── src ├── windows │ ├── theme.js │ ├── img │ │ ├── icon.png │ │ ├── pause.png │ │ ├── play.png │ │ ├── skip.png │ │ ├── configure.png │ │ └── sad-cyclops.png │ ├── timer │ │ ├── default.mp3 │ │ ├── index.html │ │ ├── index.css │ │ └── index.js │ ├── theme.css │ ├── fullscreen │ │ ├── index.html │ │ ├── index.js │ │ └── index.css │ ├── window-snapper.js │ ├── config │ │ ├── index.css │ │ ├── index.html │ │ └── index.js │ └── windows.js ├── clipboard.js ├── state │ ├── state-persister.js │ ├── timer.js │ ├── mobbers.js │ └── timer-state.js └── main.js ├── timer-example.png ├── .travis.yml ├── test ├── state │ ├── test-timer.js │ ├── state-persister.specs.js │ ├── timer.specs.js │ ├── mobbers.specs.js │ └── timer-state.specs.js ├── node-version.specs.js └── clipboard.specs.js ├── .eslintrc.json ├── package.json ├── README.md └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.14 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | *.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/icon.ico -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/icon.icns -------------------------------------------------------------------------------- /src/windows/theme.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mobberBorderHighlightColor: '#f15b2a' 3 | } 4 | -------------------------------------------------------------------------------- /timer-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/timer-example.png -------------------------------------------------------------------------------- /src/windows/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/icon.png -------------------------------------------------------------------------------- /src/windows/img/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/pause.png -------------------------------------------------------------------------------- /src/windows/img/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/play.png -------------------------------------------------------------------------------- /src/windows/img/skip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/skip.png -------------------------------------------------------------------------------- /src/windows/img/configure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/configure.png -------------------------------------------------------------------------------- /src/windows/timer/default.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/timer/default.mp3 -------------------------------------------------------------------------------- /src/windows/img/sad-cyclops.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluralsight/mob-timer/HEAD/src/windows/img/sad-cyclops.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: npm 3 | node_js: 4 | - "10.14" 5 | os: 6 | - windows 7 | - osx 8 | - linux 9 | script: 10 | - npm run test 11 | - if [[ "$TRAVIS_OS_NAME" == "windows" ]]; then npm run build-win; fi 12 | - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then npm run build-mac; fi 13 | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then npm run build-linux; fi 14 | after_script: 15 | - ls -R dist -------------------------------------------------------------------------------- /test/state/test-timer.js: -------------------------------------------------------------------------------- 1 | class TestTimer { 2 | constructor(options, callback) { 3 | this.options = options 4 | this.callback = callback 5 | this.isRunning = false 6 | } 7 | 8 | start() { 9 | this.isRunning = true 10 | } 11 | 12 | pause() { 13 | this.isRunning = false 14 | } 15 | 16 | reset(value) { 17 | this.time = value 18 | } 19 | } 20 | 21 | module.exports = TestTimer 22 | -------------------------------------------------------------------------------- /src/clipboard.js: -------------------------------------------------------------------------------- 1 | const clipboardy = require('clipboardy') 2 | 3 | module.exports = { 4 | clearClipboardHistory(numberOfItemsHistoryStores) { 5 | const millisecondsNeededBetweenWrites = 180 6 | let i = 1 7 | let id = setInterval(writeToClipboard, millisecondsNeededBetweenWrites) 8 | 9 | function writeToClipboard() { 10 | if (i < numberOfItemsHistoryStores) { 11 | clipboardy.writeSync(i.toString()) 12 | i++ 13 | } else { 14 | clipboardy.writeSync('') 15 | clearInterval(id) 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/node-version.specs.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const assert = require('assert') 3 | 4 | describe('Node version', () => { 5 | it('.nvmrc should match .travis.yml', () => { 6 | const nvmrc = fs.readFileSync('./.nvmrc', 'utf-8') 7 | const travisYml = fs.readFileSync('./.travis.yml', 'utf-8') 8 | 9 | const matches = travisYml.indexOf(` - "${nvmrc}"`) !== -1 10 | const message = [ 11 | 'Could not find node version from .nvmrc in .travis.yml!\n', 12 | '.nvmrc', nvmrc, '.travis.yml', travisYml] 13 | assert.ok(matches, message.join('\n')) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/windows/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme-primary-color: #f15b2a; 3 | --theme-secondary-color: #ec008c; 4 | 5 | --main-text-color: #000; 6 | --main-background-color: #fff; 7 | --disabled-text-color: #999; 8 | --table-stripe-color: #dcdcdc; 9 | 10 | --button-text-color: #fff; 11 | --button-background-color: var(--theme-primary-color); 12 | 13 | --mobber-border-color: #eee; 14 | --mobber-border-highlight-color: var(--theme-primary-color); 15 | 16 | --timer-pulse-text-color: #fff; 17 | --timer-pulse-color-1: var(--theme-primary-color); 18 | --timer-pulse-color-2: var(--theme-secondary-color); 19 | 20 | --timer-paused-text-color: #999; 21 | --timer-paused-background-color: #333; 22 | } 23 | -------------------------------------------------------------------------------- /src/state/state-persister.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const os = require('os') 3 | const path = require('path') 4 | 5 | const mobTimerDir = path.join(os.homedir(), '.mob-timer') 6 | const stateFile = path.join(mobTimerDir, 'state.json') 7 | const oldStateFile = path.join(os.tmpdir(), 'state.json') 8 | 9 | function read() { 10 | if (fs.existsSync(stateFile)) { 11 | return JSON.parse(fs.readFileSync(stateFile, 'utf-8')) 12 | } 13 | if (fs.existsSync(oldStateFile)) { 14 | return JSON.parse(fs.readFileSync(oldStateFile, 'utf-8')) 15 | } 16 | return {} 17 | } 18 | 19 | function write(state) { 20 | if (!fs.existsSync(mobTimerDir)) { 21 | fs.mkdirSync(mobTimerDir) 22 | } 23 | fs.writeFileSync(stateFile, JSON.stringify(state)) 24 | } 25 | 26 | module.exports = { 27 | read, 28 | write, 29 | stateFile, 30 | oldStateFile, 31 | mobTimerDir 32 | } 33 | -------------------------------------------------------------------------------- /src/windows/fullscreen/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mob Timer 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 | {name}, please sit at the keyboard 16 |
17 |
18 | 19 | 20 | 21 |
22 | 25 | 26 |
27 | 28 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /test/clipboard.specs.js: -------------------------------------------------------------------------------- 1 | const clipboard = require('../src/clipboard') 2 | const clipboardy = require('clipboardy') 3 | const sinon = require('sinon') 4 | let assert = require('assert') 5 | 6 | describe('clipboard', () => { 7 | describe('clearClipboardHistory', () => { 8 | before(() => { 9 | clipboardy.writeSync('general kenboi') 10 | sinon.spy(clipboardy, 'writeSync') 11 | clipboard.clearClipboardHistory(expectedTimesWriteSyncIsCalled) 12 | }) 13 | 14 | after(() => { 15 | clipboardy.writeSync.restore() 16 | }) 17 | 18 | it('should have cleared the clip board', function(done) { 19 | setTimeout(function() { 20 | assert.strictEqual(clipboardy.readSync(), '') 21 | done() 22 | }, 700) 23 | }) 24 | 25 | it('should call writeSync the correct number of times', () => { 26 | sinon.assert.callCount(clipboardy.writeSync, expectedTimesWriteSyncIsCalled) 27 | }) 28 | 29 | let expectedTimesWriteSyncIsCalled = 3 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/windows/fullscreen/index.js: -------------------------------------------------------------------------------- 1 | const ipc = require('electron').ipcRenderer 2 | 3 | const skipBtn = document.getElementById('skip') 4 | const startTurnBtn = document.getElementById('startTurn') 5 | const configureBtn = document.getElementById('configure') 6 | const currentEl = document.getElementById('current') 7 | const currentPicEl = document.getElementById('currentPic') 8 | const nextEl = document.getElementById('next') 9 | const nextPicEl = document.getElementById('nextPic') 10 | 11 | ipc.on('rotated', (event, data) => { 12 | if (!data.current) { 13 | data.current = { name: 'Add a mobber' } 14 | } 15 | currentEl.innerHTML = data.current.name 16 | currentPicEl.src = data.current.image || '../img/sad-cyclops.png' 17 | 18 | if (!data.next) { 19 | data.next = data.current 20 | } 21 | nextEl.innerHTML = data.next.name 22 | nextPicEl.src = data.next.image || '../img/sad-cyclops.png' 23 | }) 24 | 25 | skipBtn.addEventListener('click', () => ipc.send('skip')) 26 | startTurnBtn.addEventListener('click', () => ipc.send('startTurn')) 27 | configureBtn.addEventListener('click', () => ipc.send('configure')) 28 | 29 | ipc.send('fullscreenWindowReady') 30 | -------------------------------------------------------------------------------- /src/windows/timer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mob Timer 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 |
{name}
18 | 19 | 20 | 21 |
22 | 23 | 24 | {name} 25 |
26 |
27 | 28 | 31 | 32 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/windows/window-snapper.js: -------------------------------------------------------------------------------- 1 | let getEdges = bounds => { 2 | return { 3 | top: bounds.y, 4 | bottom: bounds.y + bounds.height, 5 | left: bounds.x, 6 | right: bounds.x + bounds.width 7 | } 8 | } 9 | 10 | let isCloseTo = (a, b, snapThreshold) => { 11 | return Math.abs(a - b) <= snapThreshold 12 | } 13 | 14 | module.exports = (windowBounds, screenBounds, snapThreshold) => { 15 | if (snapThreshold <= 0) { 16 | return { x: windowBounds.x, y: windowBounds.y } 17 | } 18 | 19 | let windowEdges = getEdges(windowBounds) 20 | let screenEdges = getEdges(screenBounds) 21 | let snapTo = { x: windowBounds.x, y: windowBounds.y } 22 | 23 | if (isCloseTo(windowEdges.left, screenEdges.left, snapThreshold)) { 24 | snapTo.x = screenEdges.left 25 | } 26 | 27 | if (isCloseTo(windowEdges.right, screenEdges.right, snapThreshold)) { 28 | snapTo.x = screenEdges.right - windowBounds.width 29 | } 30 | 31 | if (isCloseTo(windowEdges.top, screenEdges.top, snapThreshold)) { 32 | snapTo.y = screenEdges.top 33 | } 34 | 35 | if (isCloseTo(windowEdges.bottom, screenEdges.bottom, snapThreshold)) { 36 | snapTo.y = screenEdges.bottom - windowBounds.height 37 | } 38 | 39 | return snapTo 40 | } 41 | -------------------------------------------------------------------------------- /src/state/timer.js: -------------------------------------------------------------------------------- 1 | class Timer { 2 | constructor(options, callback) { 3 | this.rateMilliseconds = options.rateMilliseconds || 1000 4 | this.time = options.time || 0 5 | this.timeDelta = 0 6 | this.countDown = options.countDown === true 7 | this.callback = callback 8 | this.startingTime = null 9 | } 10 | 11 | start(now = Date.now) { 12 | this.startingTime = now() 13 | 14 | if (!this.interval) { 15 | this.interval = setInterval(() => { 16 | const secondsPassed = Math.floor((now() - this.startingTime) / 1000) 17 | this.timeDelta = secondsPassed 18 | const secondsRemaining = this.time - secondsPassed 19 | 20 | this.callback(this.countDown ? secondsRemaining : secondsPassed + this.time) 21 | }, this.rateMilliseconds) 22 | } 23 | } 24 | 25 | pause() { 26 | if (this.interval) { 27 | clearInterval(this.interval) 28 | this.interval = null 29 | } 30 | this.countDown 31 | ? (this.time = this.time - this.timeDelta) 32 | : (this.time += this.timeDelta) 33 | this.timeDelta = 0 34 | } 35 | 36 | reset(value, now = Date.now) { 37 | this.time = value 38 | this.timeDelta = 0 39 | this.startingTime = now() 40 | } 41 | } 42 | 43 | module.exports = Timer 44 | -------------------------------------------------------------------------------- /src/windows/fullscreen/index.css: -------------------------------------------------------------------------------- 1 | @import url('../theme.css'); 2 | 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | font-family: sans-serif; 7 | user-select: none; 8 | color: var(--main-text-color); 9 | background-color: var(--main-background-color); 10 | } 11 | .container { 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: center; 16 | height: 100vh; 17 | } 18 | .current { 19 | font-size: 38px; 20 | white-space: nowrap; 21 | overflow: hidden; 22 | text-overflow: ellipsis; 23 | width: 90%; 24 | text-align: center; 25 | margin-bottom: 15px; 26 | } 27 | .controls { 28 | display: flex; 29 | margin-bottom: 15px; 30 | } 31 | .btn { 32 | background: var(--button-background-color); 33 | border-radius: 3px; 34 | border: 0; 35 | color: var(--button-text-color); 36 | padding: 10px 20px; 37 | margin: 0 5px; 38 | cursor: pointer; 39 | font-size: 20px; 40 | } 41 | .next { 42 | font-size: 20px; 43 | white-space: nowrap; 44 | overflow: hidden; 45 | text-overflow: ellipsis; 46 | width: 90%; 47 | text-align: center; 48 | } 49 | .pic { 50 | border-radius: 50%; 51 | border: 2px solid var(--mobber-border-color); 52 | } 53 | .current .pic { 54 | width: 150px; 55 | height: 150px; 56 | border-width: 5px; 57 | } 58 | .next .pic { 59 | width: 30px; 60 | height: 30px; 61 | vertical-align: bottom; 62 | } 63 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:node/recommended", 5 | "plugin:promise/recommended", 6 | "standard" 7 | ], 8 | "env": { 9 | "mocha": true 10 | }, 11 | "plugins": [ 12 | "mocha", 13 | "node" 14 | ], 15 | "globals": { 16 | "expect": true 17 | }, 18 | "overrides": [ 19 | { 20 | "files": "*.spec.js", 21 | "rules": { 22 | "no-unused-expressions": "off" 23 | } 24 | } 25 | ], 26 | "rules": { 27 | "array-bracket-spacing": "error", 28 | "arrow-parens": [ 29 | 2, 30 | "as-needed" 31 | ], 32 | "eol-last": "error", 33 | "generator-star-spacing": "off", 34 | "mocha/no-exclusive-tests": "error", 35 | "node/no-unpublished-require": [ 36 | "error", 37 | { 38 | "allowModules": [ 39 | "clipboardy", 40 | "electron", 41 | "chai", 42 | "sinon" 43 | ] 44 | } 45 | ], 46 | "no-unused-vars": [ 47 | 2, 48 | { 49 | "vars": "all", 50 | "args": "after-used", 51 | "ignoreRestSiblings": true 52 | } 53 | ], 54 | "space-before-function-paren": [ 55 | "error", 56 | "never" 57 | ], 58 | "standard/object-curly-even-spacing": [ 59 | 2, 60 | "always" 61 | ], 62 | "yield-star-spacing": [ 63 | "error", 64 | { 65 | "before": false, 66 | "after": true 67 | } 68 | ] 69 | } 70 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mob-timer", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/main.js", 6 | "scripts": { 7 | "start": "electron .", 8 | "build-win": "electron-packager . --platform=win32 --arch=x64 --out=dist --icon=icon --overwrite", 9 | "build-mac": "electron-packager . --platform=darwin --arch=x64 --out=dist --icon=icon --overwrite", 10 | "build-linux": "electron-packager . --platform=linux --arch=x64 --out=dist --icon=icon --overwrite", 11 | "lint": "eslint .", 12 | "pretest": "npm run lint", 13 | "test": "mocha --use_strict --recursive", 14 | "tdd": "mocha --use_strict --recursive -w", 15 | "watch": "mocha --use_strict --recursive -w" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/pluralsight/mob-timer.git" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/pluralsight/mob-timer/issues" 25 | }, 26 | "homepage": "https://github.com/pluralsight/mob-timer#readme", 27 | "devDependencies": { 28 | "electron": "^1.8.3", 29 | "electron-packager": "^12.1.0", 30 | "eslint": "^5.8.0", 31 | "eslint-config-standard": "^12.0.0", 32 | "eslint-plugin-import": "^2.14.0", 33 | "eslint-plugin-mocha": "^5.2.0", 34 | "eslint-plugin-node": "^8.0.0", 35 | "eslint-plugin-promise": "^4.0.1", 36 | "eslint-plugin-standard": "^4.0.0", 37 | "mocha": "^5.0.4", 38 | "sinon": "^7.1.1" 39 | }, 40 | "dependencies": { 41 | "clipboardy": "^1.2.3", 42 | "uuid": "^3.2.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/state/mobbers.js: -------------------------------------------------------------------------------- 1 | const newGuid = require('uuid/v4') 2 | 3 | class Mobbers { 4 | constructor() { 5 | this.mobbers = [] 6 | this.currentMobber = 0 7 | } 8 | 9 | getAll() { 10 | return this.mobbers 11 | } 12 | 13 | addMobber(mobber) { 14 | if (!mobber.id) { 15 | mobber.id = newGuid() 16 | } 17 | this.mobbers.push(mobber) 18 | } 19 | 20 | getActiveMobbers() { 21 | return this.mobbers.filter(m => !m.disabled) 22 | } 23 | 24 | getCurrentAndNextMobbers() { 25 | let active = this.getActiveMobbers() 26 | if (!active.length) { 27 | return { current: null, next: null } 28 | } 29 | 30 | return { 31 | current: active[this.currentMobber], 32 | next: active[(this.currentMobber + 1) % active.length] 33 | } 34 | } 35 | 36 | rotate() { 37 | let active = this.getActiveMobbers() 38 | this.currentMobber = active.length ? (this.currentMobber + 1) % active.length : 0 39 | } 40 | 41 | removeMobber(mobber) { 42 | this.mobbers = this.mobbers.filter(m => m.id !== mobber.id) 43 | if (this.currentMobber >= this.getActiveMobbers().length) { 44 | this.currentMobber = 0 45 | } 46 | } 47 | 48 | updateMobber(mobber) { 49 | let currentMobber = this.getActiveMobbers()[this.currentMobber] 50 | let index = this.mobbers.findIndex(m => m.id === mobber.id) 51 | if (index >= 0) { 52 | this.mobbers[index] = mobber 53 | let active = this.getActiveMobbers() 54 | if (currentMobber && currentMobber.id !== mobber.id) { 55 | this.currentMobber = active.findIndex(m => m.id === currentMobber.id) 56 | } 57 | this.currentMobber = active.length ? this.currentMobber % active.length : 0 58 | } 59 | } 60 | 61 | shuffleMobbers() { 62 | for (let i = this.mobbers.length - 1; i >= 0; i--) { 63 | const j = Math.floor(Math.random() * (i + 1)); 64 | [this.mobbers[i], this.mobbers[j]] = [this.mobbers[j], this.mobbers[i]] 65 | } 66 | } 67 | } 68 | 69 | module.exports = Mobbers 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pluralsight Mob Timer 2 | A cross-platform timer built on [Electron](http://electron.atom.io/) 3 | for doing [Mob Programming](http://mobprogramming.org/). 4 | 5 | **Heads up!** There is also an [extension](https://marketplace.visualstudio.com/items?itemName=pluralsight.live-share-mob-timer) 6 | for Visual Studio Code's Live Share experience. 7 | This is a different, standalone, project. 8 | 9 | ![Example Timer Image](timer-example.png) 10 | 11 | Click the gear icon in the top right to configure the timer. 12 | Then click the large circle to start/stop the timer, 13 | or the smaller circle to skip to the next mobber. 14 | 15 | 16 | # Build the timer 17 | Run `npm install` and then one of the following commands for your respective operating system: 18 | - Windows: `npm run build-win` 19 | - Mac OS X: `npm run build-mac` 20 | - Linux: `npm run build-linux` (You may need to install `libcanberra-gtk-module`) 21 | 22 | Platform specific packages will be placed in the `dist` directory. 23 | If you need a platform other than these, you will need to modify the build script in the `package.json` file. 24 | 25 | 26 | # Development 27 | Run `npm install` to get the dependencies, then `npm start` to run the timer. 28 | Run `npm test` to run the unit tests once, or alternatively `npm run watch` to run them on changes. 29 | 30 | 31 | # Motivation 32 | Pluralsight has a development team that does mob programming full-time, 33 | and a few other teams dabble in mobbing as well. 34 | We have tried and enjoyed a number of other mob timers, but we had various 35 | (mostly minor) gripes with them. 36 | So we decided to build one of our own. 37 | 38 | We had a few goals: 39 | 40 | * Make a timer that is hard to ignore, but also not overly annoying 41 | * Implement escalating alerts 42 | * Customization 43 | * Have a timer that we can easily hack on, built with tech we know 44 | 45 | 46 | # License 47 | 48 | The Pluralsight Mob Timer is licensed under the [Apache 2.0 license](LICENSE). 49 | -------------------------------------------------------------------------------- /src/windows/config/index.css: -------------------------------------------------------------------------------- 1 | @import url('../theme.css'); 2 | 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | font-family: sans-serif; 7 | color: var(--main-text-color); 8 | background-color: var(--main-background-color); 9 | } 10 | 11 | input { 12 | height: 22px; 13 | } 14 | 15 | .container { 16 | margin: 20px; 17 | } 18 | 19 | .minutes { 20 | width: 40px; 21 | } 22 | 23 | .mobber { 24 | display: flex; 25 | padding: 4px; 26 | align-items: center; 27 | } 28 | 29 | .mobber .image { 30 | margin-right: 5px; 31 | height: 24px; 32 | width: 24px; 33 | border-radius: 50%; 34 | border: 2px solid var(--mobber-border-color); 35 | cursor: pointer; 36 | } 37 | 38 | .mobber .image:hover { 39 | border: 2px solid var(--mobber-border-highlight-color); 40 | } 41 | 42 | .mobber:nth-child(odd) { 43 | background-color: var(--table-stripe-color); 44 | } 45 | 46 | .mobber .btn:first-of-type { 47 | display: block; 48 | margin-left: auto; 49 | } 50 | 51 | .mobber.disabled .image { 52 | opacity: .3; 53 | } 54 | 55 | .mobber.disabled .name { 56 | font-style: italic; 57 | color: var(--disabled-text-color); 58 | text-decoration: line-through; 59 | } 60 | 61 | .oneLineInputAndButton { 62 | margin: 4px 0; 63 | } 64 | 65 | .addLabel { 66 | display: flex; 67 | align-items: center; 68 | } 69 | 70 | .mobberListActions { 71 | margin: 4px 0 4px -5px; 72 | display: flex; 73 | } 74 | 75 | .btn { 76 | background-color: var(--button-background-color); 77 | border: 0; 78 | border-radius: 3px; 79 | padding: 7px 10px; 80 | color: var(--button-text-color); 81 | margin-left: 5px; 82 | cursor: pointer; 83 | } 84 | 85 | h1 { 86 | font-size: 18px; 87 | border-bottom: 1px solid var(--main-text-color); 88 | margin-top: 20px; 89 | } 90 | 91 | input[type="checkbox" i] { 92 | position: relative; 93 | vertical-align: middle; 94 | } 95 | 96 | .settings > div { 97 | margin-bottom: 10px; 98 | } 99 | 100 | .fullscreen-seconds { 101 | width: 40px; 102 | } 103 | 104 | .replay-audio-seconds { 105 | width: 40px; 106 | } 107 | 108 | #replayAudioContainer.disabled { 109 | color: var(--disabled-text-color); 110 | } 111 | 112 | .custom-sound { 113 | width: 145px; 114 | } 115 | 116 | .clipboard-history-items { 117 | width: 40px; 118 | } 119 | -------------------------------------------------------------------------------- /test/state/state-persister.specs.js: -------------------------------------------------------------------------------- 1 | const persister = require('../../src/state/state-persister') 2 | const sinon = require('sinon') 3 | const fs = require('fs') 4 | const assert = require('assert') 5 | 6 | describe('state-persister', () => { 7 | const sandbox = sinon.createSandbox() 8 | 9 | afterEach(() => sandbox.restore()) 10 | 11 | describe('read', () => { 12 | const stateData = { some: 'state' } 13 | const oldStateData = { older: 'data' } 14 | 15 | beforeEach(() => { 16 | sandbox.stub(fs, 'readFileSync') 17 | .withArgs(persister.stateFile, 'utf-8').callsFake(() => JSON.stringify(stateData)) 18 | .withArgs(persister.oldStateFile, 'utf-8').callsFake(() => JSON.stringify(oldStateData)) 19 | }) 20 | 21 | it('should return the contents of the state.json file', () => { 22 | sandbox.stub(fs, 'existsSync') 23 | .withArgs(persister.stateFile).callsFake(() => true) 24 | 25 | const result = persister.read() 26 | assert.deepStrictEqual(result, stateData) 27 | }) 28 | 29 | it('should look for the old state file if the new one does not exist', () => { 30 | sandbox.stub(fs, 'existsSync') 31 | .withArgs(persister.stateFile).callsFake(() => false) 32 | .withArgs(persister.oldStateFile).callsFake(() => true) 33 | 34 | const result = persister.read() 35 | assert.deepStrictEqual(result, oldStateData) 36 | }) 37 | 38 | it('should return an empty object if no state file exists', () => { 39 | sandbox.stub(fs, 'existsSync') 40 | .withArgs(persister.stateFile).callsFake(() => false) 41 | .withArgs(persister.oldStateFile).callsFake(() => false) 42 | 43 | const result = persister.read() 44 | assert.deepStrictEqual(result, {}) 45 | }) 46 | }) 47 | 48 | describe('write', () => { 49 | const stateToWrite = { state: 'new' } 50 | 51 | beforeEach(() => { 52 | sandbox.stub(fs, 'writeFileSync') 53 | sandbox.stub(fs, 'mkdirSync') 54 | }) 55 | 56 | it('should write the state to the file', () => { 57 | sandbox.stub(fs, 'existsSync') 58 | .withArgs(persister.mobTimerDir).callsFake(() => true) 59 | 60 | persister.write(stateToWrite) 61 | 62 | sinon.assert.notCalled(fs.mkdirSync) 63 | sinon.assert.calledWith(fs.writeFileSync, persister.stateFile, JSON.stringify(stateToWrite)) 64 | }) 65 | 66 | it('should create the directory if needed', () => { 67 | sandbox.stub(fs, 'existsSync') 68 | .withArgs(persister.mobTimerDir).callsFake(() => false) 69 | 70 | persister.write(stateToWrite) 71 | 72 | sinon.assert.calledWith(fs.mkdirSync, persister.mobTimerDir) 73 | sinon.assert.calledWith(fs.writeFileSync, persister.stateFile, JSON.stringify(stateToWrite)) 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron') 2 | const { app, ipcMain: ipc } = electron 3 | 4 | let windows = require('./windows/windows') 5 | let TimerState = require('./state/timer-state') 6 | let statePersister = require('./state/state-persister') 7 | 8 | let timerState = new TimerState() 9 | 10 | app.on('ready', () => { 11 | timerState.setCallback(onTimerEvent) 12 | timerState.loadState(statePersister.read()) 13 | windows.setConfigState(timerState.getState()) 14 | windows.createTimerWindow() 15 | if (timerState.getState().shuffleMobbersOnStartup) { 16 | timerState.shuffleMobbers() 17 | } 18 | }) 19 | 20 | function onTimerEvent(event, data) { 21 | windows.dispatchEvent(event, data) 22 | if (event === 'configUpdated') { 23 | statePersister.write(timerState.getState()) 24 | } 25 | } 26 | 27 | ipc.on('timerWindowReady', () => timerState.initialize()) 28 | ipc.on('configWindowReady', () => timerState.publishConfig()) 29 | ipc.on('fullscreenWindowReady', () => timerState.publishConfig()) 30 | 31 | ipc.on('pause', () => timerState.pause()) 32 | ipc.on('unpause', () => timerState.start()) 33 | ipc.on('skip', () => timerState.rotate()) 34 | ipc.on('startTurn', () => timerState.start()) 35 | ipc.on('configure', () => { 36 | windows.showConfigWindow() 37 | windows.closeFullscreenWindow() 38 | }) 39 | 40 | ipc.on('shuffleMobbers', () => timerState.shuffleMobbers()) 41 | ipc.on('addMobber', (event, mobber) => timerState.addMobber(mobber)) 42 | ipc.on('removeMobber', (event, mobber) => timerState.removeMobber(mobber)) 43 | ipc.on('updateMobber', (event, mobber) => timerState.updateMobber(mobber)) 44 | ipc.on('setSecondsPerTurn', (event, secondsPerTurn) => timerState.setSecondsPerTurn(secondsPerTurn)) 45 | ipc.on('setSecondsUntilFullscreen', (event, secondsUntilFullscreen) => timerState.setSecondsUntilFullscreen(secondsUntilFullscreen)) 46 | ipc.on('setSnapThreshold', (event, threshold) => timerState.setSnapThreshold(threshold)) 47 | ipc.on('setAlertSoundTimes', (event, alertSoundTimes) => timerState.setAlertSoundTimes(alertSoundTimes)) 48 | ipc.on('setAlertSound', (event, alertSound) => timerState.setAlertSound(alertSound)) 49 | ipc.on('setTimerAlwaysOnTop', (event, value) => timerState.setTimerAlwaysOnTop(value)) 50 | ipc.on('setShuffleMobbersOnStartup', (event, value) => timerState.setShuffleMobbersOnStartup(value)) 51 | ipc.on('setClearClipboardHistoryOnTurnEnd', (event, value) => timerState.setClearClipboardHistoryOnTurnEnd(value)) 52 | ipc.on('setNumberOfItemsClipboardHistoryStores', (event, value) => timerState.setNumberOfItemsClipboardHistoryStores(value)) 53 | 54 | app.on('window-all-closed', function() { 55 | if (process.platform !== 'darwin') { 56 | app.quit() 57 | } 58 | }) 59 | 60 | app.on('activate', function() { 61 | windows.createTimerWindow() 62 | }) 63 | -------------------------------------------------------------------------------- /test/state/timer.specs.js: -------------------------------------------------------------------------------- 1 | let Timer = require('../../src/state/timer') 2 | let assert = require('assert') 3 | const sinon = require('sinon') 4 | 5 | describe('Timer', () => { 6 | let timer 7 | let timerOptions 8 | let callbacks 9 | let clock 10 | 11 | const mockDateNow = () => { 12 | let calls = 0 13 | return () => { calls++; return calls * 1000 } 14 | } 15 | 16 | let createTimer = () => { 17 | timer = new Timer(timerOptions, x => callbacks.push(x)) 18 | } 19 | 20 | beforeEach(() => { 21 | callbacks = [] 22 | timerOptions = { rateMilliseconds: 20, time: 50, countDown: true } 23 | createTimer() 24 | clock = sinon.useFakeTimers() 25 | }) 26 | 27 | afterEach(() => { 28 | timer.pause() 29 | clock.restore() 30 | }) 31 | 32 | describe('on construction', () => { 33 | describe('with specified options', () => { 34 | it('should have the specified rateMilliseconds value', () => { 35 | assert.strictEqual(timer.rateMilliseconds, timerOptions.rateMilliseconds) 36 | }) 37 | 38 | it('should have the specified value', () => { 39 | assert.strictEqual(timer.time, timerOptions.time) 40 | }) 41 | 42 | it('should know if it is counting up or down based on the specified countDown', () => { 43 | assert.strictEqual(timer.countDown, true) 44 | }) 45 | }) 46 | 47 | describe('with default options', () => { 48 | beforeEach(() => { 49 | timerOptions = {} 50 | createTimer() 51 | }) 52 | 53 | it('should have the default rateMilliseconds value', () => { 54 | assert.strictEqual(timer.rateMilliseconds, 1000) 55 | }) 56 | 57 | it('should have the default time value', () => { 58 | assert.strictEqual(timer.time, 0) 59 | }) 60 | 61 | it('should have the default countDown value', () => { 62 | assert.strictEqual(timer.countDown, false) 63 | }) 64 | }) 65 | }) 66 | 67 | describe('start', () => { 68 | it('should generate callbacks when counting down', () => { 69 | timer.start(mockDateNow()) 70 | clock.tick(50) 71 | assert.strictEqual(callbacks.join(','), '49,48') 72 | }) 73 | 74 | it('should generate callbacks when counting up', () => { 75 | timerOptions.countDown = false 76 | createTimer() 77 | timer.start(mockDateNow()) 78 | clock.tick(50) 79 | assert.strictEqual(callbacks.join(','), '51,52') 80 | }) 81 | }) 82 | 83 | describe('pause', () => { 84 | it('should stop further callbacks from occuring', () => { 85 | timer.start(mockDateNow()) 86 | clock.tick(50) 87 | timer.pause() 88 | clock.tick(100) 89 | assert.strictEqual(callbacks.join(','), '49,48') 90 | }) 91 | }) 92 | 93 | describe('reset', () => { 94 | it('should set a new time value when the timer is not running', () => { 95 | timer.reset(42) 96 | assert.strictEqual(timer.time, 42) 97 | }) 98 | 99 | it('should set a new time value when the timer is running', () => { 100 | const mockedNow = mockDateNow() 101 | timer.start(mockedNow) 102 | clock.tick(50) 103 | timer.reset(20, mockedNow) 104 | clock.tick(40) 105 | assert.strictEqual(callbacks.join(','), '49,48,19,18') 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /src/windows/timer/index.css: -------------------------------------------------------------------------------- 1 | @import url('../theme.css'); 2 | 3 | body { 4 | margin: 0; 5 | padding: 0; 6 | font-family: sans-serif; 7 | -webkit-app-region: drag; 8 | user-select: none; 9 | color: var(--main-text-color); 10 | background-color: var(--main-background-color); 11 | } 12 | 13 | .container { 14 | position: relative; 15 | height: 100vh; 16 | } 17 | 18 | .button { 19 | cursor: pointer; 20 | -webkit-app-region: no-drag; 21 | } 22 | 23 | .pic { 24 | border-radius: 50%; 25 | } 26 | 27 | .timerContainer { 28 | position: absolute; 29 | top: 5px; 30 | left: 5px; 31 | } 32 | 33 | .currentPic { 34 | position: absolute; 35 | top: 1px; 36 | left: 1px; 37 | width: 76px; 38 | height: 76px; 39 | } 40 | 41 | .timerCanvas { 42 | position: absolute; 43 | width: 80px; 44 | height: 80px; 45 | } 46 | 47 | .toggle { 48 | width: 80px; 49 | height: 80px; 50 | border-radius: 50%; 51 | } 52 | 53 | .overlay { 54 | opacity: 0; 55 | } 56 | 57 | .overlay:hover { 58 | opacity: .5; 59 | } 60 | 61 | .play { 62 | background-image: url(../img/play.png); 63 | background-size: 80px 80px; 64 | } 65 | 66 | .pause { 67 | background-image: url(../img/pause.png); 68 | background-size: 80px 80px; 69 | } 70 | 71 | .current { 72 | position: absolute; 73 | top: 25px; 74 | left: 90px; 75 | display: inline-block; 76 | font-size: 16px; 77 | white-space: nowrap; 78 | overflow: hidden; 79 | text-overflow: ellipsis; 80 | width: 120px; 81 | } 82 | 83 | .nextContainer { 84 | position: absolute; 85 | top: 50px; 86 | left: 90px; 87 | width: 100px; 88 | } 89 | 90 | .next { 91 | position: absolute; 92 | border-radius: 50%; 93 | width: 25px; 94 | height: 25px; 95 | border: 2px solid var(--mobber-border-color); 96 | } 97 | 98 | .overlay.next { 99 | background-image: url(../img/skip.png); 100 | background-size: 25px 25px; 101 | } 102 | 103 | .nextContainer span { 104 | position: absolute; 105 | top: 10px; 106 | left: 35px; 107 | font-size: 11px; 108 | white-space: nowrap; 109 | overflow: hidden; 110 | text-overflow: ellipsis; 111 | display: inline-block; 112 | width: 90px; 113 | } 114 | 115 | .configure { 116 | position: absolute; 117 | display: block; 118 | top: 5px; 119 | right: 5px; 120 | width: 20px; 121 | height: 20px; 122 | background-color: transparent; 123 | background-image: url(../img/configure.png); 124 | background-size: 20px 20px; 125 | border: 0; 126 | opacity: .5; 127 | } 128 | 129 | .configure:hover { 130 | opacity: .7; 131 | } 132 | 133 | @keyframes pulse { 134 | 0% { 135 | background: var(--timer-pulse-color-1); 136 | } 137 | 50% { 138 | background: var(--timer-pulse-color-2); 139 | } 140 | 100% { 141 | background: var(--timer-pulse-color-1); 142 | } 143 | } 144 | 145 | .isTurnEnded { 146 | animation-duration: 1s; 147 | animation-name: pulse; 148 | animation-iteration-count: infinite; 149 | color: var(--timer-pulse-text-color); 150 | } 151 | 152 | .isPaused { 153 | color: var(--timer-paused-text-color); 154 | background: var(--timer-paused-background-color); 155 | } 156 | 157 | audio { 158 | display: none; 159 | } 160 | -------------------------------------------------------------------------------- /src/windows/config/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mob Timer Configuration 6 | 7 | 8 | 9 |
10 |
11 | 15 |
16 |
17 |

Mobbers

18 |
19 |
20 | 24 |
25 |
26 | 27 |
28 |
29 |
30 |

Additional Settings

31 |
32 | Fullscreen view appears after: 33 | seconds 34 |
35 |
36 | 40 |
41 |
42 | 46 |
47 |
48 | 52 | seconds 53 |
54 |
55 | 59 | 60 |
61 |
62 | 66 |
67 |
68 | 72 |
73 |
74 | 80 | 81 |
82 | This can help prevent sensitive information like passwords from being left in the clipboard history 83 |
84 |
85 |
86 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/windows/timer/index.js: -------------------------------------------------------------------------------- 1 | const theme = require('../theme.js') 2 | 3 | const ipc = require('electron').ipcRenderer 4 | 5 | const containerEl = document.getElementById('container') 6 | const toggleBtn = document.getElementById('toggleButton') 7 | const configureBtn = document.getElementById('configureButton') 8 | const currentEl = document.getElementById('current') 9 | const nextEl = document.getElementById('next') 10 | const currentPicEl = document.getElementById('currentPic') 11 | const nextPicEl = document.getElementById('nextPic') 12 | const nextBtn = document.getElementById('nextButton') 13 | const timerCanvas = document.getElementById('timerCanvas') 14 | const alertAudio = document.getElementById('alertAudio') 15 | 16 | const context = timerCanvas.getContext('2d') 17 | 18 | let paused = true 19 | let alertSoundTimes = [] 20 | 21 | ipc.on('timerChange', (event, data) => { 22 | clearCanvas() 23 | drawTimerCircle() 24 | drawTimerArc(data.secondsRemaining, data.secondsPerTurn) 25 | }) 26 | 27 | function clearCanvas() { 28 | context.clearRect(0, 0, timerCanvas.width, timerCanvas.height) 29 | } 30 | 31 | function drawTimerCircle() { 32 | const begin = 0 33 | const end = 2 * Math.PI 34 | drawArc(begin, end, '#EEEEEE') 35 | } 36 | 37 | function drawArc(begin, end, color) { 38 | const circleCenterX = timerCanvas.width / 2 39 | const circleCenterY = circleCenterX 40 | const circleRadius = circleCenterX - 6 41 | context.beginPath() 42 | context.arc(circleCenterX, circleCenterY, circleRadius, begin, end) 43 | context.strokeStyle = color 44 | context.lineWidth = 10 45 | context.stroke() 46 | } 47 | 48 | function drawTimerArc(seconds, maxSeconds) { 49 | let percent = 1 - (seconds / maxSeconds) 50 | if (percent === 0) { 51 | return 52 | } 53 | let begin = -(0.5 * Math.PI) 54 | let end = begin + (2 * Math.PI * percent) 55 | drawArc(begin, end, theme.mobberBorderHighlightColor) 56 | } 57 | 58 | ipc.on('rotated', (event, data) => { 59 | if (!data.current) { 60 | data.current = { name: 'Add a mobber' } 61 | } 62 | currentPicEl.src = data.current.image || '../img/sad-cyclops.png' 63 | currentEl.innerHTML = data.current.name 64 | 65 | if (!data.next) { 66 | data.next = data.current 67 | } 68 | nextPicEl.src = data.next.image || '../img/sad-cyclops.png' 69 | nextEl.innerHTML = data.next.name 70 | }) 71 | 72 | ipc.on('paused', () => { 73 | paused = true 74 | containerEl.classList.add('isPaused') 75 | toggleBtn.classList.add('play') 76 | toggleBtn.classList.remove('pause') 77 | }) 78 | 79 | ipc.on('started', () => { 80 | paused = false 81 | containerEl.classList.remove('isPaused') 82 | containerEl.classList.remove('isTurnEnded') 83 | toggleBtn.classList.remove('play') 84 | toggleBtn.classList.add('pause') 85 | }) 86 | 87 | ipc.on('turnEnded', () => { 88 | paused = true 89 | containerEl.classList.remove('isPaused') 90 | containerEl.classList.add('isTurnEnded') 91 | toggleBtn.classList.add('play') 92 | toggleBtn.classList.remove('pause') 93 | }) 94 | 95 | ipc.on('configUpdated', (event, data) => { 96 | alertSoundTimes = data.alertSoundTimes 97 | alertAudio.src = data.alertSound || './default.mp3' 98 | }) 99 | 100 | ipc.on('alert', (event, data) => { 101 | if (alertSoundTimes.some(item => item === data)) { 102 | alertAudio.currentTime = 0 103 | alertAudio.play() 104 | } 105 | }) 106 | 107 | ipc.on('stopAlerts', () => { 108 | alertAudio.pause() 109 | }) 110 | 111 | toggleBtn.addEventListener('click', () => { 112 | paused ? ipc.send('unpause') : ipc.send('pause') 113 | }) 114 | nextBtn.addEventListener('click', () => ipc.send('skip')) 115 | configureBtn.addEventListener('click', () => ipc.send('configure')) 116 | 117 | ipc.send('timerWindowReady') 118 | -------------------------------------------------------------------------------- /src/windows/windows.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron') 2 | const { app } = electron 3 | const windowSnapper = require('./window-snapper') 4 | const path = require('path') 5 | 6 | let timerWindow, configWindow, fullscreenWindow 7 | let snapThreshold, secondsUntilFullscreen, timerAlwaysOnTop 8 | 9 | exports.createTimerWindow = () => { 10 | if (timerWindow) { 11 | return 12 | } 13 | 14 | let { width, height } = electron.screen.getPrimaryDisplay().workAreaSize 15 | timerWindow = new electron.BrowserWindow({ 16 | x: width - 220, 17 | y: height - 90, 18 | width: 220, 19 | height: 90, 20 | resizable: false, 21 | alwaysOnTop: timerAlwaysOnTop, 22 | frame: false, 23 | icon: path.join(__dirname, '/../../src/windows/img/icon.png') 24 | }) 25 | 26 | timerWindow.loadURL(`file://${__dirname}/timer/index.html`) 27 | timerWindow.on('closed', () => (timerWindow = null)) 28 | 29 | timerWindow.on('move', () => { 30 | if (snapThreshold <= 0) { 31 | return 32 | } 33 | 34 | let getCenter = bounds => { 35 | return { 36 | x: bounds.x + (bounds.width / 2), 37 | y: bounds.y + (bounds.height / 2) 38 | } 39 | } 40 | 41 | let windowBounds = timerWindow.getBounds() 42 | let screenBounds = electron.screen.getDisplayNearestPoint(getCenter(windowBounds)).workArea 43 | 44 | let snapTo = windowSnapper(windowBounds, screenBounds, snapThreshold) 45 | if (snapTo.x !== windowBounds.x || snapTo.y !== windowBounds.y) { 46 | timerWindow.setPosition(snapTo.x, snapTo.y) 47 | } 48 | }) 49 | } 50 | 51 | exports.showConfigWindow = () => { 52 | if (configWindow) { 53 | configWindow.show() 54 | return 55 | } 56 | exports.createConfigWindow() 57 | } 58 | 59 | exports.createConfigWindow = () => { 60 | if (configWindow) { 61 | return 62 | } 63 | 64 | configWindow = new electron.BrowserWindow({ 65 | width: 420, 66 | height: 500, 67 | autoHideMenuBar: true 68 | }) 69 | 70 | configWindow.loadURL(`file://${__dirname}/config/index.html`) 71 | configWindow.on('closed', () => (configWindow = null)) 72 | } 73 | 74 | exports.createFullscreenWindow = () => { 75 | if (fullscreenWindow) { 76 | return 77 | } 78 | 79 | const { width, height } = electron.screen.getPrimaryDisplay().workAreaSize 80 | fullscreenWindow = createAlwaysOnTopFullscreenInterruptingWindow({ 81 | width, 82 | height, 83 | resizable: false, 84 | frame: false 85 | }) 86 | 87 | fullscreenWindow.loadURL(`file://${__dirname}/fullscreen/index.html`) 88 | fullscreenWindow.on('closed', () => (fullscreenWindow = null)) 89 | } 90 | 91 | exports.closeFullscreenWindow = () => { 92 | if (fullscreenWindow) { 93 | fullscreenWindow.close() 94 | } 95 | } 96 | 97 | exports.dispatchEvent = (event, data) => { 98 | if (event === 'configUpdated') { 99 | exports.setConfigState(data) 100 | } 101 | if (event === 'alert' && data === secondsUntilFullscreen) { 102 | exports.createFullscreenWindow() 103 | } 104 | if (event === 'stopAlerts') { 105 | exports.closeFullscreenWindow() 106 | } 107 | 108 | if (timerWindow) { 109 | timerWindow.webContents.send(event, data) 110 | } 111 | if (configWindow) { 112 | configWindow.webContents.send(event, data) 113 | } 114 | if (fullscreenWindow) { 115 | fullscreenWindow.webContents.send(event, data) 116 | } 117 | } 118 | 119 | exports.setConfigState = data => { 120 | var needToRecreateTimerWindow = timerAlwaysOnTop !== data.timerAlwaysOnTop 121 | 122 | snapThreshold = data.snapThreshold 123 | secondsUntilFullscreen = data.secondsUntilFullscreen 124 | timerAlwaysOnTop = data.timerAlwaysOnTop 125 | 126 | if (needToRecreateTimerWindow && timerWindow) { 127 | timerWindow.close() 128 | exports.createTimerWindow() 129 | } 130 | } 131 | 132 | function createAlwaysOnTopFullscreenInterruptingWindow(options) { 133 | return whileAppDockHidden(() => { 134 | const window = new electron.BrowserWindow(options) 135 | window.setAlwaysOnTop(true, 'screen-saver') 136 | return window 137 | }) 138 | } 139 | 140 | function whileAppDockHidden(work) { 141 | if (app.dock) { 142 | // Mac OS: The window will be able to float above fullscreen windows too 143 | app.dock.hide() 144 | } 145 | const result = work() 146 | if (app.dock) { 147 | // Mac OS: Show in dock again, window has been created 148 | app.dock.show() 149 | } 150 | return result 151 | } 152 | -------------------------------------------------------------------------------- /src/state/timer-state.js: -------------------------------------------------------------------------------- 1 | const Timer = require('./timer') 2 | const Mobbers = require('./mobbers') 3 | const clipboard = require('../clipboard') 4 | 5 | class TimerState { 6 | constructor(options) { 7 | if (!options) { 8 | options = {} 9 | } 10 | this.secondsPerTurn = 600 11 | this.mobbers = new Mobbers() 12 | this.secondsUntilFullscreen = 30 13 | this.snapThreshold = 25 14 | this.alertSound = null 15 | this.alertSoundTimes = [] 16 | this.timerAlwaysOnTop = true 17 | this.shuffleMobbersOnStartup = false 18 | this.clearClipboardHistoryOnTurnEnd = false 19 | this.numberOfItemsClipboardHistoryStores = 25 20 | 21 | this.createTimers(options.Timer || Timer) 22 | } 23 | 24 | setCallback(callback) { 25 | this.callback = callback 26 | } 27 | 28 | createTimers(TimerClass) { 29 | this.mainTimer = new TimerClass({ countDown: true, time: this.secondsPerTurn }, secondsRemaining => { 30 | this.dispatchTimerChange(secondsRemaining) 31 | if (secondsRemaining < 0) { 32 | this.pause() 33 | this.rotate() 34 | this.callback('turnEnded') 35 | this.startAlerts() 36 | 37 | if (this.clearClipboardHistoryOnTurnEnd) { 38 | clipboard.clearClipboardHistory(this.numberOfItemsClipboardHistoryStores) 39 | } 40 | } 41 | }) 42 | 43 | this.alertsTimer = new TimerClass({ countDown: false }, alertSeconds => { 44 | this.callback('alert', alertSeconds) 45 | }) 46 | } 47 | 48 | dispatchTimerChange(secondsRemaining) { 49 | this.callback('timerChange', { 50 | secondsRemaining: secondsRemaining < 0 ? 0 : secondsRemaining, 51 | secondsPerTurn: this.secondsPerTurn 52 | }) 53 | } 54 | 55 | reset() { 56 | this.mainTimer.reset(this.secondsPerTurn) 57 | this.dispatchTimerChange(this.secondsPerTurn) 58 | } 59 | 60 | startAlerts() { 61 | this.alertsTimer.reset(0) 62 | this.alertsTimer.start() 63 | this.callback('alert', 0) 64 | } 65 | 66 | stopAlerts() { 67 | this.alertsTimer.pause() 68 | this.callback('stopAlerts') 69 | } 70 | 71 | start() { 72 | this.mainTimer.start() 73 | this.callback('started') 74 | this.stopAlerts() 75 | } 76 | 77 | pause() { 78 | this.mainTimer.pause() 79 | this.callback('paused') 80 | this.stopAlerts() 81 | } 82 | 83 | rotate() { 84 | this.reset() 85 | this.mobbers.rotate() 86 | this.callback('rotated', this.mobbers.getCurrentAndNextMobbers()) 87 | } 88 | 89 | initialize() { 90 | this.rotate() 91 | this.callback('turnEnded') 92 | this.publishConfig() 93 | } 94 | 95 | publishConfig() { 96 | this.callback('configUpdated', this.getState()) 97 | this.callback('rotated', this.mobbers.getCurrentAndNextMobbers()) 98 | } 99 | 100 | addMobber(mobber) { 101 | this.mobbers.addMobber(mobber) 102 | this.publishConfig() 103 | } 104 | 105 | removeMobber(mobber) { 106 | let currentMobber = this.mobbers.getCurrentAndNextMobbers().current 107 | let isRemovingCurrentMobber = currentMobber ? currentMobber.name === mobber.name : false 108 | 109 | this.mobbers.removeMobber(mobber) 110 | 111 | if (isRemovingCurrentMobber) { 112 | this.pause() 113 | this.reset() 114 | this.callback('turnEnded') 115 | } 116 | 117 | this.publishConfig() 118 | } 119 | 120 | updateMobber(mobber) { 121 | const currentMobber = this.mobbers.getCurrentAndNextMobbers().current 122 | const disablingCurrentMobber = (currentMobber && currentMobber.id === mobber.id && mobber.disabled) 123 | 124 | this.mobbers.updateMobber(mobber) 125 | 126 | if (disablingCurrentMobber) { 127 | this.pause() 128 | this.reset() 129 | this.callback('turnEnded') 130 | } 131 | 132 | this.publishConfig() 133 | } 134 | 135 | setSecondsPerTurn(value) { 136 | this.secondsPerTurn = value 137 | this.publishConfig() 138 | this.reset() 139 | } 140 | 141 | setSecondsUntilFullscreen(value) { 142 | this.secondsUntilFullscreen = value 143 | this.publishConfig() 144 | } 145 | 146 | setSnapThreshold(value) { 147 | this.snapThreshold = value 148 | this.publishConfig() 149 | } 150 | 151 | setAlertSound(soundFile) { 152 | this.alertSound = soundFile 153 | this.publishConfig() 154 | } 155 | 156 | setAlertSoundTimes(secondsArray) { 157 | this.alertSoundTimes = secondsArray 158 | this.publishConfig() 159 | } 160 | 161 | setTimerAlwaysOnTop(value) { 162 | this.timerAlwaysOnTop = value 163 | this.publishConfig() 164 | } 165 | 166 | setShuffleMobbersOnStartup(value) { 167 | this.shuffleMobbersOnStartup = value 168 | this.publishConfig() 169 | } 170 | 171 | shuffleMobbers() { 172 | this.mobbers.shuffleMobbers() 173 | this.publishConfig() 174 | } 175 | 176 | setClearClipboardHistoryOnTurnEnd(value) { 177 | this.clearClipboardHistoryOnTurnEnd = value 178 | this.publishConfig() 179 | } 180 | 181 | setNumberOfItemsClipboardHistoryStores(value) { 182 | this.numberOfItemsClipboardHistoryStores = value 183 | this.publishConfig() 184 | } 185 | 186 | getState() { 187 | return { 188 | mobbers: this.mobbers.getAll(), 189 | secondsPerTurn: this.secondsPerTurn, 190 | secondsUntilFullscreen: this.secondsUntilFullscreen, 191 | snapThreshold: this.snapThreshold, 192 | alertSound: this.alertSound, 193 | alertSoundTimes: this.alertSoundTimes, 194 | timerAlwaysOnTop: this.timerAlwaysOnTop, 195 | shuffleMobbersOnStartup: this.shuffleMobbersOnStartup, 196 | clearClipboardHistoryOnTurnEnd: this.clearClipboardHistoryOnTurnEnd, 197 | numberOfItemsClipboardHistoryStores: this.numberOfItemsClipboardHistoryStores 198 | } 199 | } 200 | 201 | loadState(state) { 202 | if (state.mobbers) { 203 | state.mobbers.forEach(x => this.addMobber(x)) 204 | } 205 | 206 | this.setSecondsPerTurn(state.secondsPerTurn || this.secondsPerTurn) 207 | if (typeof state.secondsUntilFullscreen === 'number') { 208 | this.setSecondsUntilFullscreen(state.secondsUntilFullscreen) 209 | } 210 | if (typeof state.snapThreshold === 'number') { 211 | this.setSnapThreshold(state.snapThreshold) 212 | } 213 | this.alertSound = state.alertSound || null 214 | this.alertSoundTimes = state.alertSoundTimes || [] 215 | if (typeof state.timerAlwaysOnTop === 'boolean') { 216 | this.timerAlwaysOnTop = state.timerAlwaysOnTop 217 | } 218 | this.shuffleMobbersOnStartup = !!state.shuffleMobbersOnStartup 219 | this.clearClipboardHistoryOnTurnEnd = !!state.clearClipboardHistoryOnTurnEnd 220 | this.numberOfItemsClipboardHistoryStores = Math.floor(state.numberOfItemsClipboardHistoryStores) > 0 ? Math.floor(state.numberOfItemsClipboardHistoryStores) : 1 221 | } 222 | } 223 | 224 | module.exports = TimerState 225 | -------------------------------------------------------------------------------- /src/windows/config/index.js: -------------------------------------------------------------------------------- 1 | const ipc = require('electron').ipcRenderer 2 | const { dialog } = require('electron').remote 3 | 4 | const mobbersEl = document.getElementById('mobbers') 5 | const shuffleEl = document.getElementById('shuffle') 6 | const minutesEl = document.getElementById('minutes') 7 | const addEl = document.getElementById('add') 8 | const addMobberForm = document.getElementById('addMobberForm') 9 | const fullscreenSecondsEl = document.getElementById('fullscreen-seconds') 10 | const snapToEdgesCheckbox = document.getElementById('snap-to-edges') 11 | const alertAudioCheckbox = document.getElementById('alertAudio') 12 | const replayAudioContainer = document.getElementById('replayAudioContainer') 13 | const replayAlertAudioCheckbox = document.getElementById('replayAlertAudio') 14 | const replayAudioAfterSeconds = document.getElementById('replayAudioAfterSeconds') 15 | const useCustomSoundCheckbox = document.getElementById('useCustomSound') 16 | const customSoundEl = document.getElementById('customSound') 17 | const timerAlwaysOnTopCheckbox = document.getElementById('timerAlwaysOnTop') 18 | const shuffleMobbersOnStartupCheckbox = document.getElementById('shuffleMobbersOnStartup') 19 | const clearClipboardHistoryOnTurnEndCheckbox = document.getElementById('clearClipboardHistoryOnTurnEnd') 20 | const numberOfItemsClipboardHistoryStores = document.getElementById('numberOfItemsClipboardHistoryStores') 21 | 22 | function createMobberEl(mobber) { 23 | const el = document.createElement('div') 24 | el.classList.add('mobber') 25 | if (mobber.disabled) { 26 | el.classList.add('disabled') 27 | } 28 | 29 | const imgEl = document.createElement('img') 30 | imgEl.src = mobber.image || '../img/sad-cyclops.png' 31 | imgEl.classList.add('image') 32 | el.appendChild(imgEl) 33 | 34 | const nameEl = document.createElement('div') 35 | nameEl.innerHTML = mobber.name 36 | nameEl.classList.add('name') 37 | el.appendChild(nameEl) 38 | 39 | const disableBtn = document.createElement('button') 40 | disableBtn.classList.add('btn') 41 | disableBtn.innerHTML = mobber.disabled ? 'Enable' : 'Disable' 42 | el.appendChild(disableBtn) 43 | 44 | const rmBtn = document.createElement('button') 45 | rmBtn.classList.add('btn') 46 | rmBtn.innerHTML = 'Remove' 47 | el.appendChild(rmBtn) 48 | 49 | imgEl.addEventListener('click', () => selectImage(mobber)) 50 | disableBtn.addEventListener('click', () => toggleMobberDisabled(mobber)) 51 | rmBtn.addEventListener('click', () => ipc.send('removeMobber', mobber)) 52 | 53 | return el 54 | } 55 | 56 | function selectImage(mobber) { 57 | var image = dialog.showOpenDialog({ 58 | title: 'Select image', 59 | filters: [ 60 | { name: 'Images', extensions: ['jpg', 'png', 'gif'] } 61 | ], 62 | properties: ['openFile'] 63 | }) 64 | 65 | if (image) { 66 | mobber.image = image[0] 67 | ipc.send('updateMobber', mobber) 68 | } 69 | } 70 | 71 | function toggleMobberDisabled(mobber) { 72 | mobber.disabled = !mobber.disabled 73 | ipc.send('updateMobber', mobber) 74 | } 75 | 76 | ipc.on('configUpdated', (event, data) => { 77 | minutesEl.value = Math.ceil(data.secondsPerTurn / 60) 78 | mobbersEl.innerHTML = '' 79 | const frag = document.createDocumentFragment() 80 | data.mobbers.map(mobber => { 81 | frag.appendChild(createMobberEl(mobber)) 82 | }) 83 | mobbersEl.appendChild(frag) 84 | fullscreenSecondsEl.value = data.secondsUntilFullscreen 85 | snapToEdgesCheckbox.checked = data.snapThreshold > 0 86 | 87 | alertAudioCheckbox.checked = data.alertSoundTimes.length > 0 88 | replayAlertAudioCheckbox.checked = data.alertSoundTimes.length > 1 89 | replayAudioAfterSeconds.value = data.alertSoundTimes.length > 1 ? data.alertSoundTimes[1] : 30 90 | updateAlertControls() 91 | 92 | useCustomSoundCheckbox.checked = !!data.alertSound 93 | customSoundEl.value = data.alertSound 94 | 95 | timerAlwaysOnTopCheckbox.checked = data.timerAlwaysOnTop 96 | shuffleMobbersOnStartupCheckbox.checked = data.shuffleMobbersOnStartup 97 | clearClipboardHistoryOnTurnEndCheckbox.checked = data.clearClipboardHistoryOnTurnEnd 98 | numberOfItemsClipboardHistoryStores.value = data.numberOfItemsClipboardHistoryStores 99 | numberOfItemsClipboardHistoryStores.disabled = !clearClipboardHistoryOnTurnEndCheckbox.checked 100 | }) 101 | 102 | minutesEl.addEventListener('change', () => { 103 | ipc.send('setSecondsPerTurn', minutesEl.value * 60) 104 | }) 105 | 106 | addMobberForm.addEventListener('submit', event => { 107 | event.preventDefault() 108 | let value = addEl.value.trim() 109 | if (!value) { 110 | return 111 | } 112 | ipc.send('addMobber', { name: value }) 113 | addEl.value = '' 114 | }) 115 | 116 | shuffleEl.addEventListener('click', event => { 117 | event.preventDefault() 118 | ipc.send('shuffleMobbers') 119 | }) 120 | 121 | fullscreenSecondsEl.addEventListener('change', () => { 122 | ipc.send('setSecondsUntilFullscreen', fullscreenSecondsEl.value * 1) 123 | }) 124 | 125 | ipc.send('configWindowReady') 126 | 127 | snapToEdgesCheckbox.addEventListener('change', () => { 128 | ipc.send('setSnapThreshold', snapToEdgesCheckbox.checked ? 25 : 0) 129 | }) 130 | 131 | alertAudioCheckbox.addEventListener('change', () => updateAlertTimes()) 132 | replayAlertAudioCheckbox.addEventListener('change', () => updateAlertTimes()) 133 | replayAudioAfterSeconds.addEventListener('change', () => updateAlertTimes()) 134 | 135 | function updateAlertTimes() { 136 | updateAlertControls() 137 | 138 | let alertSeconds = [] 139 | if (alertAudioCheckbox.checked) { 140 | alertSeconds.push(0) 141 | if (replayAlertAudioCheckbox.checked) { 142 | alertSeconds.push(replayAudioAfterSeconds.value * 1) 143 | } 144 | } 145 | 146 | ipc.send('setAlertSoundTimes', alertSeconds) 147 | } 148 | 149 | function updateAlertControls() { 150 | let replayDisabled = !alertAudioCheckbox.checked 151 | replayAlertAudioCheckbox.disabled = replayDisabled 152 | 153 | if (replayDisabled) { 154 | replayAlertAudioCheckbox.checked = false 155 | replayAudioContainer.classList.add('disabled') 156 | } else { 157 | replayAudioContainer.classList.remove('disabled') 158 | } 159 | 160 | let secondsDisabled = !replayAlertAudioCheckbox.checked 161 | replayAudioAfterSeconds.disabled = secondsDisabled 162 | } 163 | 164 | useCustomSoundCheckbox.addEventListener('change', () => { 165 | let mp3 = null 166 | 167 | if (useCustomSoundCheckbox.checked) { 168 | const selectedMp3 = dialog.showOpenDialog({ 169 | title: 'Select alert sound', 170 | filters: [ 171 | { name: 'MP3', extensions: ['mp3'] } 172 | ], 173 | properties: ['openFile'] 174 | }) 175 | 176 | if (selectedMp3) { 177 | mp3 = selectedMp3[0] 178 | } else { 179 | useCustomSoundCheckbox.checked = false 180 | } 181 | } 182 | 183 | ipc.send('setAlertSound', mp3) 184 | }) 185 | 186 | timerAlwaysOnTopCheckbox.addEventListener('change', () => { 187 | ipc.send('setTimerAlwaysOnTop', timerAlwaysOnTopCheckbox.checked) 188 | }) 189 | 190 | shuffleMobbersOnStartupCheckbox.addEventListener('change', () => { 191 | ipc.send('setShuffleMobbersOnStartup', shuffleMobbersOnStartupCheckbox.checked) 192 | }) 193 | 194 | clearClipboardHistoryOnTurnEndCheckbox.addEventListener('change', () => { 195 | numberOfItemsClipboardHistoryStores.disabled = !clearClipboardHistoryOnTurnEndCheckbox.checked 196 | ipc.send('setClearClipboardHistoryOnTurnEnd', clearClipboardHistoryOnTurnEndCheckbox.checked) 197 | }) 198 | 199 | numberOfItemsClipboardHistoryStores.addEventListener('change', () => { 200 | ipc.send('setNumberOfItemsClipboardHistoryStores', Math.floor(numberOfItemsClipboardHistoryStores.value) > 0 ? Math.floor(numberOfItemsClipboardHistoryStores.value) : 1) 201 | }) 202 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /test/state/mobbers.specs.js: -------------------------------------------------------------------------------- 1 | let Mobbers = require('../../src/state/mobbers') 2 | let assert = require('assert') 3 | const sinon = require('sinon') 4 | 5 | describe('Mobbers', () => { 6 | let mobbers 7 | 8 | beforeEach(() => { 9 | mobbers = new Mobbers() 10 | }) 11 | 12 | describe('on construction', () => { 13 | it('should have no mobbers', () => { 14 | let result = mobbers.getAll() 15 | assert.deepStrictEqual(result, []) 16 | }) 17 | }) 18 | 19 | describe('shuffleMobbers', () => { 20 | let originalRandom 21 | 22 | beforeEach(() => { 23 | originalRandom = Math.random 24 | }) 25 | 26 | afterEach(() => { 27 | Math.random = originalRandom 28 | }) 29 | 30 | it('shuffles the mobbers so there is a different order', () => { 31 | Math.random = sinon.stub() 32 | Math.random.onCall(0).returns(0.3) 33 | Math.random.onCall(1).returns(0.5) 34 | Math.random.onCall(2).returns(0.7) 35 | Math.random.onCall(3).returns(0.9) 36 | Math.random.throws(new Error('No more random should be needed!')) 37 | mobbers.addMobber({ name: 'Testerson', id: 'mobber-1' }) 38 | mobbers.addMobber({ name: 'TestersonFace', id: 'mobber-2' }) 39 | mobbers.addMobber({ name: 'TestersonHead', id: 'mobber-3' }) 40 | mobbers.addMobber({ name: 'TestersonNose', id: 'mobber-4' }) 41 | 42 | mobbers.shuffleMobbers() 43 | 44 | const mobberIds = mobbers.getAll().map(mobber => mobber.id) 45 | assert.deepStrictEqual(mobberIds, [ 46 | 'mobber-1', 47 | 'mobber-3', 48 | 'mobber-4', 49 | 'mobber-2' 50 | ]) 51 | }) 52 | }) 53 | 54 | describe('addMobber', () => { 55 | it('should add a mobber', () => { 56 | mobbers.addMobber({ name: 'Test' }) 57 | let result = mobbers.getAll() 58 | assert.strictEqual(result[0].name, 'Test') 59 | }) 60 | 61 | it('should add an id to the mobber if missing', () => { 62 | mobbers.addMobber({ name: 'Test' }) 63 | let result = mobbers.getAll() 64 | assert.notStrictEqual(result[0].id, undefined) 65 | }) 66 | 67 | it('should NOT add an id to the mobber if it already has one', () => { 68 | mobbers.addMobber({ id: 'test-id', name: 'Test' }) 69 | let result = mobbers.getAll() 70 | assert.strictEqual(result[0].id, 'test-id') 71 | }) 72 | 73 | it('should always add to the end of the list', () => { 74 | mobbers.addMobber({ name: 'Test 1' }) 75 | mobbers.addMobber({ name: 'Test 2' }) 76 | let result = mobbers.getAll() 77 | assert.strictEqual(result[0].name, 'Test 1') 78 | assert.strictEqual(result[1].name, 'Test 2') 79 | }) 80 | }) 81 | 82 | describe('getCurrentAndNextMobbers', () => { 83 | it('return null values if there are no mobbers', () => { 84 | let result = mobbers.getCurrentAndNextMobbers() 85 | assert.deepStrictEqual(result, { current: null, next: null }) 86 | }) 87 | 88 | it('return the same mobber for current and next if there is only one mobber', () => { 89 | mobbers.addMobber({ name: 'Test' }) 90 | let result = mobbers.getCurrentAndNextMobbers() 91 | assert.strictEqual(result.current.name, 'Test') 92 | assert.strictEqual(result.next.name, 'Test') 93 | }) 94 | 95 | it('return the current and next mobber when there are 2 mobbers', () => { 96 | mobbers.addMobber({ name: 'Test 1' }) 97 | mobbers.addMobber({ name: 'Test 2' }) 98 | let result = mobbers.getCurrentAndNextMobbers() 99 | assert.strictEqual(result.current.name, 'Test 1') 100 | assert.strictEqual(result.next.name, 'Test 2') 101 | }) 102 | 103 | it('should return the correct mobbers after rotating', () => { 104 | mobbers.addMobber({ name: 'Test 1' }) 105 | mobbers.addMobber({ name: 'Test 2' }) 106 | mobbers.addMobber({ name: 'Test 3' }) 107 | mobbers.rotate() 108 | let result = mobbers.getCurrentAndNextMobbers() 109 | assert.strictEqual(result.current.name, 'Test 2') 110 | assert.strictEqual(result.next.name, 'Test 3') 111 | }) 112 | 113 | it('should not include disabled mobbers', () => { 114 | mobbers.addMobber({ name: 'Test 1' }) 115 | mobbers.addMobber({ name: 'Test 2', disabled: true }) 116 | mobbers.addMobber({ name: 'Test 3' }) 117 | let result = mobbers.getCurrentAndNextMobbers() 118 | assert.strictEqual(result.current.name, 'Test 1') 119 | assert.strictEqual(result.next.name, 'Test 3') 120 | }) 121 | }) 122 | 123 | describe('rotate', () => { 124 | it('should do nothing when there are no mobbers', () => { 125 | mobbers.rotate() 126 | let result = mobbers.getCurrentAndNextMobbers() 127 | assert.deepStrictEqual(result, { current: null, next: null }) 128 | }) 129 | 130 | it('should do nothing when there is only one mobber', () => { 131 | mobbers.addMobber({ name: 'Test' }) 132 | mobbers.rotate() 133 | let result = mobbers.getCurrentAndNextMobbers() 134 | assert.strictEqual(result.current.name, 'Test') 135 | assert.strictEqual(result.next.name, 'Test') 136 | }) 137 | 138 | it('should rotate the mobbers when there are 2', () => { 139 | mobbers.addMobber({ name: 'Test 1' }) 140 | mobbers.addMobber({ name: 'Test 2' }) 141 | mobbers.rotate() 142 | let result = mobbers.getCurrentAndNextMobbers() 143 | assert.strictEqual(result.current.name, 'Test 2') 144 | assert.strictEqual(result.next.name, 'Test 1') 145 | }) 146 | 147 | it('should loop back around after the end of the list', () => { 148 | mobbers.addMobber({ name: 'Test 1' }) 149 | mobbers.addMobber({ name: 'Test 2' }) 150 | mobbers.rotate() 151 | mobbers.rotate() 152 | let result = mobbers.getCurrentAndNextMobbers() 153 | assert.strictEqual(result.current.name, 'Test 1') 154 | assert.strictEqual(result.next.name, 'Test 2') 155 | }) 156 | 157 | it('should skip disabled mobbers', () => { 158 | mobbers.addMobber({ name: 'Test 1', disabled: true }) 159 | mobbers.addMobber({ name: 'Test 2' }) 160 | mobbers.addMobber({ name: 'Test 3', disabled: true }) 161 | mobbers.addMobber({ name: 'Test 4', disabled: true }) 162 | mobbers.addMobber({ name: 'Test 5', disabled: false }) 163 | mobbers.rotate() 164 | mobbers.rotate() 165 | let result = mobbers.getCurrentAndNextMobbers() 166 | assert.strictEqual(result.current.name, 'Test 2') 167 | assert.strictEqual(result.next.name, 'Test 5') 168 | }) 169 | }) 170 | 171 | describe('removeMobber', () => { 172 | it('should not remove anyone if the id does not match', () => { 173 | mobbers.addMobber({ name: 'Test', id: 'test-id' }) 174 | mobbers.removeMobber({ name: 'Other', id: 'other-id' }) 175 | let result = mobbers.getAll() 176 | assert.strictEqual(result[0].name, 'Test') 177 | }) 178 | 179 | it('should remove the mobber that matches by id', () => { 180 | mobbers.addMobber({ name: 'Test 1', id: '1a' }) 181 | mobbers.addMobber({ name: 'Test 2', id: '2a' }) 182 | mobbers.addMobber({ name: 'Test 1', id: '1b' }) 183 | mobbers.addMobber({ name: 'Test 2', id: '2b' }) 184 | mobbers.removeMobber({ name: 'Test 1', id: '1b' }) 185 | let result = mobbers.getAll() 186 | assert.strictEqual(result.length, 3) 187 | assert.strictEqual(result[0].id, '1a') 188 | assert.strictEqual(result[1].id, '2a') 189 | assert.strictEqual(result[2].id, '2b') 190 | }) 191 | 192 | it('should update correctly if the removed mobber was the current mobber', () => { 193 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 194 | mobbers.addMobber({ name: 'Test 2', id: 't2' }) 195 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 196 | mobbers.rotate() 197 | mobbers.removeMobber({ id: 't2' }) 198 | let result = mobbers.getCurrentAndNextMobbers() 199 | assert.strictEqual(result.current.name, 'Test 3') 200 | assert.strictEqual(result.next.name, 'Test 1') 201 | }) 202 | 203 | it('should wrap around correctly if the removed mobber was current and at the end of the list', () => { 204 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 205 | mobbers.addMobber({ name: 'Test 2', id: 't2' }) 206 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 207 | mobbers.rotate() 208 | mobbers.rotate() 209 | mobbers.removeMobber({ id: 't3' }) 210 | let result = mobbers.getCurrentAndNextMobbers() 211 | assert.strictEqual(result.current.name, 'Test 1') 212 | assert.strictEqual(result.next.name, 'Test 2') 213 | }) 214 | 215 | it('should wrap around correctly even if some mobbers are disabled', () => { 216 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 217 | mobbers.addMobber({ name: 'Test 2', id: 't2', disabled: true }) 218 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 219 | mobbers.rotate() 220 | mobbers.removeMobber({ id: 't3' }) 221 | let result = mobbers.getCurrentAndNextMobbers() 222 | assert.strictEqual(result.current.name, 'Test 1') 223 | assert.strictEqual(result.next.name, 'Test 1') 224 | }) 225 | }) 226 | 227 | describe('updateMobber', () => { 228 | it('should replace the mobber by matching id', () => { 229 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 230 | mobbers.addMobber({ name: 'Test 2', id: 't2' }) 231 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 232 | mobbers.updateMobber({ name: 'Test 2-updated', id: 't2', image: 'image-path' }) 233 | let result = mobbers.getAll() 234 | assert.strictEqual(result.length, 3) 235 | assert.strictEqual(result[0].name, 'Test 1') 236 | assert.strictEqual(result[1].name, 'Test 2-updated') 237 | assert.strictEqual(result[2].name, 'Test 3') 238 | assert.strictEqual(result[1].image, 'image-path') 239 | }) 240 | 241 | it('should not replace anything if the id does not match', () => { 242 | mobbers.addMobber({ name: 'Test', id: 'test-id' }) 243 | mobbers.updateMobber({ name: 'Tester', id: 'other-id', image: 'image-path' }) 244 | let result = mobbers.getAll() 245 | assert.strictEqual(result.length, 1) 246 | assert.strictEqual(result[0].name, 'Test') 247 | assert.strictEqual(result[0].id, 'test-id') 248 | assert.strictEqual(result[0].image, undefined) 249 | }) 250 | 251 | it('should not change the current mobber when enabling another', () => { 252 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 253 | mobbers.addMobber({ name: 'Test 2', id: 't2', disabled: true }) 254 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 255 | 256 | mobbers.rotate() 257 | let result = mobbers.getCurrentAndNextMobbers() 258 | assert.strictEqual(result.current.name, 'Test 3') 259 | assert.strictEqual(result.next.name, 'Test 1') 260 | 261 | mobbers.updateMobber({ name: 'Test 2', id: 't2', disabled: false }) 262 | result = mobbers.getCurrentAndNextMobbers() 263 | assert.strictEqual(result.current.name, 'Test 3') 264 | assert.strictEqual(result.next.name, 'Test 1') 265 | }) 266 | 267 | it('should not change the current mobber when disabling another', () => { 268 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 269 | mobbers.addMobber({ name: 'Test 2', id: 't2' }) 270 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 271 | 272 | mobbers.rotate() 273 | let result = mobbers.getCurrentAndNextMobbers() 274 | assert.strictEqual(result.current.name, 'Test 2') 275 | assert.strictEqual(result.next.name, 'Test 3') 276 | 277 | mobbers.updateMobber({ name: 'Test 1', id: 't1', disabled: true }) 278 | result = mobbers.getCurrentAndNextMobbers() 279 | assert.strictEqual(result.current.name, 'Test 2') 280 | assert.strictEqual(result.next.name, 'Test 3') 281 | }) 282 | 283 | it('should go to the next mobber when disabling the current mobber', () => { 284 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 285 | mobbers.addMobber({ name: 'Test 2', id: 't2' }) 286 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 287 | 288 | let result = mobbers.getCurrentAndNextMobbers() 289 | assert.strictEqual(result.current.name, 'Test 1') 290 | assert.strictEqual(result.next.name, 'Test 2') 291 | 292 | mobbers.updateMobber({ name: 'Test 1', id: 't1', disabled: true }) 293 | result = mobbers.getCurrentAndNextMobbers() 294 | assert.strictEqual(result.current.name, 'Test 2') 295 | assert.strictEqual(result.next.name, 'Test 3') 296 | }) 297 | 298 | it('should wrap around to the first mobber when disabling the current last mobber', () => { 299 | mobbers.addMobber({ name: 'Test 1', id: 't1' }) 300 | mobbers.addMobber({ name: 'Test 2', id: 't2' }) 301 | mobbers.addMobber({ name: 'Test 3', id: 't3' }) 302 | 303 | mobbers.rotate() 304 | mobbers.rotate() 305 | let result = mobbers.getCurrentAndNextMobbers() 306 | assert.strictEqual(result.current.name, 'Test 3') 307 | assert.strictEqual(result.next.name, 'Test 1') 308 | 309 | mobbers.updateMobber({ name: 'Test 3', id: 't3', disabled: true }) 310 | result = mobbers.getCurrentAndNextMobbers() 311 | assert.strictEqual(result.current.name, 'Test 1') 312 | assert.strictEqual(result.next.name, 'Test 2') 313 | }) 314 | }) 315 | }) 316 | -------------------------------------------------------------------------------- /test/state/timer-state.specs.js: -------------------------------------------------------------------------------- 1 | let TimerState = require('../../src/state/timer-state') 2 | let TestTimer = require('./test-timer') 3 | let assert = require('assert') 4 | 5 | describe('timer-state', () => { 6 | let timerState 7 | let events 8 | 9 | let assertEvent = eventName => { 10 | var event = events.find(x => x.event === eventName) 11 | assert(event, eventName + ' event not found') 12 | return event 13 | } 14 | 15 | beforeEach(() => { 16 | events = [] 17 | timerState = new TimerState({ Timer: TestTimer }) 18 | timerState.setCallback((event, data) => { 19 | events.push({ event, data }) 20 | }) 21 | }) 22 | 23 | describe('initialize', () => { 24 | beforeEach(() => timerState.initialize()) 25 | 26 | it('should publish a timerChange event', () => { 27 | var event = assertEvent('timerChange') 28 | assert.deepStrictEqual(event.data, { 29 | secondsRemaining: 600, 30 | secondsPerTurn: 600 31 | }) 32 | }) 33 | 34 | it('should publish a rotated event', () => { 35 | var event = assertEvent('rotated') 36 | assert.deepStrictEqual(event.data, { current: null, next: null }) 37 | }) 38 | 39 | it('should publish a turnEnded event', () => { 40 | assertEvent('turnEnded') 41 | }) 42 | 43 | it('should publish a configUpdated event', () => { 44 | assertEvent('configUpdated') 45 | }) 46 | }) 47 | 48 | describe('reset', () => { 49 | beforeEach(() => timerState.reset()) 50 | 51 | it('should publish a timerChange event', () => { 52 | var event = assertEvent('timerChange') 53 | assert.deepStrictEqual(event.data, { 54 | secondsRemaining: 600, 55 | secondsPerTurn: 600 56 | }) 57 | }) 58 | }) 59 | 60 | describe('start', () => { 61 | beforeEach(() => timerState.start()) 62 | 63 | it('should start the mainTimer', function() { 64 | assert.strictEqual(timerState.mainTimer.isRunning, true) 65 | }) 66 | 67 | it('should publish a started event', () => { 68 | assertEvent('started') 69 | }) 70 | 71 | it('should publish a stopAlerts event', () => { 72 | assertEvent('stopAlerts') 73 | }) 74 | 75 | it('should publish a timerChange event when the timer calls back', () => { 76 | timerState.mainTimer.callback(599) 77 | var event = assertEvent('timerChange') 78 | assert.deepStrictEqual(event.data, { 79 | secondsRemaining: 599, 80 | secondsPerTurn: 600 81 | }) 82 | }) 83 | 84 | it('should publish events when the time is up', () => { 85 | timerState.mainTimer.callback(-1) 86 | assertEvent('turnEnded') 87 | assertEvent('paused') 88 | assertEvent('rotated') 89 | var alertEvent = assertEvent('alert') 90 | assert.strictEqual(alertEvent.data, 0) 91 | }) 92 | 93 | it('should start the alertsTimer after the timer is up', () => { 94 | assert.strictEqual(timerState.alertsTimer.isRunning, false) 95 | timerState.mainTimer.callback(-1) 96 | assert.strictEqual(timerState.alertsTimer.isRunning, true) 97 | }) 98 | 99 | it('should publish alert events after the time is up', () => { 100 | timerState.alertsTimer.callback(1) 101 | var event = assertEvent('alert') 102 | assert.strictEqual(event.data, 1) 103 | }) 104 | }) 105 | 106 | describe('pause', () => { 107 | beforeEach(() => timerState.pause()) 108 | 109 | it('should publish a paused event', () => { 110 | assertEvent('paused') 111 | }) 112 | 113 | it('should publish a stopAlerts event', () => { 114 | assertEvent('stopAlerts') 115 | }) 116 | 117 | it('should stop the mainTimer', () => { 118 | timerState.start() 119 | assert.strictEqual(timerState.mainTimer.isRunning, true) 120 | 121 | timerState.pause() 122 | assert.strictEqual(timerState.mainTimer.isRunning, false) 123 | }) 124 | }) 125 | 126 | describe('rotate', () => { 127 | beforeEach(() => { 128 | timerState.addMobber({ name: 'A' }) 129 | timerState.addMobber({ name: 'B' }) 130 | timerState.addMobber({ name: 'C' }) 131 | events = [] 132 | timerState.rotate() 133 | }) 134 | 135 | it('should publish a rotated event', () => { 136 | var event = assertEvent('rotated') 137 | assert.strictEqual(event.data.current.name, 'B', 'expected B to be current') 138 | assert.strictEqual(event.data.next.name, 'C', 'expected C to be next') 139 | }) 140 | 141 | it('should publish a timerChange event', () => { 142 | var event = assertEvent('timerChange') 143 | assert.deepStrictEqual(event.data, { 144 | secondsRemaining: 600, 145 | secondsPerTurn: 600 146 | }) 147 | }) 148 | 149 | it('should wrap around at the end of the list', () => { 150 | events = [] 151 | timerState.rotate() 152 | var event = assertEvent('rotated') 153 | assert.strictEqual(event.data.current.name, 'C', 'expected C to be current') 154 | assert.strictEqual(event.data.next.name, 'A', 'expected A to be next') 155 | }) 156 | }) 157 | 158 | describe('publishConfig', () => { 159 | beforeEach(() => timerState.publishConfig()) 160 | 161 | it('should publish a configUpdated event', () => { 162 | var event = assertEvent('configUpdated') 163 | assert.deepStrictEqual(event.data.mobbers, []) 164 | assert.strictEqual(event.data.secondsPerTurn, 600) 165 | assert.strictEqual(event.data.secondsUntilFullscreen, 30) 166 | assert.strictEqual(event.data.snapThreshold, 25) 167 | assert.strictEqual(event.data.alertSound, null) 168 | assert.deepStrictEqual(event.data.alertSoundTimes, []) 169 | assert.strictEqual(event.data.timerAlwaysOnTop, true) 170 | assert.strictEqual(event.data.shuffleMobbersOnStartup, false) 171 | assert.strictEqual(event.data.clearClipboardHistoryOnTurnEnd, false) 172 | assert.strictEqual(event.data.numberOfItemsClipboardHistoryStores, 25) 173 | }) 174 | 175 | it('should contain the mobbers if there are some', () => { 176 | timerState.addMobber({ name: 'A' }) 177 | timerState.addMobber({ name: 'B' }) 178 | events = [] 179 | 180 | timerState.publishConfig() 181 | var event = assertEvent('configUpdated') 182 | assert.strictEqual(event.data.mobbers[0].name, 'A') 183 | assert.strictEqual(event.data.mobbers[1].name, 'B') 184 | 185 | timerState.removeMobber({ name: 'A' }) 186 | timerState.removeMobber({ name: 'B' }) 187 | }) 188 | 189 | it('should publish a rotated event', () => { 190 | assertEvent('rotated') 191 | }) 192 | }) 193 | 194 | describe('addMobber', () => { 195 | beforeEach(() => timerState.addMobber({ name: 'A' })) 196 | 197 | it('should publish a configUpdated event', () => { 198 | var event = assertEvent('configUpdated') 199 | assert.strictEqual(event.data.mobbers[0].name, 'A') 200 | assert.strictEqual(event.data.secondsPerTurn, 600) 201 | }) 202 | 203 | it('should publish a rotated event', () => { 204 | var event = assertEvent('rotated') 205 | assert.strictEqual(event.data.current.name, 'A') 206 | assert.strictEqual(event.data.next.name, 'A') 207 | }) 208 | }) 209 | 210 | describe('removeMobber', () => { 211 | beforeEach(() => { 212 | timerState.addMobber({ name: 'A', id: 'a' }) 213 | timerState.addMobber({ name: 'B', id: 'b' }) 214 | timerState.addMobber({ name: 'C', id: 'c' }) 215 | events = [] 216 | timerState.removeMobber({ name: 'B', id: 'b' }) 217 | }) 218 | 219 | it('should publish a configUpdated event', () => { 220 | var event = assertEvent('configUpdated') 221 | assert.strictEqual(event.data.mobbers[0].name, 'A') 222 | assert.strictEqual(event.data.mobbers[1].name, 'C') 223 | assert.strictEqual(event.data.secondsPerTurn, 600) 224 | }) 225 | 226 | it('should publish a rotated event', () => { 227 | var event = assertEvent('rotated') 228 | assert.strictEqual(event.data.current.name, 'A') 229 | assert.strictEqual(event.data.next.name, 'C') 230 | }) 231 | 232 | it('should NOT publish a turnEnded event if the removed user was NOT current', () => { 233 | var event = events.find(x => x.event === 'turnEnded') 234 | assert.strictEqual(event, undefined) 235 | }) 236 | 237 | it('should publish a turnEnded event if the removed user was current', () => { 238 | timerState.removeMobber({ name: 'A' }) 239 | assertEvent('turnEnded') 240 | }) 241 | 242 | it('should publish a timerChange event if the removed user was current', () => { 243 | timerState.removeMobber({ name: 'A' }) 244 | assertEvent('timerChange') 245 | }) 246 | 247 | it('should publish a paused event if the removed user was current', () => { 248 | timerState.removeMobber({ name: 'A' }) 249 | assertEvent('paused') 250 | }) 251 | 252 | it('should update correctly if the removed user was current', () => { 253 | timerState.rotate() 254 | events = [] 255 | timerState.removeMobber({ name: 'C', id: 'c' }) 256 | var event = assertEvent('rotated') 257 | assert.strictEqual(event.data.current.name, 'A') 258 | assert.strictEqual(event.data.next.name, 'A') 259 | }) 260 | }) 261 | 262 | describe('updateMobber', () => { 263 | beforeEach(() => { 264 | timerState.addMobber({ id: 'a', name: 'A1' }) 265 | events = [] 266 | timerState.updateMobber({ id: 'a', name: 'A2' }) 267 | }) 268 | 269 | it('should publish a configUpdated event', () => { 270 | var event = assertEvent('configUpdated') 271 | assert.strictEqual(event.data.mobbers[0].name, 'A2') 272 | assert.strictEqual(event.data.secondsPerTurn, 600) 273 | }) 274 | 275 | it('should update correctly if the update disabled the current mobber', () => { 276 | timerState.addMobber({ id: 'b', name: 'B' }) 277 | timerState.addMobber({ id: 'c', name: 'C' }) 278 | timerState.rotate() 279 | events = [] 280 | 281 | timerState.updateMobber({ id: 'b', name: 'B', disabled: true }) 282 | 283 | assertEvent('paused') 284 | assertEvent('turnEnded') 285 | assertEvent('configUpdated') 286 | var rotatedEvent = assertEvent('rotated') 287 | assert.strictEqual(rotatedEvent.data.current.name, 'C') 288 | assert.strictEqual(rotatedEvent.data.next.name, 'A2') 289 | var timerChangeEvent = assertEvent('timerChange') 290 | assert.deepStrictEqual(timerChangeEvent.data, { 291 | secondsRemaining: 600, 292 | secondsPerTurn: 600 293 | }) 294 | }) 295 | }) 296 | 297 | describe('shuffleMobbers', () => { 298 | beforeEach(() => { 299 | const letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] 300 | letters.forEach(x => timerState.addMobber({ id: x })) 301 | events = [] 302 | timerState.shuffleMobbers() 303 | }) 304 | 305 | it('should publish a configUpdated event', () => assertEvent('configUpdated')) 306 | 307 | it('should publish a rotated event', () => assertEvent('rotated')) 308 | 309 | it('should shuffle the mobbers', () => { 310 | const mobbers = timerState.getState().mobbers.map(x => x.id).join('') 311 | assert.notStrictEqual(mobbers, 'abcdefghij') 312 | }) 313 | }) 314 | 315 | describe('setSecondsPerTurn', () => { 316 | beforeEach(() => timerState.setSecondsPerTurn(300)) 317 | 318 | it('should publish a configUpdated event', () => { 319 | var event = assertEvent('configUpdated') 320 | assert.strictEqual(event.data.secondsPerTurn, 300) 321 | }) 322 | 323 | it('should publish a timerChange event', () => { 324 | var event = assertEvent('timerChange') 325 | assert.deepStrictEqual(event.data, { 326 | secondsRemaining: 300, 327 | secondsPerTurn: 300 328 | }) 329 | }) 330 | }) 331 | 332 | describe('setSecondsUntilFullscreen', () => { 333 | beforeEach(() => timerState.setSecondsUntilFullscreen(5)) 334 | 335 | it('should publish a configUpdated event', () => { 336 | var event = assertEvent('configUpdated') 337 | assert.strictEqual(event.data.secondsUntilFullscreen, 5) 338 | }) 339 | }) 340 | 341 | describe('when setting snap threshold', () => { 342 | beforeEach(() => timerState.setSnapThreshold(100)) 343 | 344 | it('should publish configUpdated event', () => { 345 | var event = assertEvent('configUpdated') 346 | assert.strictEqual(event.data.snapThreshold, 100) 347 | }) 348 | }) 349 | 350 | describe('when setting the alert sound file', () => { 351 | beforeEach(() => timerState.setAlertSound('new-sound.mp3')) 352 | 353 | it('should publish a configUpdated event', () => { 354 | var event = assertEvent('configUpdated') 355 | assert.strictEqual(event.data.alertSound, 'new-sound.mp3') 356 | }) 357 | }) 358 | 359 | describe('when setting the alert sound times', () => { 360 | beforeEach(() => timerState.setAlertSoundTimes([1, 2, 3])) 361 | 362 | it('should publish a configUpdated event', () => { 363 | var event = assertEvent('configUpdated') 364 | assert.deepStrictEqual(event.data.alertSoundTimes, [1, 2, 3]) 365 | }) 366 | }) 367 | 368 | describe('when setting the timer always on top', () => { 369 | beforeEach(() => timerState.setTimerAlwaysOnTop(false)) 370 | 371 | it('should publish a configUpdated event', () => { 372 | var event = assertEvent('configUpdated') 373 | assert.deepStrictEqual(event.data.timerAlwaysOnTop, false) 374 | }) 375 | }) 376 | 377 | describe('when setting shuffle mobbers on startup', () => { 378 | beforeEach(() => timerState.setShuffleMobbersOnStartup(true)) 379 | 380 | it('should publish a configUpdated event', () => { 381 | var event = assertEvent('configUpdated') 382 | assert.deepStrictEqual(event.data.shuffleMobbersOnStartup, true) 383 | }) 384 | }) 385 | 386 | describe('when setting clear clipboard history between turns', () => { 387 | beforeEach(() => timerState.setClearClipboardHistoryOnTurnEnd(true)) 388 | 389 | it('should publish a configUpdated event', () => { 390 | var event = assertEvent('configUpdated') 391 | assert.deepStrictEqual(event.data.clearClipboardHistoryOnTurnEnd, true) 392 | }) 393 | }) 394 | 395 | describe('when setting number of items clipboard history stores', () => { 396 | beforeEach(() => timerState.setNumberOfItemsClipboardHistoryStores(10)) 397 | 398 | it('should publish a configUpdated event', () => { 399 | var event = assertEvent('configUpdated') 400 | assert.deepStrictEqual(event.data.numberOfItemsClipboardHistoryStores, 10) 401 | }) 402 | }) 403 | 404 | describe('getState', () => { 405 | describe('when getting non-default state', () => { 406 | beforeEach(() => { 407 | timerState.addMobber(expectedJack) 408 | timerState.addMobber(expectedJill) 409 | timerState.setSecondsPerTurn(expectedSecondsPerTurn) 410 | timerState.setSecondsUntilFullscreen(expectedSecondsUntilFullscreen) 411 | timerState.setSnapThreshold(expectedSnapThreshold) 412 | timerState.setAlertSound(expectedAlertSound) 413 | timerState.setAlertSoundTimes(expectedAlertSoundTimes) 414 | timerState.setTimerAlwaysOnTop(expectedTimerAlwaysOnTop) 415 | timerState.setShuffleMobbersOnStartup(expectedShuffleMobbersOnStartup) 416 | timerState.setClearClipboardHistoryOnTurnEnd(expectedClearClipboardHistoryOnTurnEnd) 417 | timerState.setNumberOfItemsClipboardHistoryStores(expectedNumberOfItemsClipboardHistoryStores) 418 | 419 | result = timerState.getState() 420 | }) 421 | 422 | it('should get correct mobbers', () => { 423 | var actualJack = result.mobbers.find(x => x.name === expectedJack.name) 424 | var actualJill = result.mobbers.find(x => x.name === expectedJill.name) 425 | 426 | assert.deepStrictEqual(expectedJack, actualJack) 427 | assert.deepStrictEqual(expectedJill, actualJill) 428 | }) 429 | 430 | it('should get correct seconds per turn', () => { 431 | assert.strictEqual(result.secondsPerTurn, expectedSecondsPerTurn) 432 | }) 433 | 434 | it('should get the correct seconds until fullscreen', () => { 435 | assert.strictEqual(result.secondsUntilFullscreen, expectedSecondsUntilFullscreen) 436 | }) 437 | 438 | it('should get the correct seconds until fullscreen', () => { 439 | assert.strictEqual(result.snapThreshold, expectedSnapThreshold) 440 | }) 441 | 442 | it('should get the correct alert sound', () => { 443 | assert.strictEqual(result.alertSound, expectedAlertSound) 444 | }) 445 | 446 | it('should get the correct alert sound times', () => { 447 | assert.strictEqual(result.alertSoundTimes, expectedAlertSoundTimes) 448 | }) 449 | 450 | it('should get the correct timer always on top', () => { 451 | assert.strictEqual(result.timerAlwaysOnTop, expectedTimerAlwaysOnTop) 452 | }) 453 | 454 | it('should get the correct shuffle mobbers on startup', () => { 455 | assert.strictEqual(result.shuffleMobbersOnStartup, expectedShuffleMobbersOnStartup) 456 | }) 457 | 458 | it('should get the correct clear clipboard history between turns', () => { 459 | assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, expectedClearClipboardHistoryOnTurnEnd) 460 | }) 461 | 462 | it('should get the correct number of items clipboard history stores', () => { 463 | assert.strictEqual(result.numberOfItemsClipboardHistoryStores, expectedNumberOfItemsClipboardHistoryStores) 464 | }) 465 | 466 | let result = {} 467 | let expectedJack = { name: 'jack' } 468 | let expectedJill = { name: 'jill' } 469 | let expectedSecondsPerTurn = 599 470 | let expectedSecondsUntilFullscreen = 3 471 | let expectedSnapThreshold = 42 472 | let expectedAlertSound = 'alert.mp3' 473 | let expectedAlertSoundTimes = [0, 15] 474 | let expectedTimerAlwaysOnTop = false 475 | let expectedShuffleMobbersOnStartup = true 476 | let expectedClearClipboardHistoryOnTurnEnd = true 477 | let expectedNumberOfItemsClipboardHistoryStores = 13 478 | }) 479 | 480 | describe('when getting default state', () => { 481 | beforeEach(() => (result = timerState.getState())) 482 | 483 | it('should get no mobbers', () => assert(result.mobbers.length === 0)) 484 | it('should have a default secondsPerTurn greater than zero', () => assert(result.secondsPerTurn > 0)) 485 | it('should have a default snapThreshold greater than zero', () => assert(result.snapThreshold > 0)) 486 | it('should have a null alert sound', () => assert(result.alertSound === null)) 487 | it('should have an empty array of alert sound times', () => assert.deepStrictEqual(result.alertSoundTimes, [])) 488 | it('should have a default timerAlwaysOnTop', () => assert.deepStrictEqual(result.timerAlwaysOnTop, true)) 489 | it('should have a default shuffleMobbersOnStartup', () => assert.strictEqual(result.shuffleMobbersOnStartup, false)) 490 | it('should have a default clearClipboardHistoryOnTurnEnd', () => assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, false)) 491 | it('should have a default numberOfItemsClipboardHistoryStores', () => assert.strictEqual(result.numberOfItemsClipboardHistoryStores, 25)) 492 | 493 | let result = {} 494 | }) 495 | 496 | describe('when there is one mobber', () => { 497 | before(() => { 498 | timerState.addMobber(expectedJack) 499 | 500 | result = timerState.getState() 501 | }) 502 | 503 | it('should get correct mobber', () => { 504 | var actualJack = result.mobbers.find(x => x.name === expectedJack.name) 505 | 506 | assert.deepStrictEqual(expectedJack, actualJack) 507 | }) 508 | 509 | let result = {} 510 | let expectedJack = { name: 'jack' } 511 | }) 512 | }) 513 | 514 | describe('loadState', () => { 515 | describe('when loading state data', () => { 516 | before(() => { 517 | state = { 518 | mobbers: [{ name: 'jack' }, { name: 'jill' }], 519 | secondsPerTurn: 400, 520 | secondsUntilFullscreen: 0, 521 | snapThreshold: 22, 522 | alertSound: 'bell.mp3', 523 | alertSoundTimes: [2, 3, 5, 8], 524 | timerAlwaysOnTop: false, 525 | shuffleMobbersOnStartup: true, 526 | clearClipboardHistoryOnTurnEnd: true, 527 | numberOfItemsClipboardHistoryStores: 20 528 | } 529 | 530 | timerState.loadState(state) 531 | 532 | result = timerState.getState() 533 | }) 534 | 535 | it('should load mobbers', () => assert.deepStrictEqual(result.mobbers, state.mobbers)) 536 | it('should load secondsPerTurn', () => assert.strictEqual(result.secondsPerTurn, state.secondsPerTurn)) 537 | it('should load secondsUntilFullscreen', () => assert.strictEqual(result.secondsUntilFullscreen, state.secondsUntilFullscreen)) 538 | it('should load snapThreshold', () => assert.strictEqual(result.snapThreshold, state.snapThreshold)) 539 | it('should load alertSound', () => assert.strictEqual(result.alertSound, state.alertSound)) 540 | it('should load alertSoundTimes', () => assert.deepStrictEqual(result.alertSoundTimes, [2, 3, 5, 8])) 541 | it('should load timerAlwaysOnTop', () => assert.strictEqual(result.timerAlwaysOnTop, state.timerAlwaysOnTop)) 542 | it('should load shuffleMobbersOnStartup', () => assert.strictEqual(result.shuffleMobbersOnStartup, state.shuffleMobbersOnStartup)) 543 | it('should load clearClipboardHistoryOnTurnEnd', () => assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, state.clearClipboardHistoryOnTurnEnd)) 544 | it('should load numberOfItemsClipboardHistoryStores', () => assert.strictEqual(result.numberOfItemsClipboardHistoryStores, state.numberOfItemsClipboardHistoryStores)) 545 | 546 | let result = {} 547 | let state = {} 548 | }) 549 | 550 | describe('when loading an empty state', () => { 551 | before(() => { 552 | timerState.loadState({}) 553 | 554 | result = timerState.getState() 555 | }) 556 | 557 | it('should NOT load any mobbers', () => assert.strictEqual(result.mobbers.length, 0)) 558 | it('should have a default secondsPerTurn greater than zero', () => assert(result.secondsPerTurn > 0)) 559 | it('should have a default secondsUntilFullscreen greater than zero', () => assert(result.secondsUntilFullscreen > 0)) 560 | it('should have a default snapThreshold greater than zero', () => assert(result.snapThreshold > 0)) 561 | it('should have a null alertSound', () => assert.strictEqual(result.alertSound, null)) 562 | it('should have an empty array of alertSoundTimes', () => assert.deepStrictEqual(result.alertSoundTimes, [])) 563 | it('should have a default timerAlwaysOnTop', () => assert.strictEqual(result.timerAlwaysOnTop, true)) 564 | it('should have a default shuffleMobbersOnStartup', () => assert.strictEqual(result.shuffleMobbersOnStartup, false)) 565 | it('should have a default clearClipboardHistoryOnTurnEnd', () => assert.strictEqual(result.clearClipboardHistoryOnTurnEnd, false)) 566 | it('should have a default numberOfItemsClipboardHistoryStores greater than zero', () => assert(result.numberOfItemsClipboardHistoryStores > 0)) 567 | 568 | let result = {} 569 | }) 570 | 571 | describe('when loading state with one mobber', () => { 572 | before(() => { 573 | state = { 574 | mobbers: [{ name: 'jack' }] 575 | } 576 | 577 | timerState.loadState(state) 578 | 579 | result = timerState.getState() 580 | }) 581 | 582 | it('should load one mobber', () => assert.deepStrictEqual(state.mobbers, result.mobbers)) 583 | 584 | let result = {} 585 | let state = {} 586 | }) 587 | }) 588 | }) 589 | --------------------------------------------------------------------------------