├── .eslintignore ├── assets ├── favicon.ico ├── apple-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── ms-icon-70x70.png ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png ├── mstile-150x150.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-touch-icon.png ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-icon-precomposed.png ├── browserconfig.xml ├── safari-pinned-tab.svg └── manifest.json ├── src ├── index.scss ├── learn │ ├── common │ │ ├── util.js │ │ └── constants.js │ ├── math │ │ └── drill │ │ │ ├── types.js │ │ │ ├── badge.jsx │ │ │ ├── badge-totals.jsx │ │ │ ├── previous-results.js │ │ │ ├── finished-badge.jsx │ │ │ ├── scoreboard.jsx │ │ │ ├── running-results.jsx │ │ │ ├── running.jsx │ │ │ ├── keyboard.jsx │ │ │ ├── score-bar.jsx │ │ │ ├── finished.jsx │ │ │ ├── options.jsx │ │ │ ├── helper-problems.js │ │ │ ├── quiz-line.jsx │ │ │ ├── index.jsx │ │ │ └── helper.js │ ├── menu.jsx │ ├── home.jsx │ ├── db │ │ └── index.js │ └── help │ │ └── index.jsx ├── index.jsx ├── app.jsx ├── poly.js ├── dev │ ├── test-score-bar.jsx │ └── test-finished.jsx └── template.html ├── test ├── fixtures │ └── scores-v2 │ │ └── README.md ├── _setup.js ├── learn │ ├── math │ │ └── drill │ │ │ ├── previous-results.test.js │ │ │ ├── badge-totals.test.jsx │ │ │ ├── badge.test.jsx │ │ │ ├── __snapshots__ │ │ │ ├── badge.test.jsx.snap │ │ │ ├── finished-badge.test.jsx.snap │ │ │ ├── badge-totals.test.jsx.snap │ │ │ ├── quiz-line.test.jsx.snap │ │ │ ├── scoreboard.test.jsx.snap │ │ │ └── score-bar.test.jsx.snap │ │ │ ├── keyboard.test.jsx │ │ │ ├── quiz-line.test.jsx │ │ │ ├── scoreboard.test.jsx │ │ │ ├── index.test.jsx │ │ │ ├── finished-badge.test.jsx │ │ │ ├── score-bar.test.jsx │ │ │ ├── finished.test.jsx │ │ │ └── helper.test.js │ ├── home.test.jsx │ ├── menu.test.jsx │ ├── help │ │ ├── index.test.jsx │ │ └── __snapshots__ │ │ │ └── index.test.jsx.snap │ ├── __snapshots__ │ │ ├── menu.test.jsx.snap │ │ └── home.test.jsx.snap │ └── db │ │ └── index.test.js ├── app.test.jsx ├── poly.test.js └── __snapshots__ │ └── app.test.jsx.snap ├── tsconfig.json ├── .vscode ├── settings.json ├── cSpell.json └── launch.json ├── .editorconfig ├── postcss.config.js ├── .travis.yml ├── README.md ├── .eslintrc ├── jest.config.js ├── .gitignore ├── tools ├── basics.js └── problems.txt ├── LICENSE ├── webpack.production.config.js ├── webpack.loaders.js ├── webpack.config.js └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | .git 2 | public/ 3 | coverage/ 4 | node_modules/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon.png -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/favicon-96x96.png -------------------------------------------------------------------------------- /assets/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/ms-icon-70x70.png -------------------------------------------------------------------------------- /assets/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/ms-icon-144x144.png -------------------------------------------------------------------------------- /assets/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/ms-icon-150x150.png -------------------------------------------------------------------------------- /assets/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/ms-icon-310x310.png -------------------------------------------------------------------------------- /assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/mstile-150x150.png -------------------------------------------------------------------------------- /assets/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-icon-36x36.png -------------------------------------------------------------------------------- /assets/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-icon-48x48.png -------------------------------------------------------------------------------- /assets/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-icon-72x72.png -------------------------------------------------------------------------------- /assets/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-icon-96x96.png -------------------------------------------------------------------------------- /assets/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-114x114.png -------------------------------------------------------------------------------- /assets/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-120x120.png -------------------------------------------------------------------------------- /assets/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-144x144.png -------------------------------------------------------------------------------- /assets/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-152x152.png -------------------------------------------------------------------------------- /assets/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-180x180.png -------------------------------------------------------------------------------- /assets/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-57x57.png -------------------------------------------------------------------------------- /assets/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-60x60.png -------------------------------------------------------------------------------- /assets/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-72x72.png -------------------------------------------------------------------------------- /assets/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-76x76.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-icon-144x144.png -------------------------------------------------------------------------------- /assets/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-icon-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /assets/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guyellis/learn/HEAD/assets/apple-icon-precomposed.png -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | .blueBg { 2 | background-color: red; 3 | color: white; 4 | } 5 | 6 | .running-header > span { 7 | padding: '20px'; 8 | } 9 | -------------------------------------------------------------------------------- /src/learn/common/util.js: -------------------------------------------------------------------------------- 1 | 2 | function fillArray(size, value) { 3 | return [...Array(size).keys()].map(() => value); 4 | } 5 | 6 | module.exports = { 7 | fillArray, 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/scores-v2/README.md: -------------------------------------------------------------------------------- 1 | # This folder 2 | 3 | Files from 3 different browsers: Brave, Chrome, Edge with version 2 of scores 4 | that were used for testing a bug in this version. 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "allowJs": true, 5 | "checkJs": false, 6 | "module": "commonjs", 7 | "target": "es2017", 8 | "jsx": "react", 9 | "moduleResolution": "node", 10 | "lib": ["es2015", "dom"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Configure glob patterns for excluding files and folders in searches. Inherits all glob patterns from the files.exclude setting. 3 | "search.exclude": { 4 | "**/node_modules": true, 5 | "**/bower_components": true, 6 | "**/public": true 7 | }, 8 | "cSpell.words": [ 9 | "actuals" 10 | ] 11 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | 9 | # Matches multiple files with brace expansion notation 10 | # Set default charset 11 | [*.{js,jsx,html,sass}] 12 | charset = utf-8 13 | indent_style = tab 14 | indent_size = 2 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const AUTOPREFIXER_BROWSERS = [ 2 | 'Android 2.3', 3 | 'Android >= 4', 4 | 'Chrome >= 35', 5 | 'Firefox >= 31', 6 | 'Explorer >= 9', 7 | 'iOS >= 7', 8 | 'Opera >= 12', 9 | 'Safari >= 7.1', 10 | ]; 11 | const autoprefixer = require('autoprefixer')({ browsers: AUTOPREFIXER_BROWSERS }); 12 | 13 | module.exports = { 14 | plugins: [ 15 | autoprefixer, 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /src/learn/math/drill/types.js: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | 3 | const Types = {}; 4 | 5 | Types.previousResults = PropTypes.shape({ 6 | actuals: PropTypes.arrayOf(PropTypes.number).isRequired, 7 | id: PropTypes.number.isRequired, 8 | task: PropTypes.arrayOf(PropTypes.number).isRequired, // left, right, opIndex, answer 9 | timeTaken: PropTypes.number.isRequired, 10 | }); 11 | 12 | module.exports = Types; 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - CXX=g++-4.8 3 | language: node_js 4 | node_js: 5 | - "10" 6 | addons: 7 | apt: 8 | sources: 9 | - ubuntu-toolchain-r-test 10 | packages: 11 | - gcc-4.8 12 | - g++-4.8 13 | install: npm install 14 | before_install: 15 | # Don't need to install latest npm because Node >= 9 16 | # - npm install -g npm 17 | - npm install -g greenkeeper-lockfile@1 18 | before_script: greenkeeper-lockfile-update 19 | after_script: greenkeeper-lockfile-upload 20 | -------------------------------------------------------------------------------- /test/_setup.js: -------------------------------------------------------------------------------- 1 | 2 | global.requestAnimationFrame = (done) => { 3 | setTimeout(done, 0); 4 | }; 5 | 6 | // Prevent tests from passing if a prop-type is not correct by 7 | // throwing an error instead of doing a console.error() 8 | const { error } = console; 9 | // eslint-disable-next-line no-console 10 | console.error = (warning, ...args) => { 11 | if (/(Invalid prop|Failed prop type)/gi.test(warning)) { 12 | throw new Error(warning); 13 | } 14 | error.apply(console, [warning, ...args]); 15 | }; 16 | -------------------------------------------------------------------------------- /.vscode/cSpell.json: -------------------------------------------------------------------------------- 1 | // cSpell Settings 2 | { 3 | // Version of the setting file. Always 0.1 4 | "version": "0.1", 5 | // language - current active spelling language 6 | "language": "en", 7 | // words - list of words to be always considered correct 8 | "words": [ 9 | "actuals", 10 | "xmark" 11 | ], 12 | // flagWords - list of words to be always considered incorrect 13 | // This is useful for offensive words and common spelling errors. 14 | // For example "hte" should be "the" 15 | "flagWords": [] 16 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # learn 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/guyellis/learn.svg)](https://greenkeeper.io/) 4 | 5 | Experimental learning exercises for my kids 6 | 7 | Statically hosted at [learn.guyellisrocks.com](http://learn.guyellisrocks.com). 8 | 9 | ## Why? 10 | 11 | * I haven't been able to find a site or app that is really simple for the kids to use. 12 | * I want to host this statically so once loaded can be used offline. 13 | * I want to make it open source so that other developers with kids can use and contribute to it if they want more features. 14 | -------------------------------------------------------------------------------- /src/learn/math/drill/badge.jsx: -------------------------------------------------------------------------------- 1 | const FloatingActionButton = require('@material-ui/core/Fab').default; 2 | const PropTypes = require('prop-types'); 3 | const React = require('react'); 4 | 5 | const buttonStyle = { 6 | margin: '5px', 7 | fontSize: '1.5em', 8 | }; 9 | 10 | function badge({ content }) { 11 | return ( 12 | 17 | {`${content}`} 18 | 19 | ); 20 | } 21 | 22 | badge.propTypes = { 23 | content: PropTypes.string.isRequired, 24 | }; 25 | 26 | module.exports = badge; 27 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest": true, 6 | "jest/globals": true, 7 | "node": true 8 | }, 9 | "extends": [ 10 | "airbnb" 11 | ], 12 | "plugins": [ 13 | "jest" 14 | ], 15 | "parser": "babel-eslint", 16 | "rules": { 17 | "function-paren-newline": [0], 18 | "jest/no-disabled-tests": "warn", 19 | "jest/no-focused-tests": "error", 20 | "jest/no-identical-title": "error", 21 | "jest/prefer-to-have-length": "warn", 22 | "jest/valid-expect": "error", 23 | "jsx-a11y/anchor-is-valid": [0], 24 | "no-restricted-globals": [0], 25 | "react/no-unused-state": [0] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | testPathIgnorePatterns: ['/dist/'], 4 | collectCoverageFrom: [ 5 | '!**/.vscode/**', 6 | '!**/coverage/**', 7 | '!**/dist/**', 8 | '!**/jest.config.js', 9 | '!**/node_modules/**', 10 | '!**/postcss.config.js', 11 | '!**/public/**', 12 | '!**/src/dev/**', 13 | '!**/src/index.jsx', 14 | '!**/src/tester.jsx', 15 | '!**/test/**', 16 | '!**/webpack**', 17 | '**/*.{js,jsx}', 18 | ], 19 | coverageThreshold: { 20 | global: { 21 | branches: 50, 22 | functions: 70, 23 | lines: 70, 24 | statements: 70, 25 | }, 26 | }, 27 | setupFilesAfterEnv: ['/test/_setup.js'], 28 | transform: { 29 | '.+\\.(j|t)sx?$': 'ts-jest', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /test/learn/math/drill/previous-results.test.js: -------------------------------------------------------------------------------- 1 | const PreviousResults = require('../../../../src/learn/math/drill/previous-results'); 2 | 3 | describe('previous-results', () => { 4 | test('should sort by slowest', () => { 5 | const data = [{ 6 | timeTaken: 1, 7 | }, { 8 | timeTaken: 2, 9 | }]; 10 | const expected = [{ 11 | timeTaken: 2, 12 | }, { 13 | timeTaken: 1, 14 | }]; 15 | const actual = PreviousResults.sortBySlowest(data); 16 | expect(actual).toEqual(expected); 17 | }); 18 | 19 | test('should getStats on an empty array', () => { 20 | const actual = PreviousResults.getStats([]); 21 | const expected = { 22 | correctCount: 0, 23 | incorrects: [], 24 | totalTime: 0, 25 | }; 26 | expect(actual).toEqual(expected); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/learn/math/drill/badge-totals.test.jsx: -------------------------------------------------------------------------------- 1 | 2 | const React = require('react'); 3 | const renderer = require('react-test-renderer'); 4 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 5 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 6 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 7 | const BadgeTotals = require('../../../../src/learn/math/drill/badge-totals'); 8 | 9 | const muiTheme = getMuiTheme(lightBaseTheme); 10 | 11 | test('Badge totals should be rendered', () => { 12 | const component = renderer.create( 13 | 14 | 15 | ); 16 | const badgeTotals = component.toJSON(); 17 | expect(badgeTotals).toMatchSnapshot(); 18 | }); 19 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | const { MuiThemeProvider, createMuiTheme } = require('@material-ui/core/styles'); 2 | const React = require('react'); 3 | const ReactDOM = require('react-dom'); 4 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 5 | const { BrowserRouter } = require('react-router-dom'); 6 | const poly = require('./poly'); 7 | const App = require('./app.jsx'); 8 | 9 | const theme = createMuiTheme(lightBaseTheme); 10 | 11 | poly(undefined, (err) => { 12 | if (err) { 13 | // eslint-disable-next-line no-console 14 | console.error(err); 15 | } 16 | 17 | ReactDOM.render( 18 | ( 19 | 20 | 21 | 22 | 23 | 24 | ), document.querySelector('#app'), 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /test/learn/math/drill/badge.test.jsx: -------------------------------------------------------------------------------- 1 | 2 | const React = require('react'); 3 | const renderer = require('react-test-renderer'); 4 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 5 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 6 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 7 | const Badge = require('../../../../src/learn/math/drill/badge'); 8 | 9 | const muiTheme = getMuiTheme(lightBaseTheme); 10 | 11 | test('Badge should be rendered', () => { 12 | const component = renderer.create( 13 | 14 | 18 | ); 19 | const badgeTotals = component.toJSON(); 20 | expect(badgeTotals).toMatchSnapshot(); 21 | }); 22 | -------------------------------------------------------------------------------- /test/learn/math/drill/__snapshots__/badge.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Badge should be rendered 1`] = ` 4 | 33 | `; 34 | -------------------------------------------------------------------------------- /test/learn/math/drill/keyboard.test.jsx: -------------------------------------------------------------------------------- 1 | 2 | const React = require('react'); 3 | const renderer = require('react-test-renderer'); 4 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 5 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 6 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 7 | const Keyboard = require('../../../../src/learn/math/drill/keyboard'); 8 | 9 | const muiTheme = getMuiTheme(lightBaseTheme); 10 | 11 | test('Badge totals should be rendered', () => { 12 | const component = renderer.create( 13 | 14 | 19 | ); 20 | const badgeTotals = component.toJSON(); 21 | expect(badgeTotals).toMatchSnapshot(); 22 | }); 23 | -------------------------------------------------------------------------------- /test/app.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 4 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 5 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 6 | const { 7 | MemoryRouter, 8 | } = require('react-router-dom'); 9 | const App = require('../src/app'); 10 | 11 | const muiTheme = getMuiTheme(lightBaseTheme); 12 | 13 | describe('App', () => { 14 | test('should render an App component', () => { 15 | const component = renderer.create( 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | const app = component.toJSON(); 23 | expect(app).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Ignore build files 40 | public 41 | 42 | .vscode/chrome/ 43 | .nyc_output/ 44 | 45 | # webstorm 46 | .idea 47 | 48 | # tsc output 49 | dist/ 50 | 51 | .DS_Store 52 | -------------------------------------------------------------------------------- /test/learn/home.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 4 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 5 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 6 | const { 7 | MemoryRouter, 8 | } = require('react-router-dom'); 9 | const Home = require('../../src/learn/home'); 10 | 11 | const muiTheme = getMuiTheme(lightBaseTheme); 12 | 13 | describe('Home', () => { 14 | test('should render a Home component', () => { 15 | const component = renderer.create( 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | const app = component.toJSON(); 23 | expect(app).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/learn/menu.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 4 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 5 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 6 | const { 7 | MemoryRouter, 8 | } = require('react-router-dom'); 9 | const Menu = require('../../src/learn/menu'); 10 | 11 | const muiTheme = getMuiTheme(lightBaseTheme); 12 | 13 | describe('Menu', () => { 14 | test('should render a Menu component', () => { 15 | const component = renderer.create( 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | const app = component.toJSON(); 23 | expect(app).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/learn/help/index.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 4 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 5 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 6 | const { 7 | MemoryRouter, 8 | } = require('react-router-dom'); 9 | const Help = require('../../../src/learn/help'); 10 | 11 | const muiTheme = getMuiTheme(lightBaseTheme); 12 | 13 | describe('Help', () => { 14 | test('should render the Help component', () => { 15 | const component = renderer.create( 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | const app = component.toJSON(); 23 | expect(app).toMatchSnapshot(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app.jsx: -------------------------------------------------------------------------------- 1 | const { 2 | Route, Redirect, Switch, 3 | } = require('react-router-dom'); 4 | const React = require('react'); 5 | const Drill = require('./learn/math/drill'); 6 | const Help = require('./learn/help'); 7 | const Home = require('./learn/home'); 8 | const Menu = require('./learn/menu'); 9 | const Scoreboard = require('./learn/math/drill/scoreboard'); 10 | 11 | // const Tester = require('./tester'); 12 | // 13 | 14 | function app() { 15 | return ( 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | ); 27 | } 28 | 29 | module.exports = app; 30 | -------------------------------------------------------------------------------- /src/poly.js: -------------------------------------------------------------------------------- 1 | const defaultFeatures = [ 2 | 'Map', 3 | 'Set', 4 | 'requestAnimationFrame', 5 | ]; 6 | 7 | function browserSupportsAllFeatures(features) { 8 | return features.every((f) => window[f]); 9 | } 10 | 11 | function missingFeatures(features) { 12 | return features.filter((f) => !window[f]); 13 | } 14 | 15 | function loadScript(features, done) { 16 | const cdn = `https://cdn.polyfill.io/v2/polyfill.min.js?features=${missingFeatures(features).join()}`; 17 | const js = document.createElement('script'); 18 | js.src = cdn; 19 | js.onload = function onLoad() { 20 | done(); 21 | }; 22 | js.onerror = function onError() { 23 | done(new Error(`Failed to load script ${cdn}`)); 24 | }; 25 | document.head.appendChild(js); 26 | } 27 | 28 | module.exports = (featureList = defaultFeatures, done) => { 29 | if (browserSupportsAllFeatures(featureList)) { 30 | done(); 31 | } else { 32 | loadScript(featureList, done); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/learn/common/constants.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_OPTIONS = { 2 | largeKeyboard: false, 3 | levelIndex: 0, // A 4 | minutes: '10', 5 | onscreenKeyboard: false, 6 | opIndexes: [0], // + 7 | totalProblems: '20', 8 | userName: '', 9 | }; 10 | 11 | module.exports = { 12 | ALPHABET: Object.freeze('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')), 13 | BADGE_BOUNDARIES: Object.freeze([0, 2, 3, 4]), // Seconds per question boundaries for badges 14 | COLOR_HTML: Object.freeze(['#ffd700', '#c0c0c0', '#8C7853', 'lightblue']), 15 | COLOR_TEXT: Object.freeze(['Gold', 'Silver', 'Bronze', 'Blue']), 16 | DEFAULT_OPTIONS, 17 | MATH_DRILL_OPTIONS: 'mathDrillOptions', 18 | MATH_DRILL_SCORES: 'mathDrillScores', 19 | OPERATION_NAMES: Object.freeze(['Addition', 'Subtraction', 'Multiplication', 'Division']), 20 | OPERATIONS: Object.freeze(['+', '-', 'x', '\u00F7']), 21 | RECORD_EQUAL: 'RECORD_EQUAL', 22 | RECORD_MISS: 'RECORD_MISS', 23 | RECORD_NEW: 'RECORD_NEW', 24 | RECORD_NOT_EXIST: 'RECORD_NOT_EXIST', 25 | }; 26 | -------------------------------------------------------------------------------- /src/dev/test-score-bar.jsx: -------------------------------------------------------------------------------- 1 | // Used for experimenting with components that are WIP 2 | 3 | const React = require('react'); 4 | const ScoreBar = require('../learn/math/drill/score-bar'); 5 | 6 | // const helper = require('./learn/math/drill/helper'); 7 | // const scoreParams = require('../test/fixtures/local-storage'); 8 | 9 | // const scores = JSON.parse(scoreParams.mathDrillScores); 10 | // const times = helper.getScoreBarTimes(0, [0], scores); 11 | 12 | const times2 = [ 13 | { 14 | date: 1506199285558, 15 | timePerQuestion: 1.3, 16 | }, 17 | { 18 | date: 1506216253279, 19 | timePerQuestion: 2.3, 20 | }, 21 | { 22 | date: 1506218142889, 23 | timePerQuestion: 3.4, 24 | }, 25 | { 26 | date: 1506218821483, 27 | timePerQuestion: 4.3, 28 | }, 29 | { 30 | date: 1506218844955, 31 | timePerQuestion: 19, 32 | }, 33 | ]; 34 | 35 | function tester() { 36 | return ( 37 | 38 | ); 39 | } 40 | 41 | module.exports = tester; 42 | -------------------------------------------------------------------------------- /test/poly.test.js: -------------------------------------------------------------------------------- 1 | const poly = require('../src/poly'); 2 | 3 | describe('PolyFill', () => { 4 | test('should check PolyFill list', (done) => { 5 | poly(undefined, (error) => { 6 | expect(error).toBeFalsy(); 7 | done(); 8 | }); 9 | }); 10 | 11 | test('should check for dummy property', (done) => { 12 | const { appendChild } = document.head; 13 | document.head.appendChild = (js) => js.onload(); 14 | poly(['dummy'], (error) => { 15 | expect(error).toBeFalsy(); 16 | document.head.appendChild = appendChild; 17 | done(); 18 | }); 19 | }); 20 | 21 | test('should throw a fake error', (done) => { 22 | const { appendChild } = document.head; 23 | document.head.appendChild = (js) => js.onerror('Fake Error'); 24 | poly(['dummy'], (error) => { 25 | expect(error.message).toBe('Failed to load script https://cdn.polyfill.io/v2/polyfill.min.js?features=dummy'); 26 | document.head.appendChild = appendChild; 27 | done(); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tools/basics.js: -------------------------------------------------------------------------------- 1 | // This module generates pages of basic multiplication and division problems. 2 | // Multiplication: From 1x1 to 12x12 3 | // Division: From 1/1 to 144/12 4 | 5 | const _ = require('lodash'); 6 | const fse = require('fs-extra'); 7 | const path = require('path'); 8 | 9 | const allPairs = () => { 10 | const numbers = [...Array(12).keys()].map((a) => a + 1); 11 | 12 | const m = numbers.reduce((acc, num) => { 13 | const pairs = numbers.map((a) => ([a, num])); 14 | return acc.concat(pairs); 15 | }, []); 16 | 17 | 18 | const d = numbers.reduce((acc, num) => { 19 | const pairs = numbers.map((a) => ([a * num, num])); 20 | return acc.concat(pairs); 21 | }, []); 22 | 23 | const mult = m.reduce((acc, [a, b]) => acc.concat(`${a} x ${b} =`), []); 24 | const div = d.reduce((acc, [a, b]) => acc.concat(`${a} / ${b} =`), []); 25 | 26 | return _.shuffle(mult.concat(div)); 27 | }; 28 | 29 | const math = allPairs(); 30 | 31 | const text = math.join('\n'); 32 | 33 | fse.writeFileSync(path.join(__dirname, 'problems.txt'), text, 'utf-8'); 34 | -------------------------------------------------------------------------------- /test/learn/math/drill/quiz-line.test.jsx: -------------------------------------------------------------------------------- 1 | 2 | const React = require('react'); 3 | const renderer = require('react-test-renderer'); 4 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 5 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 6 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 7 | const QuizLine = require('../../../../src/learn/math/drill/quiz-line'); 8 | 9 | const muiTheme = getMuiTheme(lightBaseTheme); 10 | 11 | test('Badge totals should be rendered', () => { 12 | const component = renderer.create( 13 | 14 | 25 | ); 26 | const badgeTotals = component.toJSON(); 27 | expect(badgeTotals).toMatchSnapshot(); 28 | }); 29 | -------------------------------------------------------------------------------- /assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Guy Ellis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/learn/math/drill/scoreboard.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 4 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 5 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 6 | const db = require('../../../../src/learn/db'); 7 | const ScoreBoard = require('../../../../src/learn/math/drill/scoreboard'); 8 | 9 | const muiTheme = getMuiTheme(lightBaseTheme); 10 | 11 | describe('ScoreBoard', () => { 12 | beforeEach(() => { 13 | db.getScores = jest.fn(); 14 | }); 15 | 16 | afterEach(() => { 17 | db.getScores.mockClear(); 18 | }); 19 | 20 | test('should render showScoreBoard', () => { 21 | db.getScores.mockReturnValueOnce([{ 22 | levelIndex: 0, 23 | correctCount: 10, 24 | opIndexes: [0], 25 | timePerQuestion: 6, 26 | }]); 27 | 28 | const component = renderer.create( 29 | 30 | 31 | , 32 | ); 33 | 34 | const finishedBadge = component.toJSON(); 35 | expect(finishedBadge).toMatchSnapshot(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/learn/math/drill/badge-totals.jsx: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | const React = require('react'); 3 | const Badge = require('./badge'); 4 | const constants = require('../../common/constants'); 5 | 6 | const { COLOR_TEXT: colorText } = constants; 7 | 8 | const badgeBoundaries = [ 9 | '2 seconds or less (per question)', 10 | 'between 2 and 3 seconds (per question)', 11 | 'between 3 and 4 seconds (per question)', 12 | 'more than 4 seconds (per question)', 13 | ]; 14 | 15 | function badgeTotals(props) { 16 | const { totals } = props; 17 | 18 | return ( 19 |
20 | { 21 | totals.map((total, colorIndex) => { 22 | const key = colorText[colorIndex]; 23 | const primaryText = `${key} Badge(s) - ${badgeBoundaries[colorIndex]}`; 24 | 25 | return ( 26 |
27 | 28 | 29 | {primaryText} 30 | 31 |
32 | ); 33 | }) 34 | } 35 |
36 | ); 37 | } 38 | 39 | badgeTotals.propTypes = { 40 | totals: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired, 41 | }; 42 | 43 | module.exports = badgeTotals; 44 | -------------------------------------------------------------------------------- /test/learn/math/drill/index.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 4 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 5 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 6 | const Drill = require('../../../../src/learn/math/drill'); 7 | 8 | const muiTheme = getMuiTheme(lightBaseTheme); 9 | 10 | jest.mock('material-ui/internal/EnhancedSwitch'); 11 | 12 | describe('Drill', () => { 13 | test('should render the Drill component', () => { 14 | const savedOptions = { 15 | largeKeyboard: true, 16 | levelIndex: 0, // A 17 | minutes: '1', 18 | onscreenKeyboard: true, 19 | opIndexes: [0], // A 20 | totalProblems: '20', 21 | userName: 'my name', 22 | }; 23 | 24 | // eslint-disable-next-line no-proto 25 | jest.spyOn(window.localStorage.__proto__, 'getItem') 26 | .mockReturnValue(JSON.stringify(savedOptions)); 27 | 28 | const component = renderer.create( 29 | 30 | 31 | ); 32 | 33 | const app = component.toJSON(); 34 | expect(app).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#b3bdf0", 3 | "display": "standalone", 4 | "icons": [ 5 | { 6 | "sizes": "192x192", 7 | "src": "/android-chrome-192x192.png", 8 | "type": "image/png" 9 | }, 10 | { 11 | "sizes": "512x512", 12 | "src": "/android-chrome-512x512.png", 13 | "type": "image/png" 14 | }, 15 | { 16 | "density": "0.75", 17 | "sizes": "36x36", 18 | "src": "/android-icon-36x36.png", 19 | "type": "image/png" 20 | }, 21 | { 22 | "density": "1.0", 23 | "sizes": "48x48", 24 | "src": "/android-icon-48x48.png", 25 | "type": "image/png" 26 | }, 27 | { 28 | "density": "1.5", 29 | "sizes": "72x72", 30 | "src": "/android-icon-72x72.png", 31 | "type": "image/png" 32 | }, 33 | { 34 | "density": "2.0", 35 | "sizes": "96x96", 36 | "src": "/android-icon-96x96.png", 37 | "type": "image/png" 38 | }, 39 | { 40 | "density": "3.0", 41 | "sizes": "144x144", 42 | "src": "/android-icon-144x144.png", 43 | "type": "image/png" 44 | }, 45 | { 46 | "density": "4.0", 47 | "sizes": "192x192", 48 | "src": "/android-icon-192x192.png", 49 | "type": "image/png" 50 | } 51 | ], 52 | "name": "Learn", 53 | "short_name": "Learn", 54 | "theme_color": "#b3bdf0" 55 | } 56 | -------------------------------------------------------------------------------- /src/dev/test-finished.jsx: -------------------------------------------------------------------------------- 1 | // Used for experimenting with components that are WIP 2 | 3 | const React = require('react'); 4 | const constants = require('../learn/common/constants'); 5 | const Finished = require('../learn/math/drill/finished'); 6 | 7 | const { 8 | RECORD_NEW, 9 | // RECORD_EQUAL, 10 | // RECORD_MISS, 11 | // RECORD_NOT_EXIST, 12 | } = constants; 13 | 14 | const a = [...Array(10).keys()]; 15 | 16 | function tester() { 17 | const previousResults = a.map((id) => ({ 18 | actuals: [5], 19 | id, 20 | task: [5, 5, 0, 5], 21 | timeTaken: 1 + parseFloat((1.1 * id).toFixed(1), 10), 22 | })); 23 | const resultInfo = { 24 | text: 'Result Info Text', 25 | newRecordInfo: RECORD_NEW, 26 | }; 27 | const scoreBarTimes = [{ 28 | date: Date.now() - (1000 * 60 * 10), 29 | timePerQuestion: 1.5, 30 | }, { 31 | date: Date.now() - (1000 * 60 * 8), 32 | timePerQuestion: 3.5, 33 | }, { 34 | date: Date.now() - (1000 * 60 * 6), 35 | timePerQuestion: 3.5, 36 | }, { 37 | date: Date.now() - (1000 * 60 * 4), 38 | timePerQuestion: 4.5, 39 | }, { 40 | date: Date.now() - (1000 * 60 * 2), 41 | timePerQuestion: 1.5, 42 | }]; 43 | 44 | return ( 45 | 55 | ); 56 | } 57 | 58 | module.exports = tester; 59 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Jest (this file)", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceFolder}/node_modules/.bin/jest", 11 | "${relativeFile}" 12 | ], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "port": 9229 16 | }, 17 | { 18 | "name": "Chrome", 19 | "type": "chrome", 20 | "request": "launch", 21 | "webRoot": "${workspaceRoot}", 22 | "url": "http://localhost:8888", 23 | "userDataDir": "${workspaceRoot}/.vscode/chrome", 24 | "sourceMaps": true, 25 | "smartStep": true, 26 | "internalConsoleOptions": "openOnSessionStart", 27 | "skipFiles": [ 28 | "node_modules/**" 29 | ], 30 | "sourceMapPathOverrides": { 31 | "webpack:///*": "${webRoot}/*" 32 | } 33 | }, 34 | { 35 | "name": "Unit Test", 36 | "type": "node", 37 | "request": "launch", 38 | "program": "${workspaceRoot}/node_modules/.bin/jest", 39 | "args": ["-i"], 40 | "cwd": "${workspaceRoot}" 41 | }, 42 | { 43 | "name": "Debug Jest Tests", 44 | "type": "node", 45 | "request": "launch", 46 | "runtimeArgs": [ 47 | "--inspect-brk", 48 | "${workspaceRoot}/node_modules/.bin/jest", 49 | "--runInBand" 50 | ], 51 | "console": "integratedTerminal", 52 | "internalConsoleOptions": "neverOpen" 53 | } 54 | ] 55 | } -------------------------------------------------------------------------------- /src/learn/menu.jsx: -------------------------------------------------------------------------------- 1 | const { Link } = require('react-router-dom'); 2 | const Toolbar = require('@material-ui/core/Toolbar').default; 3 | // const PropTypes = require('prop-types'); 4 | const Button = require('@material-ui/core/Button').default; 5 | const React = require('react'); 6 | 7 | class ToolbarMenu extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | // TODO: Fix and remove this ESLint disable 11 | // eslint-disable-next-line react/state-in-constructor 12 | this.state = { 13 | value: 3, 14 | }; 15 | this.handleChange = (event, index, value) => this.setState({ value }); 16 | } 17 | 18 | render() { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | } 37 | 38 | // ToolbarMenu.propTypes = { 39 | // match: PropTypes.shape({ 40 | // params: PropTypes.shape({ 41 | // id: PropTypes.string.isRequired, 42 | // slug: PropTypes.string.isRequired, 43 | // }).isRequired, 44 | // }).isRequired, 45 | // history: React.PropTypes.shape({ 46 | // push: React.PropTypes.func.isRequired, 47 | // }).isRequired, 48 | // }; 49 | 50 | module.exports = ToolbarMenu; 51 | -------------------------------------------------------------------------------- /src/learn/math/drill/previous-results.js: -------------------------------------------------------------------------------- 1 | 2 | class PreviousResults { 3 | static sortBySlowest(previousResults = []) { 4 | return previousResults.sort((a, b) => b.timeTaken - a.timeTaken); 5 | } 6 | 7 | static getStats(previousResults = []) { 8 | return previousResults.reduce((acc, problem) => { 9 | const { task, actuals, timeTaken } = problem; 10 | const [actual] = actuals; 11 | const [,,, answer] = task; 12 | 13 | const { 14 | incorrects, 15 | } = acc; 16 | let { 17 | correctCount, 18 | longestTime, 19 | shortestTime, 20 | } = acc; 21 | 22 | if (!longestTime) { 23 | longestTime = problem; 24 | } else { 25 | const { timeTaken: currentLongTime } = longestTime; 26 | 27 | if (currentLongTime < timeTaken) { 28 | longestTime = problem; 29 | } 30 | } 31 | 32 | if (!shortestTime) { 33 | shortestTime = problem; 34 | } else { 35 | const { timeTaken: currentShortTime } = shortestTime; 36 | 37 | if (currentShortTime > timeTaken) { 38 | shortestTime = problem; 39 | } 40 | } 41 | 42 | 43 | if (answer === actual) { 44 | correctCount += 1; 45 | } 46 | if (actuals.length > 1 || answer !== actual) { 47 | incorrects.push(problem); 48 | } 49 | 50 | return { 51 | correctCount, 52 | incorrects, 53 | longestTime, 54 | shortestTime, 55 | totalTime: acc.totalTime + timeTaken, 56 | }; 57 | }, { correctCount: 0, incorrects: [], totalTime: 0 }); 58 | } 59 | } 60 | 61 | module.exports = PreviousResults; 62 | -------------------------------------------------------------------------------- /webpack.production.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const WebpackCleanupPlugin = require('webpack-cleanup-plugin'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const loaders = require('./webpack.loaders'); 7 | 8 | const { rules } = loaders.module; 9 | 10 | rules.push({ 11 | test: /\.scss$/, 12 | use: [ 13 | { loader: MiniCssExtractPlugin.loader }, 14 | { 15 | loader: 'css-loader', 16 | options: { 17 | sourceMap: true, 18 | }, 19 | }, 20 | { 21 | loader: 'sass-loader', 22 | options: { 23 | outputStyle: 'expanded', 24 | }, 25 | }, 26 | ], 27 | exclude: /node_modules/, 28 | }); 29 | 30 | module.exports = { 31 | entry: [ 32 | './src/index.jsx', 33 | './src/index.scss', 34 | ], 35 | output: { 36 | publicPath: '/', 37 | path: path.join(__dirname, 'public'), 38 | filename: '[chunkhash].js', 39 | }, 40 | resolve: { 41 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 42 | }, 43 | module: { 44 | rules, 45 | }, 46 | optimization: { 47 | minimize: true, 48 | }, 49 | plugins: [ 50 | new WebpackCleanupPlugin(), 51 | new webpack.DefinePlugin({ 52 | 'process.env': { 53 | NODE_ENV: '"production"', 54 | }, 55 | }), 56 | new webpack.optimize.OccurrenceOrderPlugin(), 57 | new MiniCssExtractPlugin({ 58 | filename: 'style.css', 59 | allChunks: true, 60 | }), 61 | new HtmlWebpackPlugin({ 62 | template: './src/template.html', 63 | files: { 64 | css: ['style.css'], 65 | js: ['bundle.js'], 66 | }, 67 | }), 68 | ], 69 | }; 70 | -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Learn 27 | 28 | 29 |
30 |
Loading...
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /webpack.loaders.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | module: { 3 | rules: [ 4 | { 5 | test: /\.(t|j)sx?$/, 6 | exclude: [/(node_modules|bower_components|public\/)/], 7 | use: { loader: 'awesome-typescript-loader?module=es6' }, 8 | }, 9 | { 10 | test: /\.js$/, 11 | use: { loader: 'source-map-loader' }, 12 | }, 13 | { 14 | test: /\.css$/, 15 | exclude: [/node_modules/], 16 | use: [ 17 | { loader: 'style-loader' }, 18 | { loader: 'css-loader?importLoaders=1' }, 19 | ], 20 | }, 21 | { 22 | test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, 23 | exclude: /(node_modules|bower_components)/, 24 | use: { loader: 'file-loader' }, 25 | }, 26 | { 27 | test: /\.(woff|woff2)$/, 28 | exclude: /(node_modules|bower_components)/, 29 | use: { loader: 'url-loader?prefix=font/&limit=5000' }, 30 | }, 31 | { 32 | test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, 33 | exclude: /(node_modules|bower_components)/, 34 | use: { loader: 'url-loader?limit=10000&mimetype=application/octet-stream' }, 35 | }, 36 | { 37 | test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, 38 | exclude: /(node_modules|bower_components)/, 39 | use: { loader: 'url-loader?limit=10000&mimetype=image/svg+xml' }, 40 | }, 41 | { 42 | test: /\.gif/, 43 | exclude: /(node_modules|bower_components)/, 44 | use: { loader: 'url-loader?limit=10000&mimetype=image/gif' }, 45 | }, 46 | { 47 | test: /\.jpg/, 48 | exclude: /(node_modules|bower_components)/, 49 | use: { loader: 'url-loader?limit=10000&mimetype=image/jpg' }, 50 | }, 51 | { 52 | test: /\.png/, 53 | exclude: /(node_modules|bower_components)/, 54 | use: { loader: 'url-loader?limit=10000&mimetype=image/png' }, 55 | }, 56 | ], 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const loaders = require('./webpack.loaders'); 6 | 7 | const { rules } = loaders.module; 8 | 9 | const HOST = process.env.HOST || '127.0.0.1'; 10 | const PORT = process.env.PORT || '8888'; 11 | 12 | rules.push({ 13 | test: /\.scss$/, 14 | use: [ 15 | { loader: 'style-loader' }, 16 | { loader: 'css-loader', options: { importLoaders: 1 } }, 17 | { loader: 'sass-loader' }, 18 | ], 19 | exclude: /node_modules/, 20 | }); 21 | 22 | module.exports = { 23 | entry: [ 24 | 'react-hot-loader/patch', 25 | './src/index.jsx', // your app's entry point 26 | ], 27 | devtool: process.env.WEBPACK_DEVTOOL || 'eval-source-map', 28 | output: { 29 | publicPath: '/', 30 | path: path.join(__dirname, 'public'), 31 | filename: 'bundle.js', 32 | }, 33 | resolve: { 34 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 35 | }, 36 | module: { 37 | rules, 38 | }, 39 | devServer: { 40 | contentBase: './public', 41 | // only output when errors or new compilation happen 42 | stats: 'minimal', 43 | // enable HMR 44 | hot: true, 45 | // embed the webpack-dev-server runtime into the bundle 46 | inline: true, 47 | // serve index.html in place of 404 responses to allow HTML5 history 48 | historyApiFallback: true, 49 | port: PORT, 50 | host: HOST, 51 | }, 52 | plugins: [ 53 | new webpack.NoEmitOnErrorsPlugin(), 54 | new webpack.NamedModulesPlugin(), 55 | new webpack.HotModuleReplacementPlugin(), 56 | new MiniCssExtractPlugin({ 57 | filename: 'style.css', 58 | allChunks: true, 59 | }), 60 | new HtmlWebpackPlugin({ 61 | template: './src/template.html', 62 | files: { 63 | css: ['style.css'], 64 | js: ['bundle.js'], 65 | }, 66 | }), 67 | ], 68 | }; 69 | -------------------------------------------------------------------------------- /test/learn/math/drill/finished-badge.test.jsx: -------------------------------------------------------------------------------- 1 | 2 | const React = require('react'); 3 | const renderer = require('react-test-renderer'); 4 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 5 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 6 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 7 | const FinishedBadge = require('../../../../src/learn/math/drill/finished-badge'); 8 | 9 | const muiTheme = getMuiTheme(lightBaseTheme); 10 | 11 | describe('Finished Badge', () => { 12 | test('should be rendered on happy path', () => { 13 | const component = renderer.create( 14 | 15 | 21 | ); 22 | const finishedBadge = component.toJSON(); 23 | expect(finishedBadge).toMatchSnapshot(); 24 | }); 25 | 26 | test('should be rendered if multiple opIndexes', () => { 27 | const component = renderer.create( 28 | 29 | 35 | ); 36 | const finishedBadge = component.toJSON(); 37 | expect(finishedBadge).toMatchSnapshot(); 38 | }); 39 | 40 | test('should not be rendered if correct answers under 10', () => { 41 | const component = renderer.create( 42 | 43 | 49 | ); 50 | const finishedBadge = component.toJSON(); 51 | expect(finishedBadge).toMatchSnapshot(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/learn/math/drill/finished-badge.jsx: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | const React = require('react'); 3 | const Badge = require('./badge'); 4 | const constants = require('../../common/constants'); 5 | const helper = require('./helper'); 6 | 7 | const finishedBadgeStyle = { 8 | 9 | }; 10 | 11 | const { 12 | COLOR_TEXT: colorText, 13 | ALPHABET: alphabet, 14 | OPERATION_NAMES: operationNames, 15 | OPERATIONS: operations, 16 | } = constants; 17 | 18 | function noBadge(message) { 19 | return ( 20 |
21 | {message} 22 |
23 | ); 24 | } 25 | 26 | function finishedBadge(props) { 27 | const { 28 | levelIndex, 29 | opIndexes, 30 | timePerQuestion, 31 | totalCorrectAnswers, 32 | } = props; 33 | 34 | if (totalCorrectAnswers < 10) { 35 | const message = `You need 10 or more correct answers to earn a badge. On this 36 | test you got ${totalCorrectAnswers} answer(s) correct.`; 37 | return noBadge(message); 38 | } 39 | 40 | const colorIndex = helper.getBadgeColorIndex(timePerQuestion); 41 | const color = colorText[colorIndex]; 42 | const letter = alphabet[levelIndex]; 43 | 44 | const operationName = opIndexes.length === 1 45 | ? operationNames[opIndexes[0]] 46 | : `Mixed ${opIndexes.map((opIndex) => operationNames[opIndex]).join(' / ')}`; 47 | 48 | const operation = opIndexes.length === 1 49 | ? operations[opIndexes[0]] 50 | : 'M'; 51 | 52 | return ( 53 |
54 | 55 | 56 | 57 | 58 | {` Congratulations on getting a new ${color} Badge for Level ${letter} ${operationName}!`} 59 | 60 |
61 | ); 62 | } 63 | 64 | finishedBadge.propTypes = { 65 | levelIndex: PropTypes.number.isRequired, 66 | opIndexes: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired, 67 | timePerQuestion: PropTypes.number.isRequired, 68 | totalCorrectAnswers: PropTypes.number.isRequired, 69 | }; 70 | 71 | module.exports = finishedBadge; 72 | -------------------------------------------------------------------------------- /test/learn/math/drill/__snapshots__/finished-badge.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Finished Badge should be rendered if multiple opIndexes 1`] = ` 4 |
7 | 8 | 37 | 38 | 39 | Congratulations on getting a new Silver Badge for Level A Mixed Addition / Subtraction! 40 | 41 |
42 | `; 43 | 44 | exports[`Finished Badge should be rendered on happy path 1`] = ` 45 |
48 | 49 | 78 | 79 | 80 | Congratulations on getting a new Silver Badge for Level A Addition! 81 | 82 |
83 | `; 84 | 85 | exports[`Finished Badge should not be rendered if correct answers under 10 1`] = ` 86 |
89 | You need 10 or more correct answers to earn a badge. On this 90 | test you got 9 answer(s) correct. 91 |
92 | `; 93 | -------------------------------------------------------------------------------- /src/learn/home.jsx: -------------------------------------------------------------------------------- 1 | const FloatingActionButton = require('@material-ui/core/Fab').default; 2 | const { Link } = require('react-router-dom'); 3 | const Button = require('@material-ui/core/Button').default; 4 | const React = require('react'); 5 | const { Share, BugReport } = require('@material-ui/icons'); 6 | const Tooltip = require('@material-ui/core/Tooltip').default; 7 | 8 | const style = { 9 | marginTop: '20px', 10 | }; 11 | 12 | const githubIconStyle = { 13 | textAlign: 'right', 14 | marginTop: '20px', 15 | }; 16 | 17 | function share() { 18 | navigator.share({ 19 | title: 'Math Drill', 20 | text: 'Math Exercises for Kids', 21 | url: 'https://learn.guyellisrocks.com', 22 | }) 23 | // eslint-disable-next-line no-console 24 | .then(() => console.log('Successful share')) 25 | // eslint-disable-next-line no-console 26 | .catch((error) => console.log('Error sharing', error)); 27 | } 28 | 29 | function shareComponent() { 30 | if (!navigator.share) { 31 | return null; 32 | } 33 | const shareStyle = { 34 | paddingLeft: '20px', 35 | }; 36 | return ( 37 | 38 | 39 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | 51 | function home() { 52 | return ( 53 |
54 |
55 | 61 | 62 | 66 | 67 | 68 | 69 | 70 | {shareComponent()} 71 |
72 |
73 | 74 | 75 | 76 |
77 |
78 | 79 | 80 | 81 |
82 |
83 | 84 | 85 | 86 |
87 |
88 | ); 89 | } 90 | 91 | module.exports = home; 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Guy Ellis ", 3 | "bugs": { 4 | "url": "https://github.com/guyellis/learn/issues" 5 | }, 6 | "dependencies": { 7 | "@material-ui/core": "4.3.3", 8 | "@material-ui/icons": "4.2.1", 9 | "autoprefixer": "10.4.16", 10 | "fs-extra": "8.1.0", 11 | "html-webpack-plugin": "5.5.0", 12 | "lodash": "4.17.21", 13 | "material-ui": "0.20.2", 14 | "mini-css-extract-plugin": "0.8.0", 15 | "moment": "2.29.4", 16 | "node-sass": "9.0.0", 17 | "prop-types": "15.7.2", 18 | "react": "16.9.0", 19 | "react-dom": "16.9.0", 20 | "react-markdown": "8.0.4", 21 | "react-router-dom": "5.1.2", 22 | "sass-loader": "7.3.1", 23 | "webpack": "5.94.0", 24 | "webpack-cleanup-plugin": "0.5.1", 25 | "webpack-cli": "3.3.12" 26 | }, 27 | "description": "Experimental learning exercises for my kids", 28 | "devDependencies": { 29 | "awesome-typescript-loader": "5.2.1", 30 | "babel-eslint": "10.0.3", 31 | "css-loader": "6.8.1", 32 | "eslint": "6.7.2", 33 | "eslint-config-airbnb": "18.0.1", 34 | "eslint-config-airbnb-base": "14.0.0", 35 | "eslint-plugin-import": "2.18.2", 36 | "eslint-plugin-jest": "23.1.1", 37 | "eslint-plugin-jsx-a11y": "6.2.3", 38 | "eslint-plugin-react": "7.14.3", 39 | "file-loader": "5.0.2", 40 | "jest": "29.3.1", 41 | "postcss-loader": "7.3.3", 42 | "pre-commit": "1.2.2", 43 | "react-hot-loader": "4.12.18", 44 | "react-test-renderer": "16.8.6", 45 | "source-map-loader": "0.2.4", 46 | "style-loader": "1.0.1", 47 | "ts-jest": "29.0.3", 48 | "typescript": "3.5.3", 49 | "url-loader": "3.0.0", 50 | "webpack-dev-server": "5.2.1" 51 | }, 52 | "engines": { 53 | "node": ">=10", 54 | "npm": ">=6" 55 | }, 56 | "homepage": "https://github.com/guyellis/learn#readme", 57 | "license": "MIT", 58 | "main": "index.js", 59 | "name": "learn", 60 | "pre-commit": { 61 | "colors": true, 62 | "run": [ 63 | "test" 64 | ], 65 | "silent": false 66 | }, 67 | "repository": { 68 | "type": "git", 69 | "url": "git+https://github.com/guyellis/learn.git" 70 | }, 71 | "scripts": { 72 | "build": "webpack --config webpack.production.config.js --progress --profile --colors", 73 | "coverage:view": "google-chrome coverage/lcov-report/index.html", 74 | "coverage": "tsc && jest --coverage", 75 | "deploy": "npm run build && npm run asset-copy && npm run upload", 76 | "lint": "eslint --ext js --ext jsx .", 77 | "lintfix": "eslint --ext js --ext jsx . --fix", 78 | "start": "webpack-dev-server --hot --progress --profile --colors", 79 | "test": "npm run lint && npm run lintfix && tsc && jest --coverage && npm run build", 80 | "asset-copy": "cp -R assets/* public/", 81 | "upload": "aws s3 cp public s3://learn.guyellisrocks.com/ --recursive" 82 | }, 83 | "version": "0.0.2" 84 | } 85 | -------------------------------------------------------------------------------- /test/learn/math/drill/score-bar.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const ScoreBar = require('../../../../src/learn/math/drill/score-bar'); 4 | 5 | function mockDates(times) { 6 | Date.now = jest.fn(); 7 | times.forEach((time) => { 8 | const mockDate = time.date + time.dateMockDelta; 9 | Date.now.mockImplementationOnce(() => mockDate); 10 | }); 11 | } 12 | 13 | describe('ScoreBar', () => { 14 | const nowFn = Date.now; 15 | afterAll(() => { 16 | Date.now = nowFn; 17 | }); 18 | 19 | test('should not show if showScoreBar is false', () => { 20 | const times = []; 21 | 22 | const component = renderer.create(); 26 | 27 | const finishedBadge = component.toJSON(); 28 | expect(finishedBadge).toMatchSnapshot(); 29 | }); 30 | 31 | test('should not show if times is empty', () => { 32 | const times = []; 33 | 34 | const component = renderer.create(); 38 | 39 | const finishedBadge = component.toJSON(); 40 | expect(finishedBadge).toMatchSnapshot(); 41 | }); 42 | 43 | test('should show 1 column with Gold Badge', () => { 44 | const times = [{ 45 | date: 1506395542848, // 9/25/2017 8:12pm PST 46 | timePerQuestion: 1.5, 47 | dateMockDelta: 1000 * 60 * 4, 48 | }]; 49 | 50 | mockDates(times); 51 | 52 | const component = renderer.create(); 56 | 57 | const finishedBadge = component.toJSON(); 58 | expect(finishedBadge).toMatchSnapshot(); 59 | }); 60 | 61 | test('should show 1 column with below Blue Badge', () => { 62 | const times = [{ 63 | date: 1506395542848, // 9/25/2017 8:12pm PST 64 | timePerQuestion: 15.2, 65 | dateMockDelta: 1000 * 60 * 4, 66 | }]; 67 | 68 | mockDates(times); 69 | 70 | const component = renderer.create(); 74 | 75 | const finishedBadge = component.toJSON(); 76 | expect(finishedBadge).toMatchSnapshot(); 77 | }); 78 | 79 | test('should show 5 columns with mixed Badges', () => { 80 | const times = [{ 81 | date: 1506395542848, // 9/25/2017 8:12pm PST 82 | timePerQuestion: 15.2, 83 | dateMockDelta: 1000 * 60 * 4, 84 | }, { 85 | date: 1506395642848, // A bit later 86 | timePerQuestion: 10.2, 87 | dateMockDelta: 1000 * 60 * 2, 88 | }, { 89 | date: 1506395742848, // And later 90 | timePerQuestion: 5.2, 91 | dateMockDelta: 1000 * 3, 92 | }]; 93 | 94 | mockDates(times); 95 | 96 | const component = renderer.create(); 100 | 101 | const finishedBadge = component.toJSON(); 102 | expect(finishedBadge).toMatchSnapshot(); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/learn/math/drill/__snapshots__/badge-totals.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Badge totals should be rendered 1`] = ` 4 |
5 |
6 | 35 | 36 | Gold Badge(s) - 2 seconds or less (per question) 37 | 38 |
39 |
40 | 69 | 70 | Silver Badge(s) - between 2 and 3 seconds (per question) 71 | 72 |
73 |
74 | 103 | 104 | Bronze Badge(s) - between 3 and 4 seconds (per question) 105 | 106 |
107 |
108 | `; 109 | -------------------------------------------------------------------------------- /src/learn/math/drill/scoreboard.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const TableHead = require('@material-ui/core/TableHead').default; 3 | const TableRow = require('@material-ui/core/TableRow').default; 4 | const TableBody = require('@material-ui/core/TableBody').default; 5 | const TableCell = require('@material-ui/core/TableCell').default; 6 | const Table = require('@material-ui/core/Table').default; 7 | const Paper = require('@material-ui/core/Paper').default; 8 | 9 | const Badge = require('./badge'); 10 | const BadgeTotals = require('./badge-totals'); 11 | const constants = require('../../common/constants'); 12 | const helper = require('./helper'); 13 | 14 | const { 15 | ALPHABET: alphabet, 16 | OPERATION_NAMES: operationNames, 17 | } = constants; 18 | 19 | 20 | function badge(badges) { 21 | if (!badges) { 22 | return null; 23 | } 24 | return ( 25 | 26 | { 27 | badges.map((amount, color) => { 28 | if (!amount) { 29 | return null; 30 | } 31 | const key = `${amount}${color}`; 32 | 33 | return ( 34 | 39 | ); 40 | }) 41 | } 42 | 43 | ); 44 | } 45 | 46 | function scoreboard() { 47 | const { ops: operations, totals, levels } = helper.getScoreboard(); 48 | const ops = [...operations]; 49 | const opNames = ops.map((op) => { 50 | const operators = op.split(''); 51 | const names = operators.map((operator) => operationNames[parseInt(operator, 10)]); 52 | return names.join(' / '); 53 | }); 54 | /* 55 | { 56 | ops, // A Set of strings representing operators 57 | totals, // A 4 element array of badge totals 58 | levels, // An sparse array of up to 26 elements of objects with keys matching values in ops Set 59 | } 60 | */ 61 | 62 | return ( 63 |
64 |

65 | Scoreboard 66 |

67 | 68 | 69 | 70 | 71 | 72 | Level 73 | {opNames.map((opName) => ( 74 | 75 | {opName} 76 | 77 | ))} 78 | 79 | 80 | 81 | {levels.map((level, index) => (level ? ( 82 | 83 | 84 | {alphabet[index]} 85 | 86 | {ops.map((op) => ( 87 | {badge(level[op])} 88 | ))} 89 | 90 | ) : ('') 91 | ))} 92 | 93 |
94 |
95 |
96 | ); 97 | } 98 | 99 | module.exports = scoreboard; 100 | -------------------------------------------------------------------------------- /src/learn/math/drill/running-results.jsx: -------------------------------------------------------------------------------- 1 | 2 | const React = require('react'); 3 | const PropTypes = require('prop-types'); 4 | const constants = require('../../common/constants'); 5 | const Badge = require('./badge'); 6 | const helper = require('./helper'); 7 | 8 | const { OPERATIONS } = constants; 9 | const spanStyle = { paddingLeft: 20 }; 10 | const inABox = { 11 | borderWidth: 1, 12 | borderStyle: 'solid', 13 | marginLeft: 20, 14 | padding: 3, 15 | }; 16 | const check = '\u2714'; 17 | const xmark = '\u2717'; 18 | const lineStyle = { lineHeight: '35px' }; 19 | const correctStyle = { color: 'green', ...lineStyle }; 20 | const incorrectStyle = { color: 'red', ...lineStyle }; 21 | const spanStyleIncorrect = { color: 'red', ...spanStyle }; 22 | 23 | function runningResults(props) { 24 | const { previousResults, showIndex } = props; 25 | const previousResultRows = previousResults.map(({ 26 | task, actuals, timeTaken, id, 27 | }) => { 28 | const [left, right, opIndex, answer] = task; 29 | const [actual] = actuals; // most recent answer is first in array 30 | const incorrects = actuals.slice(1).reverse(); 31 | const operation = OPERATIONS[opIndex]; 32 | const correct = answer === actual; 33 | const style = correct ? correctStyle : incorrectStyle; 34 | const colorIndex = helper.getBadgeColorIndex(timeTaken); 35 | return ( 36 |
37 | { 38 | showIndex 39 | && ( 40 | 41 | {`${id + 1})`} 42 | 43 | ) 44 | } 45 | 46 | 47 | {left} 48 | 49 | 50 | {operation} 51 | 52 | 53 | {right} 54 | 55 | 56 | = 57 | 58 | 59 | {actual} 60 | 61 | 62 | {correct ? check : xmark} 63 | 64 | 65 | {`${timeTaken} seconds`} 66 | 67 | { 68 | !!incorrects.length 69 | && ( 70 | 71 | {`Incorrect answer(s): ${incorrects.join()}`} 72 | 73 | ) 74 | } 75 |
76 | ); 77 | }); 78 | return ( 79 |
80 | {previousResultRows} 81 |
82 | ); 83 | } 84 | 85 | runningResults.propTypes = { 86 | previousResults: PropTypes.arrayOf(PropTypes.shape({ 87 | actuals: PropTypes.arrayOf(PropTypes.number).isRequired, 88 | id: PropTypes.number.isRequired, 89 | task: PropTypes.array.isRequired, // left, right, opIndex, answer 90 | timeTaken: PropTypes.number.isRequired, 91 | })).isRequired, 92 | showIndex: PropTypes.bool, 93 | }; 94 | 95 | runningResults.defaultProps = { 96 | showIndex: false, 97 | }; 98 | 99 | module.exports = runningResults; 100 | -------------------------------------------------------------------------------- /src/learn/math/drill/running.jsx: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | const React = require('react'); 3 | const constants = require('../../common/constants'); 4 | const QuizLine = require('./quiz-line'); 5 | const RunningResults = require('./running-results'); 6 | 7 | const { 8 | ALPHABET: alphabet, 9 | OPERATIONS: operations, 10 | } = constants; 11 | 12 | function running(props) { 13 | const { 14 | checkAnswer, 15 | currentTask, 16 | largeKeyboard, 17 | levelIndex, 18 | newRecord, 19 | onscreenKeyboard, 20 | opIndexes, 21 | previousResults, 22 | questionsRemaining, 23 | timeLeft, 24 | } = props; 25 | 26 | if (!currentTask) { 27 | // eslint-disable-next-line no-console 28 | console.warn('No currentTask defined in renderRunning'); 29 | return null; 30 | } 31 | 32 | const spanStyle = { 33 | paddingLeft: 20, 34 | }; 35 | 36 | const lastTen = previousResults.slice(Math.max(0, previousResults.length - 10)).reverse(); 37 | const lastResult = lastTen[0]; 38 | 39 | return ( 40 |
41 |
42 | 43 | {`Level: ${alphabet[levelIndex]}`} 44 | 45 | 46 | {`Operation(s): ${opIndexes.map((i) => operations[i]).join()}`} 47 | 48 |
49 |
50 | 51 | {`Time Left: ${timeLeft} seconds`} 52 | 53 | 54 | {`Questions Remaining: ${questionsRemaining}`} 55 | 56 |
57 |
58 | 66 | 70 |
71 |
72 | ); 73 | } 74 | 75 | running.propTypes = { 76 | checkAnswer: PropTypes.func.isRequired, 77 | currentTask: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types 78 | largeKeyboard: PropTypes.bool.isRequired, 79 | levelIndex: PropTypes.number.isRequired, 80 | newRecord: PropTypes.shape({ 81 | isNewRecord: PropTypes.bool.isRequired, 82 | currentTimePerQuestion: PropTypes.number.isRequired, 83 | existingRecordTimePerQuestion: PropTypes.number.isRequired, 84 | }).isRequired, 85 | onscreenKeyboard: PropTypes.bool.isRequired, 86 | opIndexes: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired, 87 | previousResults: PropTypes.arrayOf(PropTypes.shape({ 88 | task: PropTypes.array.isRequired, // left, right, opIndex, answer 89 | actuals: PropTypes.arrayOf(PropTypes.number).isRequired, 90 | timeTaken: PropTypes.number.isRequired, 91 | id: PropTypes.number.isRequired, 92 | })).isRequired, 93 | questionsRemaining: PropTypes.number.isRequired, 94 | timeLeft: PropTypes.number.isRequired, 95 | }; 96 | 97 | running.defaultProps = { 98 | // currentRecord: null, 99 | }; 100 | 101 | module.exports = running; 102 | -------------------------------------------------------------------------------- /test/learn/__snapshots__/menu.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Menu should render a Menu component 1`] = ` 4 | 120 | `; 121 | -------------------------------------------------------------------------------- /src/learn/math/drill/keyboard.jsx: -------------------------------------------------------------------------------- 1 | const FloatingActionButton = require('@material-ui/core/Fab').default; 2 | const PropTypes = require('prop-types'); 3 | const React = require('react'); 4 | const { Backspace, KeyboardReturn } = require('@material-ui/icons'); 5 | const Tooltip = require('@material-ui/core/Tooltip').default; 6 | 7 | const normalButtonStyle = { 8 | margin: '5px', 9 | fontSize: '2em', 10 | }; 11 | 12 | const largeButtonStyle = { 13 | height: '80px', 14 | width: '80px', 15 | margin: '5px', 16 | fontSize: '2em', 17 | }; 18 | 19 | let buttonStyle = {}; 20 | 21 | // eslint-disable-next-line react/prefer-stateless-function 22 | class Keyboard extends React.Component { 23 | constructor(props) { 24 | super(props); 25 | const buttons = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'back', 'enter', 'nothing']; 26 | this.click = buttons.reduce((acc, item) => { 27 | acc[item] = this.onClick.bind(this, item); 28 | return acc; 29 | }, {}); 30 | 31 | if (props.largeKeyboard) { 32 | buttonStyle = { ...largeButtonStyle }; 33 | } else { 34 | buttonStyle = { ...normalButtonStyle }; 35 | } 36 | } 37 | 38 | onClick(value) { 39 | if (value === 'nothing') { 40 | return; 41 | } 42 | const { keyPress } = this.props; 43 | keyPress(value); 44 | } 45 | 46 | render() { 47 | const { 48 | onscreenKeyboard, 49 | } = this.props; 50 | 51 | if (!onscreenKeyboard) { 52 | return null; 53 | } 54 | 55 | const layout = [ 56 | [ 57 | '1', // key 58 | ['1', 'One', 1, '1'], 59 | ['2', 'Two', 2, '2'], 60 | ['3', 'Three', 3, '3'], 61 | [, 'Backspace', 'back', '4'], 62 | ], 63 | [ 64 | '2', // key 65 | ['4', 'Four', 4, '5'], 66 | ['5', 'Five', 5, '6'], 67 | ['6', 'Six', 6, '7'], 68 | [' ', ' ', 'Nothing1', '8'], 69 | ], 70 | [ 71 | '3', // key 72 | ['7', 'Seven', 7, '9'], 73 | ['8', 'Eight', 8, '10'], 74 | ['9', 'Nine', 9, '11'], 75 | [' ', ' ', 'Nothing2', '12'], 76 | ], 77 | [ 78 | '4', // key 79 | [' ', ' ', 'Nothing3', '13'], 80 | ['0', 'Zero', 0, '14'], 81 | [' ', ' ', 'Nothing4', '15'], 82 | [, 'Enter', 'enter', '16'], 83 | ], 84 | ]; 85 | 86 | return ( 87 |
88 | { 89 | layout.map((lay) => ( 90 |
91 | { 92 | lay.slice(1).map((item) => { 93 | const [content, title, click, key] = item; 94 | return ( 95 | 96 | this.onClick(click)} 99 | style={buttonStyle} 100 | > 101 | {content} 102 | 103 | 104 | ); 105 | }) 106 | } 107 |
108 | )) 109 | } 110 |
111 | ); 112 | } 113 | } 114 | 115 | Keyboard.propTypes = { 116 | keyPress: PropTypes.func.isRequired, 117 | onscreenKeyboard: PropTypes.bool.isRequired, 118 | largeKeyboard: PropTypes.bool.isRequired, 119 | }; 120 | 121 | module.exports = Keyboard; 122 | -------------------------------------------------------------------------------- /test/learn/math/drill/__snapshots__/quiz-line.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Badge totals should be rendered 1`] = ` 4 |
5 |
13 | 21 | 1 22 | 23 | 31 | + 32 | 33 | 41 | 1 42 | 43 | 51 | = 52 | 53 |
62 |
66 | 80 |
81 |
82 | 122 |
123 |
136 | Ready for your first answer... 137 |
138 |
139 |
149 | Current Speed 1.1 and Record 1 150 |
151 |
152 |
153 |
154 | `; 155 | -------------------------------------------------------------------------------- /src/learn/db/index.js: -------------------------------------------------------------------------------- 1 | const constants = require('../common/constants'); 2 | 3 | const { 4 | DEFAULT_OPTIONS, 5 | MATH_DRILL_OPTIONS, 6 | MATH_DRILL_SCORES, 7 | } = constants; 8 | 9 | class DB { 10 | static setItem(key, value) { 11 | localStorage.setItem(key, JSON.stringify(value)); 12 | } 13 | 14 | static getItem(key) { 15 | const item = localStorage.getItem(key); 16 | return item ? JSON.parse(item) : item; 17 | } 18 | 19 | static saveOptions(data) { 20 | DB.setItem(MATH_DRILL_OPTIONS, data); 21 | } 22 | 23 | static getOptions() { 24 | let options = DB.getItem(MATH_DRILL_OPTIONS); 25 | if (options) { 26 | if (typeof options.userName !== 'string') { 27 | options.userName = ''; 28 | DB.saveOptions(options); 29 | } 30 | if (typeof options.largeKeyboard !== 'boolean') { 31 | options.largeKeyboard = false; 32 | DB.saveOptions(options); 33 | } 34 | if (typeof options.opIndex === 'number') { 35 | options.opIndexes = [options.opIndex]; 36 | delete options.opIndex; 37 | DB.saveOptions(options); 38 | } 39 | } else { 40 | options = DEFAULT_OPTIONS; 41 | DB.saveOptions(options); 42 | } 43 | return options; 44 | } 45 | 46 | static saveScores(data) { 47 | DB.setItem(MATH_DRILL_SCORES, data); 48 | } 49 | 50 | static appendScore(score) { 51 | const scores = DB.getScores() || []; 52 | scores.push(score); 53 | DB.saveScores({ 54 | version: 3, 55 | scores, 56 | }); 57 | } 58 | 59 | 60 | /** 61 | * Converts the scores in localStorage from Version 1 to 2 62 | * @static 63 | * @param {array} scores - Version 1 array of scores 64 | * @returns {object} - new scores shape 65 | * @memberof DB 66 | */ 67 | static convertVersion1(scores) { 68 | // Need to change it to an object and the previousResults array needs to have the 69 | // actual property changed to an actuals array. 70 | scores.forEach((score) => { 71 | if (score.previousResults) { 72 | score.previousResults.forEach((previousResult) => { 73 | if (previousResult.actual) { 74 | // eslint-disable-next-line no-param-reassign 75 | previousResult.actuals = [previousResult.actual]; 76 | // eslint-disable-next-line no-param-reassign 77 | delete previousResult.actual; 78 | } 79 | }); 80 | } 81 | }); 82 | 83 | return { 84 | version: 2, 85 | scores, 86 | }; 87 | } 88 | 89 | 90 | /** 91 | * Converts the scores in localStorage from Version 2 to 3 92 | * @static 93 | * @param {object} scores - version 2 scores 94 | * @memberof DB 95 | */ 96 | static convertVersion2(scoresObj) { 97 | const { version, scores } = scoresObj; 98 | if (version !== 2) { 99 | throw new Error(`version must be 2 and not ${version} in convertVersion2`); 100 | } 101 | // Fix bug - "key" in scores objects should have had a delimiter between the level 102 | // and the operators. 103 | scores.forEach((score) => { 104 | const { levelIndex, opIndexes } = score; 105 | // eslint-disable-next-line no-param-reassign 106 | score.key = `${levelIndex}-${opIndexes.sort().join('')}`; 107 | }); 108 | 109 | return { 110 | version: 3, 111 | scores, 112 | }; 113 | } 114 | 115 | static getScores() { 116 | const scores = DB.getItem(MATH_DRILL_SCORES); 117 | 118 | if (Array.isArray(scores)) { 119 | const convertedScores = DB.convertVersion1(scores); 120 | DB.saveScores(convertedScores); 121 | return DB.getScores(); 122 | } 123 | 124 | if (scores && scores.version === 2) { 125 | const convertedScores = DB.convertVersion2(scores); 126 | DB.saveScores(convertedScores); 127 | return DB.getScores(); 128 | } 129 | 130 | return scores && scores.scores; 131 | } 132 | } 133 | 134 | module.exports = DB; 135 | -------------------------------------------------------------------------------- /tools/problems.txt: -------------------------------------------------------------------------------- 1 | 10 / 10 = 2 | 96 / 8 = 3 | 8 x 10 = 4 | 6 x 9 = 5 | 11 x 9 = 6 | 9 x 7 = 7 | 50 / 5 = 8 | 12 / 6 = 9 | 24 / 4 = 10 | 7 x 11 = 11 | 9 x 9 = 12 | 2 x 4 = 13 | 108 / 12 = 14 | 2 x 11 = 15 | 77 / 7 = 16 | 32 / 4 = 17 | 2 / 1 = 18 | 20 / 4 = 19 | 10 x 6 = 20 | 18 / 6 = 21 | 6 x 5 = 22 | 10 x 8 = 23 | 12 / 1 = 24 | 30 / 3 = 25 | 7 x 4 = 26 | 72 / 6 = 27 | 42 / 7 = 28 | 10 x 4 = 29 | 96 / 12 = 30 | 30 / 5 = 31 | 121 / 11 = 32 | 9 x 11 = 33 | 108 / 9 = 34 | 50 / 10 = 35 | 99 / 11 = 36 | 10 / 2 = 37 | 22 / 2 = 38 | 11 x 10 = 39 | 84 / 12 = 40 | 18 / 3 = 41 | 12 x 12 = 42 | 10 x 12 = 43 | 8 x 4 = 44 | 4 / 4 = 45 | 5 x 5 = 46 | 11 x 2 = 47 | 7 x 8 = 48 | 2 x 1 = 49 | 44 / 11 = 50 | 7 x 3 = 51 | 64 / 8 = 52 | 9 x 1 = 53 | 16 / 2 = 54 | 56 / 8 = 55 | 10 x 1 = 56 | 12 x 7 = 57 | 24 / 12 = 58 | 18 / 2 = 59 | 10 / 1 = 60 | 6 x 2 = 61 | 15 / 5 = 62 | 7 x 2 = 63 | 9 x 10 = 64 | 27 / 3 = 65 | 10 x 5 = 66 | 40 / 4 = 67 | 3 / 3 = 68 | 9 x 8 = 69 | 3 x 3 = 70 | 2 x 6 = 71 | 8 / 2 = 72 | 7 / 7 = 73 | 24 / 2 = 74 | 9 / 9 = 75 | 1 x 5 = 76 | 6 x 7 = 77 | 12 x 11 = 78 | 48 / 12 = 79 | 5 x 3 = 80 | 7 x 12 = 81 | 9 x 4 = 82 | 33 / 11 = 83 | 1 x 4 = 84 | 11 x 6 = 85 | 12 x 4 = 86 | 4 / 2 = 87 | 35 / 5 = 88 | 3 x 11 = 89 | 6 x 12 = 90 | 8 x 1 = 91 | 8 / 4 = 92 | 60 / 12 = 93 | 12 x 2 = 94 | 88 / 11 = 95 | 60 / 6 = 96 | 32 / 8 = 97 | 5 x 6 = 98 | 72 / 12 = 99 | 2 x 10 = 100 | 45 / 5 = 101 | 11 x 3 = 102 | 12 x 1 = 103 | 60 / 10 = 104 | 1 / 1 = 105 | 36 / 6 = 106 | 21 / 7 = 107 | 8 x 7 = 108 | 12 / 2 = 109 | 35 / 7 = 110 | 4 / 1 = 111 | 7 x 9 = 112 | 48 / 6 = 113 | 11 / 1 = 114 | 2 x 8 = 115 | 8 x 9 = 116 | 16 / 8 = 117 | 144 / 12 = 118 | 18 / 9 = 119 | 8 x 11 = 120 | 11 x 5 = 121 | 3 x 10 = 122 | 1 x 9 = 123 | 10 x 3 = 124 | 4 x 7 = 125 | 1 x 2 = 126 | 24 / 8 = 127 | 14 / 7 = 128 | 2 / 2 = 129 | 100 / 10 = 130 | 63 / 9 = 131 | 56 / 7 = 132 | 9 x 3 = 133 | 5 x 9 = 134 | 4 x 10 = 135 | 49 / 7 = 136 | 20 / 5 = 137 | 7 x 5 = 138 | 10 x 11 = 139 | 9 x 2 = 140 | 9 / 3 = 141 | 12 x 10 = 142 | 4 x 9 = 143 | 5 x 7 = 144 | 33 / 3 = 145 | 110 / 10 = 146 | 6 / 3 = 147 | 9 x 6 = 148 | 4 x 12 = 149 | 40 / 10 = 150 | 3 x 2 = 151 | 6 x 8 = 152 | 77 / 11 = 153 | 8 x 12 = 154 | 11 x 11 = 155 | 8 / 8 = 156 | 6 x 10 = 157 | 4 x 6 = 158 | 72 / 9 = 159 | 24 / 6 = 160 | 120 / 10 = 161 | 40 / 5 = 162 | 3 x 4 = 163 | 11 / 11 = 164 | 8 x 2 = 165 | 11 x 7 = 166 | 30 / 6 = 167 | 6 x 4 = 168 | 54 / 9 = 169 | 66 / 6 = 170 | 12 x 9 = 171 | 9 / 1 = 172 | 8 x 8 = 173 | 5 / 5 = 174 | 4 x 3 = 175 | 9 x 5 = 176 | 4 x 5 = 177 | 8 x 3 = 178 | 6 x 1 = 179 | 7 x 10 = 180 | 12 / 4 = 181 | 6 x 3 = 182 | 6 / 1 = 183 | 3 x 9 = 184 | 5 / 1 = 185 | 36 / 4 = 186 | 5 x 2 = 187 | 44 / 4 = 188 | 4 x 1 = 189 | 11 x 4 = 190 | 20 / 2 = 191 | 99 / 9 = 192 | 11 x 1 = 193 | 48 / 4 = 194 | 3 x 1 = 195 | 10 x 7 = 196 | 3 x 8 = 197 | 2 x 7 = 198 | 14 / 2 = 199 | 12 x 3 = 200 | 5 x 11 = 201 | 4 x 11 = 202 | 70 / 10 = 203 | 132 / 12 = 204 | 72 / 8 = 205 | 120 / 12 = 206 | 1 x 10 = 207 | 45 / 9 = 208 | 1 x 12 = 209 | 5 x 12 = 210 | 10 x 2 = 211 | 42 / 6 = 212 | 54 / 6 = 213 | 36 / 12 = 214 | 5 x 1 = 215 | 1 x 6 = 216 | 25 / 5 = 217 | 12 x 5 = 218 | 11 x 12 = 219 | 8 x 6 = 220 | 132 / 11 = 221 | 2 x 2 = 222 | 4 x 4 = 223 | 81 / 9 = 224 | 7 / 1 = 225 | 70 / 7 = 226 | 1 x 8 = 227 | 28 / 7 = 228 | 4 x 8 = 229 | 7 x 7 = 230 | 90 / 10 = 231 | 55 / 5 = 232 | 28 / 4 = 233 | 2 x 12 = 234 | 21 / 3 = 235 | 8 / 1 = 236 | 5 x 4 = 237 | 7 x 6 = 238 | 36 / 3 = 239 | 12 / 12 = 240 | 4 x 2 = 241 | 6 / 6 = 242 | 7 x 1 = 243 | 110 / 11 = 244 | 20 / 10 = 245 | 10 x 10 = 246 | 88 / 8 = 247 | 10 x 9 = 248 | 27 / 9 = 249 | 1 x 11 = 250 | 10 / 5 = 251 | 9 x 12 = 252 | 3 x 12 = 253 | 3 x 5 = 254 | 24 / 3 = 255 | 48 / 8 = 256 | 12 x 6 = 257 | 90 / 9 = 258 | 15 / 3 = 259 | 36 / 9 = 260 | 80 / 10 = 261 | 80 / 8 = 262 | 5 x 10 = 263 | 6 x 11 = 264 | 2 x 5 = 265 | 55 / 11 = 266 | 3 x 7 = 267 | 1 x 1 = 268 | 5 x 8 = 269 | 1 x 7 = 270 | 3 x 6 = 271 | 1 x 3 = 272 | 60 / 5 = 273 | 30 / 10 = 274 | 8 x 5 = 275 | 66 / 11 = 276 | 12 x 8 = 277 | 84 / 7 = 278 | 6 x 6 = 279 | 2 x 3 = 280 | 6 / 2 = 281 | 12 / 3 = 282 | 3 / 1 = 283 | 22 / 11 = 284 | 11 x 8 = 285 | 2 x 9 = 286 | 40 / 8 = 287 | 63 / 7 = 288 | 16 / 4 = -------------------------------------------------------------------------------- /test/learn/math/drill/finished.test.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const renderer = require('react-test-renderer'); 3 | const MuiThemeProvider = require('material-ui/styles/MuiThemeProvider').default; 4 | const getMuiTheme = require('material-ui/styles/getMuiTheme').default; 5 | const lightBaseTheme = require('material-ui/styles/baseThemes/lightBaseTheme').default; 6 | const Finished = require('../../../../src/learn/math/drill/finished'); 7 | const constants = require('../../../../src/learn/common/constants'); 8 | const db = require('../../../../src/learn/db'); 9 | 10 | const { 11 | RECORD_NEW, 12 | // RECORD_EQUAL, 13 | // RECORD_MISS, 14 | // RECORD_NOT_EXIST, 15 | } = constants; 16 | const muiTheme = getMuiTheme(lightBaseTheme); 17 | 18 | describe('Finished', () => { 19 | beforeEach(() => { 20 | db.getScores = jest.fn(); 21 | }); 22 | 23 | afterEach(() => { 24 | db.getScores.mockClear(); 25 | }); 26 | 27 | test('should render when there is a single incorrect result', () => { 28 | const previousResults = [{ 29 | actuals: [5], 30 | id: 1, 31 | task: [5, 5, 0, 10], 32 | timeTaken: 2.2, 33 | }]; 34 | const resultInfo = { 35 | text: 'Result Info Text', 36 | newRecordInfo: RECORD_NEW, 37 | }; 38 | 39 | const component = renderer.create( 40 | 41 | 50 | ); 51 | 52 | const finishedBadge = component.toJSON(); 53 | expect(finishedBadge).toMatchSnapshot(); 54 | }); 55 | 56 | test('should render when there is a single correct result', () => { 57 | const previousResults = [{ 58 | actuals: [10], 59 | id: 1, 60 | task: [5, 5, 0, 10], 61 | timeTaken: 2.2, 62 | }]; 63 | const resultInfo = { 64 | text: 'Result Info Text', 65 | newRecordInfo: RECORD_NEW, 66 | }; 67 | 68 | const component = renderer.create( 69 | 70 | 79 | ); 80 | 81 | const finishedBadge = component.toJSON(); 82 | expect(finishedBadge).toMatchSnapshot(); 83 | }); 84 | 85 | test('should render when there is a mix of correct and incorrect results', () => { 86 | const previousResults = [{ 87 | actuals: [5], // single incorrect 88 | id: 1, 89 | task: [5, 5, 0, 10], 90 | timeTaken: 2.2, 91 | }, { 92 | actuals: [10], // single correct 93 | id: 2, 94 | task: [5, 5, 0, 10], 95 | timeTaken: 2.2, 96 | }, { 97 | actuals: [10, 5, 4, 3], // correct with some wrong 98 | id: 3, 99 | task: [5, 5, 0, 10], 100 | timeTaken: 2.2, 101 | }]; 102 | const resultInfo = { 103 | text: 'Result Info Text', 104 | newRecordInfo: RECORD_NEW, 105 | }; 106 | db.getScores.mockReturnValueOnce([{ 107 | key: '0-01', 108 | timePerQuestion: 2, 109 | }, { 110 | key: '0-02', 111 | timePerQuestion: 0.5, 112 | }, { 113 | key: '0-01', 114 | timePerQuestion: 1, 115 | }]); 116 | 117 | const component = renderer.create( 118 | 119 | 128 | ); 129 | 130 | const finishedBadge = component.toJSON(); 131 | expect(finishedBadge).toMatchSnapshot(); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/learn/help/index.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const ReactMarkdown = require('react-markdown'); 3 | 4 | const input = ` 5 | ### Help with Math Drill 6 | 7 | #### Options 8 | 9 | Configure the options for the test. These options get saved in your browser \ 10 | so if you visit this page again with the same browser then the settings \ 11 | will be as you last left them. 12 | 13 | The scores and badges are also saved in the browser. There is no server or \ 14 | database for this app. Everything happens on your device and in the browser \ 15 | and that's where all the data lives as well. If you use a different browser \ 16 | you will start from fresh. If you share your browser with somebody they'll have \ 17 | your settings and scores. 18 | 19 | ##### Level 20 | 21 | There are 26 levels from A to Z. If you select a different level the previous \ 22 | one will be deselected. All levels are cumulative meaning that, for example \ 23 | on level E you will also get questions from levels A through D. The questions \ 24 | are randomly weighted towards the higher level you select. That means that \ 25 | if you select level E there will probably be more questions from this level \ 26 | than level D and there will be more from level D than from C etc. 27 | 28 | ##### Operation 29 | 30 | The four operators Addition, Subtraction, Multiplication and Division can be \ 31 | selected in any combination. At least one of them needs to be selected and all \ 32 | four can be selected as well. 33 | 34 | Because all Operations can be selected and because the Levels are cumulative you \ 35 | can get all four operations with all levels by selecting all the Operators and \ 36 | Level Z. 37 | 38 | ##### Time 39 | 40 | The time is expressed in minutes and will stop the test after this time period \ 41 | has elapsed. Fractions of a minute can be used to refine the time. Time is not \ 42 | really important if you are collecting badges (see below) because average time \ 43 | becomes the deciding factor so setting this to a high value is okay. 44 | 45 | ##### Total Questions 46 | 47 | This is the total number of questions that will be asked. The test will end \ 48 | once this number of questions have been correctly answered. If a question is not \ 49 | correctly answered then the test will remain on that question until it is \ 50 | complete. 51 | 52 | If you want to collect badges for completed tests you must answer at least ten \ 53 | questions in each test. 54 | 55 | ##### Keyboard 56 | 57 | If you're doing this on a touch device like a smart phone or tablet the the \ 58 | onscreen keyboard is the easiest to use. It's a simple keyboard that has all 59 | the functionality you need. 60 | 61 | If you doing this from a desktop computer or laptop then switching this off \ 62 | is probably going to be easier. 63 | 64 | Try them both and see which one works best for you. 65 | 66 | ##### Large Keyboard 67 | 68 | This will only show up if you have selected "onscreen keyboard." Selecting \ 69 | this option will increase the size of the onscreen keyboard. It seems to \ 70 | make it easier for kids to use a larger keyboard sometimes. 71 | 72 | ##### Your Name 73 | 74 | This is entirely optional. 75 | 76 | ### Running 77 | 78 | Once you start running the test you will see the Level, Operation(s) \ 79 | Time Left and Questions Remaining at the top of the screen. 80 | 81 | The current question will be presented right below that with a ? in a green \ 82 | box where the answer will go. 83 | 84 | Answering a question correctly will advance to the next question until they \ 85 | are all completed or until time runs out. 86 | 87 | After you have started answering questions two more pieces of information \ 88 | will be presented under the question. The result of the previous question \ 89 | you answered and your current speed versus the record (if there is one). 90 | 91 | ### Finished 92 | 93 | Once you have completed answering all your questions you'll see the results \ 94 | on the Finished page. 95 | 96 | If you have earned a new badge you will see that here as well. 97 | 98 | There is also a summary of the question you answered quickest, slowest and \ 99 | all your results if you want to review them. 100 | 101 | # Scoreboard 102 | 103 | The scoreboard is fairly obvious. 104 | 105 | There is a summary at the top showing all the badges you have earned. 106 | 107 | Below that is a table showing badges by level and operation. 108 | 109 | For now badges are only awarded for single-operation tests. 110 | `; 111 | 112 | function help() { 113 | return ( 114 | 115 | ); 116 | } 117 | 118 | module.exports = help; 119 | -------------------------------------------------------------------------------- /test/learn/help/__snapshots__/index.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Help should render the Help component 1`] = ` 4 | Array [ 5 |

6 | Help with Math Drill 7 |

, 8 |

9 | Options 10 |

, 11 |

12 | Configure the options for the test. These options get saved in your browser so if you visit this page again with the same browser then the settings will be as you last left them. 13 |

, 14 |

15 | The scores and badges are also saved in the browser. There is no server or database for this app. Everything happens on your device and in the browser and that's where all the data lives as well. If you use a different browser you will start from fresh. If you share your browser with somebody they'll have your settings and scores. 16 |

, 17 |
18 | Level 19 |
, 20 |

21 | There are 26 levels from A to Z. If you select a different level the previous one will be deselected. All levels are cumulative meaning that, for example on level E you will also get questions from levels A through D. The questions are randomly weighted towards the higher level you select. That means that if you select level E there will probably be more questions from this level than level D and there will be more from level D than from C etc. 22 |

, 23 |
24 | Operation 25 |
, 26 |

27 | The four operators Addition, Subtraction, Multiplication and Division can be selected in any combination. At least one of them needs to be selected and all four can be selected as well. 28 |

, 29 |

30 | Because all Operations can be selected and because the Levels are cumulative you can get all four operations with all levels by selecting all the Operators and Level Z. 31 |

, 32 |
33 | Time 34 |
, 35 |

36 | The time is expressed in minutes and will stop the test after this time period has elapsed. Fractions of a minute can be used to refine the time. Time is not really important if you are collecting badges (see below) because average time becomes the deciding factor so setting this to a high value is okay. 37 |

, 38 |
39 | Total Questions 40 |
, 41 |

42 | This is the total number of questions that will be asked. The test will end once this number of questions have been correctly answered. If a question is not correctly answered then the test will remain on that question until it is complete. 43 |

, 44 |

45 | If you want to collect badges for completed tests you must answer at least ten questions in each test. 46 |

, 47 |
48 | Keyboard 49 |
, 50 |

51 | If you're doing this on a touch device like a smart phone or tablet the the onscreen keyboard is the easiest to use. It's a simple keyboard that has all 52 | the functionality you need. 53 |

, 54 |

55 | If you doing this from a desktop computer or laptop then switching this off is probably going to be easier. 56 |

, 57 |

58 | Try them both and see which one works best for you. 59 |

, 60 |
61 | Large Keyboard 62 |
, 63 |

64 | This will only show up if you have selected "onscreen keyboard." Selecting this option will increase the size of the onscreen keyboard. It seems to make it easier for kids to use a larger keyboard sometimes. 65 |

, 66 |
67 | Your Name 68 |
, 69 |

70 | This is entirely optional. 71 |

, 72 |

73 | Running 74 |

, 75 |

76 | Once you start running the test you will see the Level, Operation(s) Time Left and Questions Remaining at the top of the screen. 77 |

, 78 |

79 | The current question will be presented right below that with a ? in a green box where the answer will go. 80 |

, 81 |

82 | Answering a question correctly will advance to the next question until they are all completed or until time runs out. 83 |

, 84 |

85 | After you have started answering questions two more pieces of information will be presented under the question. The result of the previous question you answered and your current speed versus the record (if there is one). 86 |

, 87 |

88 | Finished 89 |

, 90 |

91 | Once you have completed answering all your questions you'll see the results on the Finished page. 92 |

, 93 |

94 | If you have earned a new badge you will see that here as well. 95 |

, 96 |

97 | There is also a summary of the question you answered quickest, slowest and all your results if you want to review them. 98 |

, 99 |

100 | Scoreboard 101 |

, 102 |

103 | The scoreboard is fairly obvious. 104 |

, 105 |

106 | There is a summary at the top showing all the badges you have earned. 107 |

, 108 |

109 | Below that is a table showing badges by level and operation. 110 |

, 111 |

112 | For now badges are only awarded for single-operation tests. 113 |

, 114 | ] 115 | `; 116 | -------------------------------------------------------------------------------- /src/learn/math/drill/score-bar.jsx: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | const React = require('react'); 3 | const moment = require('moment'); 4 | const constants = require('../../common/constants'); 5 | const helper = require('./helper'); 6 | 7 | const { 8 | BADGE_BOUNDARIES: badgeBoundaries, 9 | COLOR_HTML: htmlColors, 10 | COLOR_TEXT: colorText, 11 | } = constants; 12 | 13 | const sbStyleBase = { 14 | 15 | }; 16 | 17 | const sbHeadStyleBase = { 18 | width: '100%', 19 | border: '1px solid black', 20 | display: 'flex', 21 | textAlign: 'left', 22 | }; 23 | 24 | const sbHeadItemStyleBase = { 25 | flexGrow: '1', 26 | }; 27 | 28 | const sbBodyStyleBase = { 29 | width: '100%', 30 | border: '1px solid black', 31 | display: 'flex', 32 | }; 33 | 34 | const sbBodyItemStyleBase = { 35 | border: '1px solid black', 36 | display: 'flex', 37 | flexDirection: 'column', 38 | fontSize: 'large', 39 | height: '100%', 40 | flexGrow: '1', 41 | textAlign: 'center', 42 | }; 43 | 44 | function renderColors(times, maxVal) { 45 | const sbBodyItemStyle = { ...sbBodyItemStyleBase, height: `${maxVal * 50}px` }; 46 | 47 | const extra = badgeBoundaries.concat(maxVal); 48 | const widths = extra.reduce((acc, boundary, index) => { 49 | if (index !== badgeBoundaries.length) { 50 | acc.push(Math.round((100 * (extra[index + 1] - boundary)) / maxVal)); 51 | } 52 | return acc; 53 | }, []); 54 | 55 | return ( 56 | 57 | { 58 | colorText.map((color, index) => { 59 | const style = { 60 | backgroundColor: htmlColors[index], 61 | height: `${widths[index]}%`, 62 | }; 63 | return ( 64 |
68 | {color} 69 |
70 | ); 71 | }) 72 | } 73 |
74 | ); 75 | } 76 | 77 | function renderUserScores(times, maxVal) { 78 | return times.map((time) => { 79 | const { timePerQuestion, date } = time; 80 | const sbBodyItemStyle = { ...sbBodyItemStyleBase, height: `${maxVal * 50}px` }; 81 | const timePerQuestionLimit = Math.min(maxVal, timePerQuestion); 82 | 83 | const one = Math.min(95, ((timePerQuestionLimit * 100) / maxVal) - 5).toFixed(1); 84 | const three = Math.max(0, (((maxVal - timePerQuestionLimit) * 100) / maxVal) - 5).toFixed(1); 85 | const userTimes = [ 86 | one, 87 | 10, 88 | three, 89 | ]; 90 | 91 | const rightArrow = '\u21FE'; 92 | const downArrow = '\u2193'; 93 | const arrow = timePerQuestion > timePerQuestionLimit 94 | ? downArrow : rightArrow; 95 | 96 | const userTimeText = ['', `YOU${arrow}`, '']; 97 | 98 | const userColor = helper.getBadgeHtmlColor(timePerQuestion); 99 | 100 | return ( 101 | 102 | { 103 | userTimes.map((userTime, index) => { 104 | const key = `userTimes-${index}`; 105 | const style = { 106 | height: `${userTime}%`, 107 | }; 108 | if (index === 1) { 109 | style.backgroundColor = userColor; 110 | } 111 | return ( 112 | 113 | {userTimeText[index]} 114 | 115 | ); 116 | }) 117 | } 118 | 119 | ); 120 | }); 121 | } 122 | 123 | function renderTitles(times) { 124 | const titles = times.map(({ date }) => ({ 125 | title: moment(date).fromNow(), 126 | date, 127 | })).concat({ title: 'Badges', date: 0 }); 128 | 129 | return ( 130 |
131 | { 132 | titles.map(({ title, date }) => ( 133 | 134 | {title} 135 | 136 | )) 137 | } 138 |
139 | ); 140 | } 141 | 142 | function scoreBar({ times, showScoreBar }) { 143 | if (!showScoreBar || !times.length) { 144 | return null; 145 | } 146 | 147 | const boundaryMax = Math.max(...badgeBoundaries); 148 | const maxTimePerQuestion = Math.max(...times.map((time) => time.timePerQuestion)); 149 | const maxVal = maxTimePerQuestion > (boundaryMax * 1.5) 150 | ? boundaryMax * 2 151 | : boundaryMax * 1.5; 152 | 153 | const sbBodyStyle = { ...sbBodyStyleBase, height: `${maxVal * 50}px` }; 154 | 155 | return ( 156 |
157 |
158 | {renderTitles(times)} 159 |
160 |
161 | {renderUserScores(times, maxVal)} 162 | {renderColors(times, maxVal)} 163 |
164 |
165 | ); 166 | } 167 | 168 | scoreBar.propTypes = { 169 | times: PropTypes.arrayOf(PropTypes.shape({ 170 | date: PropTypes.number.isRequired, 171 | timePerQuestion: PropTypes.number.isRequired, 172 | }).isRequired).isRequired, 173 | showScoreBar: PropTypes.bool.isRequired, 174 | }; 175 | 176 | module.exports = scoreBar; 177 | -------------------------------------------------------------------------------- /src/learn/math/drill/finished.jsx: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | const React = require('react'); 3 | const RunningResults = require('./running-results'); 4 | const constants = require('../../common/constants'); 5 | const FinishedBadge = require('./finished-badge'); 6 | const ScoreBar = require('./score-bar'); 7 | const PreviousResults = require('./previous-results'); 8 | const Types = require('./types'); 9 | const helper = require('./helper'); 10 | 11 | const { 12 | RECORD_NEW, 13 | RECORD_EQUAL, 14 | RECORD_MISS, 15 | RECORD_NOT_EXIST, 16 | } = constants; 17 | 18 | const baseRecordStyle = { 19 | color: 'black', 20 | backgroundColor: '#8bc78b', 21 | padding: '20px', 22 | borderRadius: '5px', 23 | marginBottom: '10px', 24 | }; 25 | 26 | const newRecordStyle = baseRecordStyle; 27 | 28 | const missRecordStyle = { ...baseRecordStyle, backgroundColor: '#ecc9e0' }; 29 | 30 | const notExistRecordStyle = { 31 | ...baseRecordStyle, 32 | color: 'white', 33 | backgroundColor: '#8583dc', 34 | }; 35 | 36 | function processResultInfo(resultInfo) { 37 | const { newRecordInfo, text } = resultInfo; 38 | let style; 39 | switch (newRecordInfo) { 40 | case RECORD_NEW: 41 | case RECORD_EQUAL: 42 | style = newRecordStyle; 43 | break; 44 | case RECORD_MISS: 45 | style = missRecordStyle; 46 | break; 47 | case RECORD_NOT_EXIST: 48 | style = notExistRecordStyle; 49 | break; 50 | default: 51 | return ( 52 |
53 | {`Unknown newRecordInfo ${newRecordInfo} in switch case`} 54 |
55 | ); 56 | } 57 | return ( 58 |
59 | {text} 60 |
61 | ); 62 | } 63 | 64 | function finished(props) { 65 | const { levelIndex: propsLevelIndex, opIndexes: propsOpIndexes } = props; 66 | const { 67 | levelIndex, 68 | opIndexes, 69 | previousResults, 70 | resultInfo, 71 | scoreBarTimes = helper.getScoreBarTimes(propsLevelIndex, propsOpIndexes), 72 | timeAllowed, 73 | timeLeft, 74 | totalProblems, 75 | } = props; 76 | 77 | const crunchedResults = PreviousResults.getStats(previousResults); 78 | 79 | const { 80 | incorrects, 81 | correctCount, 82 | longestTime: long, 83 | shortestTime: short, 84 | totalTime, 85 | } = crunchedResults; 86 | 87 | const longestTime = long ? [long] : []; 88 | const shortestTime = short ? [short] : []; 89 | const timePerQuestion = parseFloat((totalTime / correctCount).toFixed(1)); 90 | 91 | const slowSort = PreviousResults.sortBySlowest(previousResults); 92 | 93 | if (!longestTime.length) { 94 | return ( 95 |
96 |

97 | Finished 98 |

99 |
100 | {'Looks like you didn\'t get a chance to do anything that round!'} 101 |
102 |
103 | ); 104 | } 105 | 106 | return ( 107 |
108 |

109 | Finished 110 |

111 | {processResultInfo(resultInfo)} 112 | 118 | 9} 121 | /> 122 |
123 | {`You had ${timeLeft} seconds left out of the ${timeAllowed} seconds allowed.`} 124 |
125 |
126 | {`You correctly answered ${correctCount} of the ${totalProblems} problems.`} 127 |
128 |

129 | The problem that took you the longest to answer 130 |

131 | 132 |

133 | The problem you answered the fastest 134 |

135 | 136 | {!!incorrects.length 137 | && ( 138 |
139 |

140 | The ones that you got wrong 141 |

142 | 143 |
144 | )} 145 |

146 | How you did (sorted by slowest) 147 |

148 | 151 |
152 | ); 153 | } 154 | 155 | finished.propTypes = { 156 | levelIndex: PropTypes.number.isRequired, 157 | opIndexes: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired, 158 | previousResults: PropTypes.arrayOf(Types.previousResults).isRequired, 159 | resultInfo: PropTypes.shape({ 160 | text: PropTypes.string.isRequired, 161 | newRecordInfo: PropTypes.string.isRequired, 162 | }).isRequired, 163 | scoreBarTimes: PropTypes.arrayOf(PropTypes.shape({ 164 | date: PropTypes.number.isRequired, 165 | timePerQuestion: PropTypes.number.isRequired, 166 | })), 167 | timeAllowed: PropTypes.number.isRequired, 168 | timeLeft: PropTypes.number.isRequired, 169 | totalProblems: PropTypes.number.isRequired, 170 | }; 171 | 172 | finished.defaultProps = { 173 | scoreBarTimes: undefined, 174 | }; 175 | 176 | module.exports = finished; 177 | -------------------------------------------------------------------------------- /test/learn/__snapshots__/home.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Home should render a Home component 1`] = ` 4 | 173 | `; 174 | -------------------------------------------------------------------------------- /src/learn/math/drill/options.jsx: -------------------------------------------------------------------------------- 1 | const FloatingActionButton = require('@material-ui/core/Fab').default; 2 | const PropTypes = require('prop-types'); 3 | const Button = require('@material-ui/core/Button').default; 4 | const React = require('react'); 5 | const TextField = require('@material-ui/core/TextField').default; 6 | const Switch = require('@material-ui/core/Switch').default; 7 | const FormGroup = require('@material-ui/core/FormGroup').default; 8 | const FormControlLabel = require('@material-ui/core/FormControlLabel').default; 9 | const { Send } = require('@material-ui/icons'); 10 | const Tooltip = require('@material-ui/core/Tooltip').default; 11 | const constants = require('../../common/constants'); 12 | 13 | const { 14 | ALPHABET: alphabet, 15 | OPERATIONS: operations, 16 | } = constants; 17 | 18 | const buttonStyle = { 19 | margin: '5px', 20 | fontSize: '2em', 21 | }; 22 | 23 | const sectionStyle = { 24 | marginTop: '30px', 25 | }; 26 | 27 | function options(props) { 28 | const { 29 | largeKeyboard, 30 | levelIndex, 31 | minutes, 32 | onChange, 33 | onscreenKeyboard, 34 | onStart, 35 | opIndexes, 36 | setParentState, 37 | totalProblems, 38 | userName, 39 | } = props; 40 | 41 | function toggleOpIndex(opIndex) { 42 | const position = props.opIndexes.indexOf(opIndex); 43 | let operatIndexes; 44 | if (position >= 0) { 45 | operatIndexes = props.opIndexes; 46 | if (props.opIndexes.length > 1) { 47 | // Don't allow count to fall under 1 48 | operatIndexes.splice(position, 1); 49 | } 50 | } else { 51 | operatIndexes = props.opIndexes.concat(opIndex); 52 | } 53 | 54 | props.setParentState({ opIndexes: operatIndexes }); 55 | } 56 | 57 | return ( 58 |
59 |
60 |

61 | Level 62 |

63 |
64 | { 65 | alphabet.map((letter, index) => ( 66 | 67 | setParentState({ levelIndex: index })} 70 | color={index === levelIndex ? 'secondary' : 'primary'} 71 | style={buttonStyle} 72 | > 73 | {letter} 74 | 75 | 76 | )) 77 | } 78 |
79 |
80 |
81 |

82 | Operation 83 |

84 |
85 | { 86 | operations.map((operation, index) => ( 87 | 88 | toggleOpIndex(index)} 91 | color={opIndexes.includes(index) ? 'secondary' : 'primary'} 92 | style={buttonStyle} 93 | > 94 | {operation} 95 | 96 | 97 | )) 98 | } 99 |
100 |
101 |
102 |

103 | Time 104 |

105 | 115 |
116 |
117 |

118 | Total Questions (you only get badges for 10 or more correct questions) 119 |

120 | 130 |
131 |
132 |

133 | Keyboard 134 |

135 | 136 | 144 | )} 145 | label="Use onscreen keyboard" 146 | /> 147 | 148 |
149 | {onscreenKeyboard 150 | && ( 151 |
152 |

153 | Large Keyboard 154 |

155 | 156 | 164 | )} 165 | label="Large Keyboard" 166 | /> 167 | 168 |
169 | )} 170 |
171 |

172 | Your Name (Optional) 173 |

174 | 184 |
185 |
186 | 190 |
191 |
192 |
193 | ); 194 | } 195 | 196 | 197 | options.propTypes = { 198 | largeKeyboard: PropTypes.bool.isRequired, 199 | levelIndex: PropTypes.number.isRequired, 200 | minutes: PropTypes.string.isRequired, 201 | onChange: PropTypes.func.isRequired, 202 | onscreenKeyboard: PropTypes.bool.isRequired, 203 | onStart: PropTypes.func.isRequired, 204 | opIndexes: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired, 205 | setParentState: PropTypes.func.isRequired, 206 | totalProblems: PropTypes.string.isRequired, 207 | userName: PropTypes.string.isRequired, 208 | }; 209 | 210 | module.exports = options; 211 | -------------------------------------------------------------------------------- /src/learn/math/drill/helper-problems.js: -------------------------------------------------------------------------------- 1 | // Define the problems for math drill 2 | 3 | const special1 = [...Array(9).keys()].map((a) => [0, a + 1]); 4 | const special2 = [...Array(9).keys()].map((a) => [a + 1, 0]); 5 | const special3 = [...Array(10).keys()].map((a) => [a + 1, 0]); 6 | const special4 = [...Array(18).keys()].map((a) => [a + 1, a + 1]); 7 | const special5 = [...Array(9).keys()].map((a) => [1, a + 1]); 8 | const special6 = [...Array(9).keys()].map((a) => [a + 1, 1]); // [1...9, 1] 9 | const special7 = [...Array(9).keys()].map((a) => [a + 1, a + 1]); // [[1,1],...[9,9]] 10 | 11 | const special8 = [...Array(8).keys()] 12 | .map((a) => a + 2) // [2...9] 13 | .reduce((acc, denominator) => { 14 | let numerator = denominator - 1; 15 | while (numerator > 0) { 16 | acc.push([numerator, denominator]); 17 | numerator -= 1; 18 | } 19 | return acc; 20 | }, []); // [[1,2],[1,3],[2,3]...] 21 | 22 | // Extra for multiplication 23 | const special9 = [...Array(11).keys()].map((a) => [10, a]); 24 | const special10 = [...Array(11).keys()].map((a) => [a, 10]); 25 | const special11 = [...Array(12).keys()].map((a) => [11, a]); 26 | const special12 = [...Array(12).keys()].map((a) => [a, 11]); 27 | const special13 = [...Array(13).keys()].map((a) => [12, a]); 28 | const special14 = [...Array(13).keys()].map((a) => [a, 12]); 29 | 30 | // Extra for division 31 | const special15 = [...Array(12).keys()].map((a) => [(a + 1) * 10, 10]); 32 | const special16 = [...Array(12).keys()].map((a) => [(a + 1) * 11, 11]); 33 | const special17 = [...Array(12).keys()].map((a) => [(a + 1) * 12, 12]); 34 | 35 | const levelOps = [ 36 | // Addition + 37 | [ 38 | [[1, 2], [2, 1], [1, 3], [3, 1]], // A 39 | [[1, 4], [4, 1], [1, 1]], // B 40 | [[1, 5], [5, 1], [2, 2]], // C 41 | [[1, 6], [6, 1], [3, 3]], // D 42 | [[1, 7], [7, 1], [4, 4]], // E 43 | [[1, 8], [8, 1], [5, 5]], // F 44 | // #1 [0, 1..9] 45 | // #2 [1..9, 0] 46 | [[1, 9], [9, 1], ...special1, ...special2], // G 47 | [[2, 3], [3, 2], [6, 6]], // H 48 | [[4, 2], [2, 4], [7, 7]], // I 49 | [[5, 2], [2, 5], [8, 8]], // J 50 | [[6, 2], [2, 6], [9, 9]], // K 51 | [[7, 2], [2, 7], [4, 7], [7, 4]], // L 52 | [[8, 2], [2, 8], [8, 6], [6, 8]], // M 53 | [[9, 2], [2, 9], [9, 6], [6, 9]], // N 54 | [[4, 3], [3, 4], [6, 7], [7, 6]], // O 55 | [[5, 3], [3, 5], [7, 8], [8, 7]], // P 56 | [[5, 8], [8, 5], [7, 9], [9, 7]], // Q 57 | [[6, 3], [3, 6], [5, 9], [9, 5]], // R 58 | [[7, 3], [3, 7], [8, 9], [9, 8]], // S 59 | [[8, 3], [3, 8], [4, 9], [9, 4]], // T 60 | [[9, 3], [3, 9], [5, 7], [7, 5]], // U 61 | [[4, 5], [5, 4], [4, 8], [8, 4]], // V 62 | [[4, 6], [6, 4], [5, 6], [6, 5]], // W 63 | [], // X 64 | [], // Y 65 | [], // Z 66 | ], 67 | // Subtraction - 68 | [ 69 | [[3, 2], [3, 1], [4, 3], [4, 1]], // A 70 | [[5, 4], [5, 1], [2, 1]], // B 71 | [[6, 1], [6, 5], [4, 2]], // C 72 | [[7, 1], [7, 6], [6, 3]], // D 73 | [[8, 1], [8, 7], [8, 4]], // E 74 | [[9, 1], [9, 8], [10, 5]], // F 75 | [[10, 1], [10, 9], ...special3], // G # [1..10, 0] 76 | [[5, 3], [5, 2], [12, 6]], // H 77 | [[6, 2], [6, 4], [14, 7]], // I 78 | [[7, 2], [7, 5], [16, 8]], // J 79 | [[8, 2], [8, 6], [18, 9]], // K 80 | [[9, 2], [9, 7], [11, 7], [11, 4]], // L 81 | special4, // M #4 [1..18, 1..18] 82 | [[10, 2], [10, 8], [14, 8], [14, 6]], // N 83 | [[11, 2], [11, 9], [15, 9], [15, 6]], // O 84 | [[7, 3], [7, 4], [13, 7], [13, 6]], // P 85 | [[8, 3], [8, 5], [15, 8], [15, 7]], // Q 86 | [[13, 8], [13, 5], [16, 9], [16, 7]], // R 87 | [[9, 3], [9, 6], [14, 9], [14, 5]], // S 88 | [[10, 3], [10, 7], [17, 9], [17, 8]], // T 89 | [[11, 3], [11, 8], [13, 9], [13, 4]], // U 90 | [[12, 3], [12, 9], [12, 7], [12, 5]], // V 91 | [[9, 5], [9, 4], [12, 8], [12, 4]], // W 92 | [[10, 6], [10, 4], [11, 6], [11, 5]], // X 93 | [], // Y 94 | [], // Z 95 | ], 96 | // Multiplication x 97 | [ 98 | // #5 [1, 1..9] 99 | // #6 [1..9, 1] 100 | [...special5, ...special6], // A 101 | // #1 [0, 1..9] 102 | // #2 [1..9, 0] 103 | [...special1, ...special2], // B 104 | [[2, 3], [3, 2], [2, 2]], // C 105 | [[2, 4], [4, 2], [2, 5], [5, 2]], // D 106 | [[6, 2], [2, 6], [7, 2], [2, 7]], // E 107 | [[8, 2], [2, 8], [9, 2], [2, 9]], // F 108 | [[9, 3], [3, 9], [9, 4], [4, 9]], // G 109 | [[9, 5], [5, 9], [3, 3]], // H 110 | [[9, 6], [6, 9], [4, 4]], // I 111 | [[9, 7], [7, 9], [5, 5]], // J 112 | [[9, 8], [8, 9], [6, 6]], // K 113 | [[3, 4], [4, 3], [7, 7]], // L 114 | [[3, 5], [5, 3], [8, 8]], // M 115 | [[3, 6], [6, 3], [9, 9]], // N 116 | [[3, 7], [7, 3], [3, 8], [8, 3]], // O 117 | [[7, 8], [8, 7], [6, 8], [8, 6]], // P 118 | [[5, 8], [8, 5], [4, 8], [8, 4]], // Q 119 | [[7, 6], [6, 7], [7, 5], [5, 7]], // R 120 | [[7, 4], [4, 7], [6, 5], [5, 6]], // S 121 | [[5, 4], [4, 5], [4, 6], [6, 4]], // T 122 | [...special9], // U #9 [10, 0..10] 123 | [...special10], // V #10 [0..10, 10] 124 | [...special11], // W #11 [11, 0..11] 125 | [...special12], // X #11 [0..11, 11] 126 | [...special13], // Y #12 [12, 0..12] 127 | [...special14], // Z #12 [0..12, 12] 128 | ], 129 | // Division / 130 | [ 131 | special6, // A 132 | special7, // B 133 | [[6, 2], [6, 3], [4, 2]], // C 134 | [[8, 2], [8, 4], [10, 2], [10, 5]], // D 135 | [[12, 2], [12, 6], [14, 2], [14, 7]], // E 136 | [[16, 2], [16, 8], [18, 2], [18, 9]], // F 137 | special8, // G 138 | [[27, 9], [27, 3], [36, 9], [36, 4]], // H 139 | [[45, 9], [45, 5], [9, 3]], // I 140 | [[54, 9], [54, 6], [16, 4]], // J 141 | [[63, 9], [63, 7], [25, 5]], // K 142 | [[72, 9], [72, 8], [36, 6]], // L 143 | [[12, 3], [12, 4], [49, 7]], // M 144 | [[15, 3], [15, 5], [64, 8]], // N 145 | [[18, 3], [18, 6], [81, 9]], // O 146 | [[21, 3], [21, 7], [24, 3], [24, 8]], // P 147 | [[56, 8], [56, 7], [48, 8], [48, 6]], // Q 148 | [[40, 8], [40, 5], [32, 8], [32, 4]], // R 149 | [[42, 7], [42, 6], [35, 7], [35, 5]], // S 150 | [[28, 7], [28, 4], [30, 6], [30, 5]], // T 151 | [[20, 4], [20, 5], [24, 6], [24, 4]], // U 152 | [[90, 9], [99, 9], [108, 9]], // V 153 | [[64, 8], [72, 8], [80, 8], [88, 8], [96, 8]], // W 154 | [...special15], // X #15 [10, 20, 30 ... 10] 155 | [...special16], // Y #16 [11, 22, 33 ... 11] 156 | [...special17], // Z #17 [12, 24, 36 ... 12] 157 | ], 158 | 159 | ]; 160 | 161 | module.exports = { 162 | levelOps, 163 | }; 164 | -------------------------------------------------------------------------------- /test/learn/db/index.test.js: -------------------------------------------------------------------------------- 1 | const db = require('../../../src/learn/db'); 2 | const constants = require('../../../src/learn/common/constants'); 3 | 4 | const { 5 | MATH_DRILL_OPTIONS, 6 | MATH_DRILL_SCORES, 7 | } = constants; 8 | 9 | describe('DB', () => { 10 | // beforeAll(() => { 11 | // }); 12 | let getItemMock; 13 | let setItemMock; 14 | beforeEach(() => { 15 | // eslint-disable-next-line no-proto 16 | setItemMock = jest.spyOn(window.localStorage.__proto__, 'setItem'); 17 | // eslint-disable-next-line no-proto 18 | getItemMock = jest.spyOn(window.localStorage.__proto__, 'getItem'); 19 | }); 20 | afterEach(() => { 21 | getItemMock.mockRestore(); 22 | setItemMock.mockRestore(); 23 | }); 24 | test('should set an item', () => { 25 | const value = { prop: 'one' }; 26 | 27 | // eslint-disable-next-line no-proto 28 | jest.spyOn(window.localStorage.__proto__, 'setItem'); 29 | 30 | db.setItem('key', value); 31 | expect(localStorage.setItem).toHaveBeenCalledWith('key', JSON.stringify(value)); 32 | }); 33 | 34 | test('should get an item', () => { 35 | const value = { prop: 'one' }; 36 | 37 | localStorage.getItem.mockReturnValueOnce(JSON.stringify(value)); 38 | 39 | const actual = db.getItem('key'); 40 | expect(actual).toEqual(value); 41 | }); 42 | 43 | test('should get a falsy item', () => { 44 | localStorage.getItem.mockReturnValueOnce(null); 45 | const actual = db.getItem('key'); 46 | expect(actual).toBeNull(); 47 | }); 48 | 49 | test('should save options', () => { 50 | const value = { prop: 'one' }; 51 | db.saveOptions(value); 52 | expect(localStorage.setItem).toHaveBeenCalledWith(MATH_DRILL_OPTIONS, JSON.stringify(value)); 53 | }); 54 | 55 | test('should save scores', () => { 56 | const value = { prop: 'one' }; 57 | db.saveScores(value); 58 | expect(localStorage.setItem).toHaveBeenCalledWith(MATH_DRILL_SCORES, JSON.stringify(value)); 59 | }); 60 | 61 | test('should append scores', () => { 62 | localStorage.getItem.mockReturnValueOnce(JSON.stringify({ 63 | version: 3, 64 | scores: [1, 2], 65 | })); 66 | db.appendScore(3); 67 | expect(localStorage.setItem).toHaveBeenCalledWith(MATH_DRILL_SCORES, JSON.stringify({ 68 | version: 3, 69 | scores: [1, 2, 3], 70 | })); 71 | }); 72 | 73 | test('should append scores when score collection is empty', () => { 74 | localStorage.getItem.mockReturnValueOnce(null); 75 | db.appendScore(3); 76 | expect(localStorage.setItem).toHaveBeenCalledWith(MATH_DRILL_SCORES, JSON.stringify({ 77 | version: 3, 78 | scores: [3], 79 | })); 80 | }); 81 | 82 | test('should get default options when none have previously been saved', () => { 83 | localStorage.getItem.mockReturnValueOnce(null); 84 | const actual = db.getOptions(); 85 | const defaultOptions = { 86 | largeKeyboard: false, 87 | levelIndex: 0, // A 88 | minutes: '10', 89 | onscreenKeyboard: false, 90 | opIndexes: [0], // + 91 | totalProblems: '20', 92 | userName: '', 93 | }; 94 | expect(localStorage.setItem).toHaveBeenCalledWith( 95 | MATH_DRILL_OPTIONS, JSON.stringify(defaultOptions)); 96 | expect(actual).toMatchObject(defaultOptions); 97 | }); 98 | 99 | test('should get and convert options', () => { 100 | const savedOptions = { 101 | levelIndex: 0, // A 102 | minutes: '1', 103 | onscreenKeyboard: true, 104 | opIndex: 0, // + 105 | totalProblems: '20', 106 | }; 107 | localStorage.getItem.mockReturnValueOnce(JSON.stringify(savedOptions)); 108 | 109 | const actual = db.getOptions(); 110 | 111 | expect(localStorage.setItem).toHaveBeenCalledTimes(3); 112 | 113 | savedOptions.userName = ''; 114 | savedOptions.largeKeyboard = false; 115 | savedOptions.opIndexes = [0]; 116 | delete savedOptions.opIndex; 117 | expect(localStorage.setItem).toHaveBeenLastCalledWith( 118 | MATH_DRILL_OPTIONS, JSON.stringify(savedOptions)); 119 | 120 | expect(actual).toEqual(savedOptions); 121 | }); 122 | 123 | test('should get options that do not need converting', () => { 124 | const savedOptions = { 125 | largeKeyboard: true, 126 | levelIndex: 0, // A 127 | minutes: '1', 128 | onscreenKeyboard: true, 129 | opIndexes: [0], // A 130 | totalProblems: '20', 131 | userName: 'my name', 132 | }; 133 | localStorage.getItem.mockReturnValueOnce(JSON.stringify(savedOptions)); 134 | 135 | const actual = db.getOptions(); 136 | 137 | expect(localStorage.setItem).toHaveBeenCalledTimes(0); 138 | 139 | expect(actual).toEqual(savedOptions); 140 | }); 141 | 142 | test('should update scores and set version to current', () => { 143 | const scoresVersion1 = [{ 144 | levelIndex: 0, 145 | opIndexes: [0], 146 | key: '00', 147 | previousResults: [{ 148 | actual: 1, 149 | }, { 150 | actuals: [2], 151 | }], 152 | }, { 153 | levelIndex: 0, 154 | opIndexes: [0], 155 | key: '00', 156 | dummy: 1, // to test "else" in target code 157 | }]; 158 | 159 | const scoresVersion2 = { 160 | version: 2, 161 | scores: [{ 162 | levelIndex: 0, 163 | opIndexes: [0], 164 | key: '00', 165 | previousResults: [{ 166 | actuals: [1], 167 | }, { 168 | actuals: [2], 169 | }], 170 | }, { 171 | levelIndex: 0, 172 | opIndexes: [0], 173 | key: '00', 174 | dummy: 1, 175 | }], 176 | }; 177 | 178 | const scoresVersion3 = { 179 | version: 3, 180 | scores: [{ 181 | levelIndex: 0, 182 | opIndexes: [0], 183 | key: '0-0', 184 | previousResults: [{ 185 | actuals: [1], 186 | }, { 187 | actuals: [2], 188 | }], 189 | }, { 190 | levelIndex: 0, 191 | opIndexes: [0], 192 | key: '0-0', 193 | dummy: 1, 194 | }], 195 | }; 196 | 197 | localStorage.getItem.mockReturnValueOnce( 198 | JSON.stringify(scoresVersion1)); 199 | localStorage.getItem.mockReturnValueOnce( 200 | JSON.stringify(scoresVersion2)); 201 | localStorage.getItem.mockReturnValueOnce( 202 | JSON.stringify(scoresVersion3)); 203 | 204 | const actual = db.getScores(); 205 | 206 | expect(localStorage.setItem).toHaveBeenCalledTimes(2); 207 | expect(localStorage.setItem).toHaveBeenCalledWith( 208 | MATH_DRILL_SCORES, JSON.stringify(scoresVersion2)); 209 | expect(localStorage.setItem).toHaveBeenLastCalledWith( 210 | MATH_DRILL_SCORES, JSON.stringify(scoresVersion3)); 211 | 212 | expect(actual).toEqual(scoresVersion3.scores); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /test/learn/math/drill/__snapshots__/scoreboard.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ScoreBoard should render showScoreBoard 1`] = ` 4 |
5 |

6 | Scoreboard 7 |

8 |
9 |
10 | 39 | 40 | Gold Badge(s) - 2 seconds or less (per question) 41 | 42 |
43 |
44 | 73 | 74 | Silver Badge(s) - between 2 and 3 seconds (per question) 75 | 76 |
77 |
78 | 107 | 108 | Bronze Badge(s) - between 3 and 4 seconds (per question) 109 | 110 |
111 |
112 | 141 | 142 | Blue Badge(s) - more than 4 seconds (per question) 143 | 144 |
145 |
146 |
154 | 157 | 160 | 163 | 170 | 177 | 178 | 179 | 182 | 185 | 192 | 228 | 229 | 230 |
168 | Level 169 | 175 | Addition 176 |
190 | A 191 | 196 | 197 | 226 | 227 |
231 |
232 |
233 | `; 234 | -------------------------------------------------------------------------------- /src/learn/math/drill/quiz-line.jsx: -------------------------------------------------------------------------------- 1 | const FloatingActionButton = require('@material-ui/core/Fab').default; 2 | const PropTypes = require('prop-types'); 3 | const React = require('react'); 4 | const TextField = require('@material-ui/core/TextField').default; 5 | const { Done } = require('@material-ui/icons'); 6 | const Keyboard = require('./keyboard'); 7 | const constants = require('../../common/constants'); 8 | const RunningResults = require('./running-results'); 9 | 10 | const { OPERATIONS: operations } = constants; 11 | 12 | const numberStyle = { 13 | fontSize: 'xx-large', 14 | margin: '10px', 15 | }; 16 | 17 | const checkStyle = { 18 | margin: '10px', 19 | }; 20 | 21 | const inputStyle = { 22 | textAlign: 'center', 23 | }; 24 | 25 | const answerStyle = { 26 | 27 | paddingLeft: '15px', 28 | paddingRight: '15px', 29 | fontSize: 'xx-large', 30 | backgroundColor: 'green', 31 | color: 'white', 32 | }; 33 | 34 | const lastResultCorrectStyle = { 35 | borderRadius: '5px', 36 | border: 'medium solid green', 37 | display: 'inline-block', 38 | paddingRight: '20px', 39 | }; 40 | 41 | const lastResultIncorrectStyle = { ...lastResultCorrectStyle, border: 'medium solid red' }; 42 | 43 | const quizLineStyle = { 44 | marginTop: '10px', 45 | marginBottom: '10px', 46 | }; 47 | 48 | class QuizLine extends React.Component { 49 | constructor() { 50 | super(); 51 | this.onChange = this.onChange.bind(this); 52 | this.handleKeyPress = this.handleKeyPress.bind(this); 53 | this.checkAnswer = this.checkAnswer.bind(this); 54 | this.keyPress = this.keyPress.bind(this); 55 | // TODO: Fix and remove this ESLint disable 56 | // eslint-disable-next-line react/state-in-constructor 57 | this.state = { answer: '' }; 58 | } 59 | 60 | onChange(e) { 61 | const { name, value } = e.target; 62 | this.setState({ 63 | [name]: value, 64 | }); 65 | } 66 | 67 | checkAnswer() { 68 | const { checkAnswer } = this.props; 69 | const { answer } = this.state; 70 | checkAnswer(answer); 71 | this.setState({ 72 | answer: '', 73 | }); 74 | } 75 | 76 | handleKeyPress(event) { 77 | if (event.key === 'Enter') { 78 | this.checkAnswer(); 79 | } 80 | } 81 | 82 | keyPress(key) { 83 | const { answer } = this.state; 84 | if (!isNaN(key)) { 85 | this.setState({ 86 | answer: `${answer}${key}`, 87 | }); 88 | } else { 89 | switch (key) { 90 | case 'back': 91 | if (answer.length) { 92 | this.setState({ 93 | answer: answer.substr(0, answer.length - 1), 94 | }); 95 | } 96 | break; 97 | case 'enter': 98 | this.checkAnswer(); 99 | break; 100 | default: 101 | // eslint-disable-next-line no-console 102 | console.warn(`Unknown key in keyPress ${key}`); 103 | break; 104 | } 105 | } 106 | } 107 | 108 | renderNewRecord() { 109 | const { 110 | newRecord: { 111 | isNewRecord, 112 | currentTimePerQuestion, 113 | existingRecordTimePerQuestion, 114 | }, 115 | } = this.props; 116 | 117 | const current = isNaN(currentTimePerQuestion) ? '[none]' : currentTimePerQuestion; 118 | const existing = existingRecordTimePerQuestion || '[none]'; 119 | 120 | const recordText = `Current Speed ${current} and Record ${existing}${isNewRecord ? ' NEW RECORD!' : ''}`; 121 | 122 | return ( 123 |
124 |
125 | {recordText} 126 |
127 |
128 | ); 129 | } 130 | 131 | renderLastResult() { 132 | const { lastResult } = this.props; 133 | if (!lastResult) { 134 | const divStyle = { 135 | 136 | ...lastResultCorrectStyle, 137 | paddingBottom: '5px', 138 | paddingLeft: '10px', 139 | paddingRight: '10px', 140 | paddingTop: '5px', 141 | display: 'block', 142 | }; 143 | 144 | return ( 145 |
146 | {'Ready for your first answer...'} 147 |
148 | ); 149 | } 150 | const { actuals, task } = lastResult; 151 | const [actual] = actuals; 152 | const [,,, answer] = task; 153 | 154 | const borderStyle = answer === actual 155 | ? lastResultCorrectStyle 156 | : lastResultIncorrectStyle; 157 | 158 | return ( 159 |
160 | 161 |
162 | ); 163 | } 164 | 165 | render() { 166 | const { answer } = this.state; 167 | const { onscreenKeyboard, problem, largeKeyboard } = this.props; 168 | const [left, right, opIndex] = problem; 169 | const operator = operations[opIndex]; 170 | return ( 171 |
172 |
173 | 174 | {left} 175 | 176 | 177 | {operator} 178 | 179 | 180 | {right} 181 | 182 | 183 | = 184 | 185 | { 186 | onscreenKeyboard 187 | ? ( 188 | 189 | {answer || '?'} 190 | 191 | ) 192 | : ( 193 | 203 | ) 204 | } 205 | { 206 | !onscreenKeyboard 207 | && ( 208 | 209 | 210 | Check Answer 211 | 212 | ) 213 | } 214 |
215 | {this.renderLastResult()} 216 | {this.renderNewRecord()} 217 |
218 | 223 |
224 |
225 | ); 226 | } 227 | } 228 | 229 | QuizLine.propTypes = { 230 | checkAnswer: PropTypes.func.isRequired, 231 | lastResult: PropTypes.shape({ 232 | actuals: PropTypes.arrayOf(PropTypes.number).isRequired, 233 | id: PropTypes.number.isRequired, 234 | task: PropTypes.array.isRequired, // left, right, opIndex, answer 235 | timeTaken: PropTypes.number.isRequired, 236 | }), 237 | newRecord: PropTypes.shape({ 238 | isNewRecord: PropTypes.bool.isRequired, 239 | currentTimePerQuestion: PropTypes.number.isRequired, 240 | existingRecordTimePerQuestion: PropTypes.number.isRequired, 241 | }).isRequired, 242 | onscreenKeyboard: PropTypes.bool.isRequired, 243 | largeKeyboard: PropTypes.bool.isRequired, 244 | problem: PropTypes.arrayOf(PropTypes.number).isRequired, 245 | }; 246 | 247 | QuizLine.defaultProps = { 248 | lastResult: null, 249 | }; 250 | 251 | module.exports = QuizLine; 252 | -------------------------------------------------------------------------------- /test/__snapshots__/app.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App should render an App component 1`] = ` 4 | 303 | `; 304 | -------------------------------------------------------------------------------- /src/learn/math/drill/index.jsx: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const React = require('react'); 3 | const db = require('../../db'); 4 | const Finished = require('./finished'); 5 | const helper = require('./helper'); 6 | const Options = require('./options'); 7 | const Running = require('./running'); 8 | 9 | class MathDrill extends React.Component { 10 | static save(keyValuePair) { 11 | const options = db.getOptions(); 12 | if (!options || !keyValuePair || !keyValuePair.hasOwnProperty) { 13 | return; 14 | } 15 | 16 | Object.keys(options).forEach((key) => { 17 | if (Object.prototype.hasOwnProperty.call(keyValuePair, key)) { 18 | options[key] = keyValuePair[key]; 19 | } 20 | }); 21 | 22 | db.saveOptions(options); 23 | } 24 | 25 | constructor() { 26 | super(); 27 | 28 | const options = db.getOptions(); 29 | 30 | // TODO: Fix and remove this ESLint disable 31 | // eslint-disable-next-line react/state-in-constructor 32 | this.state = { 33 | largeKeyboard: options.largeKeyboard, 34 | currentTask: [], 35 | levelIndex: options.levelIndex, 36 | minutes: options.minutes, 37 | onscreenKeyboard: options.onscreenKeyboard, 38 | opIndexes: options.opIndexes, 39 | previousResults: [], // previousResults results of quiz 40 | totalProblems: options.totalProblems, 41 | userName: options.userName, 42 | }; 43 | 44 | this.checkAnswer = this.checkAnswer.bind(this); 45 | this.onChange = this.onChange.bind(this); 46 | this.onStart = this.onStart.bind(this); 47 | this.setNextTask = this.setNextTask.bind(this); 48 | this.onInterval = this.onInterval.bind(this); 49 | this.setParentState = this.setParentState.bind(this); 50 | } 51 | 52 | onInterval() { 53 | const { 54 | endTime, 55 | } = this.state; 56 | const timeLeft = Math.round(endTime.diff(moment()) / 1000); 57 | if (timeLeft <= 0) { 58 | this.endQuiz({ timeLeft }); 59 | } else { 60 | this.setState({ 61 | timeLeft, 62 | }); 63 | } 64 | } 65 | 66 | onStart() { 67 | this.setNextTask(); 68 | const { 69 | levelIndex, 70 | minutes = '1', 71 | opIndexes, 72 | totalProblems, 73 | } = this.state; 74 | const seconds = parseFloat(minutes, 10) * 60; 75 | const startTime = moment(); 76 | const endTime = moment().add(seconds, 'seconds'); 77 | const timerId = setInterval(this.onInterval, 1000); 78 | const currentRecord = helper.getCurrentRecord(levelIndex, opIndexes); 79 | this.setState({ 80 | currentAction: 'running', 81 | currentRecord, 82 | endTime, 83 | questionsRemaining: parseInt(totalProblems, 10), 84 | seconds, 85 | startTime, 86 | timeLeft: seconds, 87 | timerId, 88 | }); 89 | } 90 | 91 | onChange(e) { 92 | const { name } = e.target; 93 | const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value; 94 | const kvp = { [name]: value }; 95 | this.setState(kvp); 96 | MathDrill.save(kvp); 97 | } 98 | 99 | setParentState(state) { 100 | this.setState(state); 101 | MathDrill.save(state); 102 | } 103 | 104 | setNextTask() { 105 | const { levelIndex, opIndexes, currentTask } = this.state; 106 | const nextTask = helper.getLowerUpper(levelIndex, opIndexes); 107 | 108 | if (nextTask.every((item, index) => currentTask[index] === item)) { 109 | this.setNextTask(); 110 | } else { 111 | this.setState({ 112 | currentTask: nextTask, 113 | }); 114 | } 115 | } 116 | 117 | endQuiz(otherState = {}) { 118 | const { timerId } = this.state; 119 | clearInterval(timerId); 120 | // Need to do this to get latest results. Merging in otherState 121 | // with state will provide the final results. We can't rely on 122 | // a call to this.setState() in a previous method because it batches 123 | // its calls and may not have updated state. 124 | const state = { 125 | 126 | ...this.state, 127 | ...otherState, 128 | }; 129 | const resultInfo = helper.appendScore(state); 130 | this.setState({ 131 | currentAction: 'finished', 132 | timerId: null, 133 | ...otherState, 134 | resultInfo, 135 | }); 136 | } 137 | 138 | // eslint-disable-next-line react/sort-comp 139 | static currentMatchesLast(task, previousResults) { 140 | if (!previousResults.length) { 141 | return false; 142 | } 143 | const lastResult = previousResults[previousResults.length - 1]; 144 | const { task: lastTask } = lastResult; 145 | return task.length === 4 && task.every((t, i) => t === lastTask[i]); 146 | } 147 | 148 | checkAnswer(answer) { 149 | const actual = parseInt(answer, 10); 150 | let { totalProblems } = this.state; 151 | const { startTime } = this.state; 152 | totalProblems = parseInt(totalProblems, 10); 153 | if (!isNaN(actual)) { 154 | const { currentTask: task, previousResults = [] } = this.state; 155 | let { correctCount = 0, totalCount } = this.state; 156 | totalCount += 1; 157 | let { previousTime = startTime } = this.state; 158 | const timeTaken = parseFloat((moment().diff(previousTime) / 1000).toFixed(1)); 159 | 160 | const [,,, expected] = task; 161 | const correct = actual === expected; 162 | if (correct) { 163 | correctCount += 1; 164 | previousTime = moment(); 165 | } 166 | // If first time wrong then push onto previousResults 167 | // otherwise replace last element in previousResults 168 | // If correct and was previously wrong then replace otherwise push. 169 | // If problem exists at end of array then replace otherwise push. 170 | // A task is an array of: left, right, opIndex, expectedAnswer 171 | // and problems are not repeated so a first time can be checked using this. 172 | if (MathDrill.currentMatchesLast(task, previousResults)) { 173 | const current = previousResults[previousResults.length - 1]; 174 | current.actuals.unshift(actual); 175 | current.timeTaken = timeTaken; 176 | } else { 177 | const actuals = [actual]; 178 | previousResults.push({ 179 | task, actuals, timeTaken, id: previousResults.length, 180 | }); 181 | } 182 | 183 | const otherState = { 184 | correct, 185 | correctCount, 186 | previousTime, 187 | result: `${actual} is ${correct ? 'correct' : 'wrong'}`, 188 | previousResults, 189 | totalCount, 190 | questionsRemaining: totalProblems - correctCount, 191 | }; 192 | 193 | if (correctCount === totalProblems) { 194 | this.endQuiz(otherState); 195 | } else { 196 | this.setState(otherState); 197 | if (correct) { 198 | this.setNextTask(); 199 | } 200 | } 201 | } 202 | } 203 | 204 | renderOptions() { 205 | const { 206 | largeKeyboard, 207 | levelIndex, 208 | minutes, 209 | onscreenKeyboard, 210 | opIndexes, 211 | totalProblems, 212 | userName, 213 | } = this.state || {}; 214 | 215 | return ( 216 | 228 | ); 229 | } 230 | 231 | renderRunning() { 232 | const { 233 | currentRecord, 234 | currentTask, 235 | largeKeyboard, 236 | levelIndex, 237 | onscreenKeyboard, 238 | opIndexes, 239 | previousResults, 240 | questionsRemaining, 241 | startTime, 242 | timeLeft, 243 | } = this.state; 244 | 245 | return ( 246 | 258 | ); 259 | } 260 | 261 | renderFinished() { 262 | const { 263 | previousResults, 264 | resultInfo, 265 | seconds, 266 | timeLeft, 267 | totalProblems, 268 | levelIndex, 269 | opIndexes, 270 | } = this.state; 271 | return ( 272 | 281 | ); 282 | } 283 | 284 | render() { 285 | const { 286 | currentAction = 'start', 287 | } = this.state || {}; 288 | switch (currentAction) { 289 | case 'start': 290 | return this.renderOptions(); 291 | case 'running': 292 | return this.renderRunning(); 293 | case 'finished': 294 | return this.renderFinished(); 295 | default: 296 | // eslint-disable-next-line no-console 297 | console.error(`Unknown currentAction ${currentAction}`); 298 | return null; 299 | } 300 | } 301 | } 302 | 303 | module.exports = MathDrill; 304 | -------------------------------------------------------------------------------- /src/learn/math/drill/helper.js: -------------------------------------------------------------------------------- 1 | // A collection of helper functions 2 | const moment = require('moment'); 3 | const db = require('../../db'); 4 | const constants = require('../../common/constants'); 5 | const { levelOps } = require('./helper-problems'); 6 | 7 | const { 8 | RECORD_NEW, 9 | RECORD_EQUAL, 10 | RECORD_MISS, 11 | RECORD_NOT_EXIST, 12 | BADGE_BOUNDARIES: badgeBoundaries, 13 | COLOR_HTML, 14 | } = constants; 15 | 16 | 17 | function calculateAnswer([left, right], opIndex) { 18 | switch (opIndex) { 19 | case 0: 20 | return left + right; 21 | case 1: 22 | return left - right; 23 | case 2: 24 | return left * right; 25 | case 3: 26 | // When numerator is greater than denominator we're going to treat 27 | // it as a zero 28 | return Math.floor(left / right); 29 | default: 30 | return 0; 31 | } 32 | } 33 | 34 | /** 35 | * 36 | * @param {number} levelIndex - 0 through 25 - each letter of the alphabet 37 | * @param {number[]} opIndexes - An array of 0 through 3 - operators +, -, *, / 38 | */ 39 | function getLowerUpper(levelIndexParam, opIndexes) { 40 | // Pick a random value from the opIndexes array 41 | const opIndex = opIndexes[Math.floor(Math.random() * opIndexes.length)]; 42 | 43 | // Pick a collection of levels based on the opIndex 44 | const levels = levelOps[opIndex].filter((level) => !!level.length); 45 | // Some of the last few levels are intentionally empty because it's just a repeat of 46 | // the final level that has values. 47 | const levelIndex = Math.min(levels.length, levelIndexParam); 48 | // Put all the levels up to levelIndex into an array except put the same number of each 49 | // level as the ordinal number of the level. This will gradually weight the higher levels 50 | // so pairs from those levels will come up more often. 51 | // e.g. 52 | // level = 0, [0] 53 | // level = 1: [0, 1, 1] 54 | // level = 2: [0, 1, 1, 2, 2, 2] 55 | const weightedLevels = [...Array(levelIndex + 1).keys()] 56 | .reduce((acc, level) => acc.concat([...Array(level + 1).keys()] 57 | .map(() => level)), []); 58 | 59 | // Pick a random value from the array 60 | const levelElement = weightedLevels[Math.floor(Math.random() * weightedLevels.length)]; 61 | // Get the array of pairs for that level 62 | const level = levels[Math.min(levelElement, levels.length - 1)]; 63 | // Pick a random pair from the array 64 | const pair = level[Math.floor(Math.random() * level.length)]; 65 | 66 | return [...pair, opIndex, calculateAnswer(pair, opIndex)]; 67 | } 68 | 69 | // The key identifies a problem set by level and operators. This allows us 70 | // to search for previous scores that used that combination to see if this 71 | // is a new record. 72 | function createKey(levelIndex, opIndexes) { 73 | return `${levelIndex.toString()}-${opIndexes.join('')}`; 74 | } 75 | 76 | function getCurrentRecord(levelIndex, opIndexes) { 77 | const existingScores = db.getScores() || []; 78 | const key = createKey(levelIndex, opIndexes); 79 | 80 | const matchingProblems = existingScores.filter((score) => score.key === key) 81 | .sort((a, b) => a.timePerQuestion - b.timePerQuestion); 82 | return matchingProblems[0]; 83 | } 84 | 85 | function appendScore(results) { 86 | const { 87 | correctCount, 88 | levelIndex, 89 | minutes, 90 | opIndexes: operatorIndexes, 91 | previousResults, 92 | questionsRemaining, 93 | timeLeft, 94 | totalProblems, 95 | } = results; 96 | const opIndexes = operatorIndexes.sort(); 97 | 98 | const completed = questionsRemaining === 0; 99 | const timeTaken = (minutes * 60) - timeLeft; 100 | 101 | const timePerQuestion = correctCount === 0 102 | ? NaN 103 | : parseFloat((timeTaken / correctCount).toFixed(1)); 104 | const incorrectCount = previousResults.length - correctCount; 105 | const date = Date.now(); 106 | 107 | const resultInfo = {}; 108 | const currentRecord = getCurrentRecord(levelIndex, opIndexes); 109 | 110 | // 5 scenarios 111 | // 1. No matching problems - first time this test has been done. 112 | // 2. Equals previous high score 113 | // 3. Beats previous high score 114 | // 4. Worse than previous high score 115 | // 5. Zero questions answered 116 | if (isNaN(timePerQuestion)) { 117 | resultInfo.text = `You didn't answer any questions correctly so we are not going to use this score \ 118 | as part of your high scores. If you are struggling with these problems then try an \ 119 | easier level or an ${'easier'} operator.`; 120 | resultInfo.newRecordInfo = RECORD_MISS; 121 | } else if (currentRecord) { 122 | const { timePerQuestion: bestTimePerQuestion } = currentRecord; 123 | if (timePerQuestion === bestTimePerQuestion) { 124 | resultInfo.text = `Your time is equal to your best score of ${timePerQuestion} seconds per question. Great job!`; 125 | resultInfo.newRecordInfo = RECORD_EQUAL; 126 | } else if (timePerQuestion < bestTimePerQuestion) { 127 | resultInfo.text = `NEW RECORD! Awesome work! Your new best score is ${timePerQuestion} seconds per question.`; 128 | resultInfo.newRecordInfo = RECORD_NEW; 129 | } else { 130 | resultInfo.text = `You answered the questions at a rate of ${timePerQuestion} seconds \ 131 | per question. (Your best score is ${bestTimePerQuestion} seconds per question.)`; 132 | resultInfo.newRecordInfo = RECORD_MISS; 133 | } 134 | } else { 135 | resultInfo.text = `This is the first time you've done this problem. You took \ 136 | ${timePerQuestion} seconds per question. Do this test again to see if you can \ 137 | beat this score. Good luck!`; 138 | resultInfo.newRecordInfo = RECORD_NOT_EXIST; 139 | } 140 | 141 | if (!isNaN(timePerQuestion)) { 142 | const record = { 143 | completed, 144 | correctCount, 145 | date, 146 | incorrectCount, 147 | key: createKey(levelIndex, opIndexes), 148 | levelIndex, 149 | minutes, 150 | opIndexes, 151 | previousResults, 152 | questionsRemaining, 153 | timeLeft, 154 | timePerQuestion, 155 | timeTaken, 156 | totalProblems, 157 | }; 158 | 159 | db.appendScore(record); 160 | } 161 | 162 | return resultInfo; 163 | } 164 | 165 | function getBadgeColorIndex(timePerQuestion) { 166 | return badgeBoundaries 167 | .reduce((badgeColor, boundary, index) => (timePerQuestion > boundary ? index : badgeColor), 0); 168 | } 169 | 170 | function getBadgeHtmlColor(timePerQuestion) { 171 | const index = getBadgeColorIndex(timePerQuestion); 172 | return COLOR_HTML[index]; 173 | } 174 | 175 | function getScoreBarTimes(levelIndex, opIndexes, scoreParams) { 176 | const thisKey = createKey(levelIndex, opIndexes); 177 | const scores = scoreParams || db.getScores() || []; 178 | const filteredScores = scores 179 | .filter(({ key, correctCount }) => key === thisKey && correctCount > 9) 180 | .sort((a, b) => a.date - b.date); 181 | if (!filteredScores.length) { 182 | return []; 183 | } 184 | 185 | return filteredScores 186 | .slice(-5) 187 | .map(({ date, timePerQuestion }) => ({ 188 | date, 189 | timePerQuestion, 190 | })); 191 | } 192 | 193 | 194 | function getScoreboard() { 195 | const scores = db.getScores() || []; 196 | // totals are for badges irrespective of operators so there will always be 197 | // 4 of these for Gold, Silver, Bronze and Blue 198 | const totals = [0, 0, 0, 0]; 199 | 200 | // levelOperators has keys (props) that map to both the level and operator(s) that were used 201 | // and aggregate the scores based on this. 202 | // Example keys: 203 | // '00' - Level A Addition 204 | // '313' - Level D Mixed Subtraction/Division 205 | // The values are arrays of 4 elements that match to badge colors and the number of 206 | // badges at that level/operator(s) 207 | // Example value: 208 | // [0, 5, 2, 1] - 0 Gold, 5 Silver, 2 Bronze, 1 Blue 209 | const levelOperators = scores.reduce((acc, score) => { 210 | const { 211 | levelIndex, 212 | correctCount, 213 | opIndexes, 214 | timePerQuestion, 215 | } = score; 216 | 217 | // Filter down the scores to just those that have 218 | // at least 10 correct answers 219 | if (correctCount > 9) { 220 | const key = createKey(levelIndex, opIndexes); 221 | if (!acc[key]) { 222 | acc[key] = [0, 0, 0, 0]; 223 | } 224 | const levelOp = acc[key]; 225 | 226 | const badgeColorIndex = getBadgeColorIndex(timePerQuestion); 227 | totals[badgeColorIndex] += 1; 228 | levelOp[badgeColorIndex] += 1; 229 | } 230 | 231 | return acc; 232 | }, {}); 233 | 234 | // ops is a collection of strings that are keys into the operators 235 | // used in the test. The operators are +, -, *, / and each of those 236 | // maps to an index value from 0 through 3. 237 | // For example: '0' is + and '13' is mixed - and / 238 | // Each new operator key is appended to this Set. 239 | const ops = new Set(); 240 | 241 | // levelOperators is now an object with both the level and the operators 242 | // in the keys. In order to make this easy for the component to show this 243 | // in a table form we are now going to change it into a two dimensional 244 | // array. The rows will map to levels and then each row will have a collection 245 | // of arrays that show the number of badges for that operator(s). 246 | // We will need another array to map the operator(s) into the columns. 247 | const levels = Object.keys(levelOperators).reduce((acc, levelOperator) => { 248 | const [levelIndexSplit, operatorIndexes] = levelOperator.split('-'); 249 | const levelIndex = parseInt(levelIndexSplit, 10); 250 | ops.add(operatorIndexes); 251 | if (!acc[levelIndex]) { 252 | acc[levelIndex] = {}; 253 | } 254 | acc[levelIndex][operatorIndexes] = levelOperators[levelOperator]; 255 | return acc; 256 | }, []); 257 | 258 | // levels now looks something like this: 259 | // [ 260 | // { '0': [0,5,3,1], '01': [0,0,0,2]}, 261 | // undefined, 262 | // { '0': [0,5,3,1], '02': [0,0,1,2]}, 263 | // ] 264 | // and ops is a set with: 265 | // '0', '01', '02' 266 | 267 | 268 | return { 269 | ops, // A Set of strings representing operators 270 | totals, // A 4 element array of badge totals 271 | levels, // An sparse array of up to 26 elements of objects with keys matching values in ops Set 272 | }; 273 | } 274 | 275 | function currentTimePerQuestion({ previousResults, startTime }) { 276 | const timeElapsed = moment().diff(startTime) / 1000; 277 | const correctQuestions = previousResults.reduce((acc, result) => { 278 | // result: {"task":[1,3,0,4],"actuals":[4],"timeTaken":2.5,"id":0} 279 | const { task, actuals } = result; 280 | const [actual] = actuals; 281 | const [,,, answer] = task; 282 | return acc + Number(actual === answer); 283 | }, 0); 284 | return correctQuestions 285 | ? parseFloat((timeElapsed / correctQuestions).toFixed(1)) 286 | : NaN; 287 | } 288 | 289 | function newRecord({ currentRecord, previousResults, startTime }) { 290 | const currentTime = currentTimePerQuestion({ previousResults, startTime }); 291 | if (!currentRecord) { 292 | return { 293 | isNewRecord: false, 294 | currentTimePerQuestion: currentTime, 295 | existingRecordTimePerQuestion: NaN, 296 | }; 297 | } 298 | const { timePerQuestion } = currentRecord; 299 | return { 300 | isNewRecord: isNaN(currentTime) ? false : timePerQuestion > currentTime, 301 | currentTimePerQuestion: currentTime, 302 | existingRecordTimePerQuestion: timePerQuestion || NaN, 303 | }; 304 | } 305 | 306 | module.exports = { 307 | appendScore, 308 | calculateAnswer, 309 | getBadgeColorIndex, 310 | getBadgeHtmlColor, 311 | getCurrentRecord, 312 | getLowerUpper, 313 | getScoreBarTimes, 314 | getScoreboard, 315 | newRecord, 316 | }; 317 | -------------------------------------------------------------------------------- /test/learn/math/drill/__snapshots__/score-bar.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ScoreBar should not show if showScoreBar is false 1`] = `null`; 4 | 5 | exports[`ScoreBar should not show if times is empty 1`] = `null`; 6 | 7 | exports[`ScoreBar should show 1 column with Gold Badge 1`] = ` 8 |
11 |
21 |
31 | 38 | 4 minutes ago 39 | 40 | 47 | Badges 48 | 49 |
50 |
51 |
61 | 74 | 81 | 82 | 83 | 91 | YOU⇾ 92 | 93 | 100 | 101 | 102 | 103 | 116 |
124 | Gold 125 |
126 |
134 | Silver 135 |
136 |
144 | Bronze 145 |
146 |
154 | Blue 155 |
156 |
157 |
158 |
159 | `; 160 | 161 | exports[`ScoreBar should show 1 column with below Blue Badge 1`] = ` 162 |
165 |
175 |
185 | 192 | 4 minutes ago 193 | 194 | 201 | Badges 202 | 203 |
204 |
205 |
215 | 228 | 235 | 236 | 237 | 245 | YOU↓ 246 | 247 | 254 | 255 | 256 | 257 | 270 |
278 | Gold 279 |
280 |
288 | Silver 289 |
290 |
298 | Bronze 299 |
300 |
308 | Blue 309 |
310 |
311 |
312 |
313 | `; 314 | 315 | exports[`ScoreBar should show 5 columns with mixed Badges 1`] = ` 316 |
319 |
329 |
339 | 346 | 4 minutes ago 347 | 348 | 355 | 2 minutes ago 356 | 357 | 364 | a few seconds ago 365 | 366 | 373 | Badges 374 | 375 |
376 |
377 |
387 | 400 | 407 | 408 | 409 | 417 | YOU↓ 418 | 419 | 426 | 427 | 428 | 429 | 442 | 449 | 450 | 451 | 459 | YOU↓ 460 | 461 | 468 | 469 | 470 | 471 | 484 | 491 | 492 | 493 | 501 | YOU⇾ 502 | 503 | 510 | 511 | 512 | 513 | 526 |
534 | Gold 535 |
536 |
544 | Silver 545 |
546 |
554 | Bronze 555 |
556 |
564 | Blue 565 |
566 |
567 |
568 |
569 | `; 570 | -------------------------------------------------------------------------------- /test/learn/math/drill/helper.test.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const db = require('../../../../src/learn/db'); 3 | const helper = require('../../../../src/learn/math/drill/helper'); 4 | const constants = require('../../../../src/learn/common/constants'); 5 | const util = require('../../../../src/learn/common/util'); 6 | 7 | const { getScoreboard } = helper; 8 | 9 | const { fillArray } = util; 10 | 11 | const { 12 | RECORD_NEW, 13 | RECORD_EQUAL, 14 | RECORD_MISS, 15 | RECORD_NOT_EXIST, 16 | // BADGE_BOUNDARIES: badgeBoundaries, 17 | // COLOR_HTML, 18 | } = constants; 19 | 20 | describe('Helper', () => { 21 | beforeEach(() => { 22 | db.getScores = jest.fn(); 23 | db.appendScore = jest.fn(); 24 | }); 25 | 26 | afterEach(() => { 27 | db.getScores.mockClear(); 28 | db.appendScore.mockClear(); 29 | }); 30 | 31 | test('should get a pair of numbers from level #1 plus', () => { 32 | const pair = helper.getLowerUpper(0, [0]); 33 | expect(pair[0] === 1 || pair[1] === 1).toBe(true); 34 | }); 35 | 36 | test('should get a random pair of numbers', () => { 37 | const pair = helper.getLowerUpper(4, [0]); 38 | expect(pair[0]).toBeLessThan(8); 39 | expect(pair[1]).toBeLessThan(8); 40 | }); 41 | 42 | test('should default to [] when scores null in getScoreBarTimes()', () => { 43 | db.getScores.mockReturnValueOnce(null); 44 | 45 | const actual = helper.getScoreBarTimes(0, [0]); 46 | 47 | const expected = []; 48 | 49 | expect(actual).toEqual(expected); 50 | }); 51 | 52 | test('should getScoreBarTimes() for zero items', () => { 53 | const scores = []; 54 | 55 | const actual = helper.getScoreBarTimes(0, [0], scores); 56 | 57 | const expected = []; 58 | 59 | expect(actual).toEqual(expected); 60 | }); 61 | 62 | test('should getScoreBarTimes() for single item', () => { 63 | const scores = [{ 64 | key: '0-0', 65 | correctCount: 10, 66 | date: 1, 67 | timePerQuestion: 1.3, 68 | }]; 69 | 70 | const actual = helper.getScoreBarTimes(0, [0], scores); 71 | 72 | const expected = [{ 73 | date: 1, 74 | timePerQuestion: 1.3, 75 | }]; 76 | 77 | expect(actual).toEqual(expected); 78 | }); 79 | 80 | test('should get the expected number of results', () => { 81 | const scores = []; 82 | const expected = []; 83 | for (let i = 0; i < 5; i += 1) { 84 | scores.push({ 85 | key: '0-0', 86 | correctCount: 10, 87 | date: 1, 88 | timePerQuestion: 2, 89 | }); 90 | 91 | const actual = helper.getScoreBarTimes(0, [0], scores); 92 | 93 | expected.push({ 94 | date: 1, 95 | timePerQuestion: 2, 96 | }); 97 | 98 | expect(actual).toEqual(expected); 99 | } 100 | }); 101 | 102 | test('should sort getScoreBarTimes() for two items', () => { 103 | const scores = [{ 104 | key: '0-0', 105 | correctCount: 22, 106 | date: 2, 107 | timePerQuestion: 2.2, 108 | }, { 109 | key: '0-0', 110 | correctCount: 11, 111 | date: 1, 112 | timePerQuestion: 1.1, 113 | }]; 114 | 115 | const actual = helper.getScoreBarTimes(0, [0], scores); 116 | 117 | const expected = [{ 118 | date: 1, 119 | timePerQuestion: 1.1, 120 | }, { 121 | date: 2, 122 | timePerQuestion: 2.2, 123 | }]; 124 | 125 | expect(actual).toEqual(expected); 126 | }); 127 | 128 | test( 129 | 'should not return more than 5 items getScoreBarTimes() for six items', 130 | () => { 131 | const scores = [{ 132 | key: '0-0', 133 | correctCount: 66, 134 | date: 6, 135 | timePerQuestion: 6.6, 136 | }, { 137 | key: '0-0', 138 | correctCount: 55, 139 | date: 5, 140 | timePerQuestion: 5.5, 141 | }, { 142 | key: '0-0', 143 | correctCount: 44, 144 | date: 4, 145 | timePerQuestion: 4.4, 146 | }, { 147 | key: '0-0', 148 | correctCount: 33, 149 | date: 3, 150 | timePerQuestion: 3.3, 151 | }, { 152 | key: '0-0', 153 | correctCount: 22, 154 | date: 2, 155 | timePerQuestion: 2.2, 156 | }, { 157 | key: '0-0', 158 | correctCount: 11, 159 | date: 1, 160 | timePerQuestion: 1.1, 161 | }]; 162 | 163 | const actual = helper.getScoreBarTimes(0, [0], scores); 164 | 165 | const expected = [{ 166 | date: 2, 167 | timePerQuestion: 2.2, 168 | }, { 169 | date: 3, 170 | timePerQuestion: 3.3, 171 | }, { 172 | date: 4, 173 | timePerQuestion: 4.4, 174 | }, { 175 | date: 5, 176 | timePerQuestion: 5.5, 177 | }, { 178 | date: 6, 179 | timePerQuestion: 6.6, 180 | }]; 181 | 182 | expect(actual).toEqual(expected); 183 | }, 184 | ); 185 | 186 | test('should calculate all Answer operators', () => { 187 | const { calculateAnswer } = helper; 188 | const inputs = [ 189 | [[6, 3], 0, 9], // 6 + 3 = 9 190 | [[6, 3], 1, 3], // 6 - 3 = 3 191 | [[6, 3], 2, 18], // 6 x 3 = 18 192 | [[6, 3], 3, 2], // 6 / 3 = 2 193 | [[6, 3], 4, 0], // Unknown operator returns 0 194 | ]; 195 | 196 | inputs.forEach((input) => { 197 | const [pair, operator, expected] = input; 198 | const actual = calculateAnswer(pair, operator); 199 | expect(actual).toBe(expected); 200 | }); 201 | }); 202 | 203 | test('should get current record if data exists', () => { 204 | db.getScores.mockReturnValueOnce([{ 205 | key: '0-01', 206 | timePerQuestion: 2, 207 | }, { 208 | key: '0-02', 209 | timePerQuestion: 0.5, 210 | }, { 211 | key: '0-01', 212 | timePerQuestion: 1, 213 | }]); 214 | const { getCurrentRecord } = helper; 215 | 216 | const actual = getCurrentRecord(0, [0, 1]); 217 | expect(actual).toEqual({ 218 | key: '0-01', 219 | timePerQuestion: 1, 220 | }); 221 | }); 222 | 223 | test('should get undefined for current record if no data exists', () => { 224 | db.getScores.mockReturnValueOnce(undefined); 225 | const { getCurrentRecord } = helper; 226 | 227 | const actual = getCurrentRecord(0, [0, 1]); 228 | expect(actual).toBeUndefined(); 229 | }); 230 | 231 | test('should append score with no existing record', () => { 232 | db.getScores.mockReturnValueOnce(undefined); 233 | const { appendScore } = helper; 234 | const results = { 235 | correctCount: 10, 236 | levelIndex: 0, 237 | minutes: 10, // in minutes 238 | opIndexes: [0], 239 | previousResults: [], // TODO: A test where this is undefined 240 | questionsRemaining: 0, 241 | timeLeft: 540, // in seconds 242 | totalProblems: 10, 243 | }; 244 | 245 | const actual = appendScore(results); 246 | expect(actual).toEqual({ 247 | // eslint-disable-next-line no-multi-str 248 | text: 'This is the first time you\'ve done this problem. You took \ 249 | 6 seconds per question. Do this test again to see if you can \ 250 | beat this score. Good luck!', 251 | newRecordInfo: RECORD_NOT_EXIST, 252 | }); 253 | expect(db.getScores).toHaveBeenCalled(); 254 | expect(db.appendScore).toHaveBeenCalled(); 255 | }); 256 | 257 | test('should not append score if no correct answers', () => { 258 | db.getScores.mockReturnValueOnce(undefined); 259 | const { appendScore } = helper; 260 | const results = { 261 | correctCount: 0, 262 | levelIndex: 0, 263 | minutes: 10, // in minutes 264 | opIndexes: [0], 265 | previousResults: [], // TODO: A test where this is undefined 266 | questionsRemaining: 0, 267 | timeLeft: 540, // in seconds 268 | totalProblems: 10, 269 | }; 270 | 271 | const actual = appendScore(results); 272 | expect(actual).toEqual({ 273 | // eslint-disable-next-line no-multi-str 274 | text: 'You didn\'t answer any questions correctly so we are not going to use this score \ 275 | as part of your high scores. If you are struggling with these problems then try an \ 276 | easier level or an easier operator.', 277 | newRecordInfo: RECORD_MISS, 278 | }); 279 | expect(db.getScores).toHaveBeenCalled(); 280 | expect(db.appendScore).not.toHaveBeenCalled(); 281 | }); 282 | 283 | test('should report an equal record', () => { 284 | db.getScores.mockReturnValueOnce([{ 285 | timePerQuestion: 6, 286 | key: '0-0', 287 | }]); 288 | const { appendScore } = helper; 289 | const results = { 290 | correctCount: 10, 291 | levelIndex: 0, 292 | minutes: 10, // in minutes 293 | opIndexes: [0], 294 | previousResults: [], // TODO: A test where this is undefined 295 | questionsRemaining: 0, 296 | timeLeft: 540, // in seconds 297 | totalProblems: 10, 298 | }; 299 | 300 | const actual = appendScore(results); 301 | expect(actual).toEqual({ 302 | // eslint-disable-next-line no-multi-str 303 | text: 'Your time is equal to your best score of 6 seconds per question. Great job!', 304 | newRecordInfo: RECORD_EQUAL, 305 | }); 306 | expect(db.getScores).toHaveBeenCalled(); 307 | expect(db.appendScore).toHaveBeenCalled(); 308 | }); 309 | 310 | test('should report a new record', () => { 311 | db.getScores.mockReturnValueOnce([{ 312 | timePerQuestion: 7, 313 | key: '0-0', 314 | }]); 315 | const { appendScore } = helper; 316 | const results = { 317 | correctCount: 10, 318 | levelIndex: 0, 319 | minutes: 10, // in minutes 320 | opIndexes: [0], 321 | previousResults: [], // TODO: A test where this is undefined 322 | questionsRemaining: 0, 323 | timeLeft: 540, // in seconds 324 | totalProblems: 10, 325 | }; 326 | 327 | const actual = appendScore(results); 328 | expect(actual).toEqual({ 329 | // eslint-disable-next-line no-multi-str 330 | text: 'NEW RECORD! Awesome work! Your new best score is 6 seconds per question.', 331 | newRecordInfo: RECORD_NEW, 332 | }); 333 | expect(db.getScores).toHaveBeenCalled(); 334 | expect(db.appendScore).toHaveBeenCalled(); 335 | }); 336 | 337 | test('should report a slower result', () => { 338 | db.getScores.mockReturnValueOnce([{ 339 | timePerQuestion: 5, 340 | key: '0-0', 341 | }]); 342 | const { appendScore } = helper; 343 | const results = { 344 | correctCount: 10, 345 | levelIndex: 0, 346 | minutes: 10, // in minutes 347 | opIndexes: [0], 348 | previousResults: [], // TODO: A test where this is undefined 349 | questionsRemaining: 0, 350 | timeLeft: 540, // in seconds 351 | totalProblems: 10, 352 | }; 353 | 354 | const actual = appendScore(results); 355 | expect(actual).toEqual({ 356 | // eslint-disable-next-line no-multi-str 357 | text: 'You answered the questions at a rate of 6 seconds \ 358 | per question. (Your best score is 5 seconds per question.)', 359 | newRecordInfo: RECORD_MISS, 360 | }); 361 | expect(db.getScores).toHaveBeenCalled(); 362 | expect(db.appendScore).toHaveBeenCalled(); 363 | }); 364 | 365 | describe('getScoreboard', () => { 366 | test('should get null, false and 0 for no scores', () => { 367 | db.getScores.mockReturnValueOnce(undefined); 368 | const expected = { 369 | levels: [], 370 | ops: new Set(), 371 | totals: fillArray(4, 0), 372 | }; 373 | const actual = getScoreboard(); 374 | 375 | expect(actual).toEqual(expected); 376 | }); 377 | 378 | test('should get values for one score', () => { 379 | db.getScores.mockReturnValueOnce([{ 380 | levelIndex: 0, 381 | correctCount: 10, 382 | opIndexes: [0], 383 | timePerQuestion: 6, 384 | }]); 385 | 386 | const expected = { 387 | levels: [{ 0: [0, 0, 0, 1] }], // Blue badge for Level A 388 | ops: new Set(['0']), // Operation 0 - addition 389 | totals: [0, 0, 0, 1], // One blue (index 3) badge in total 390 | }; 391 | 392 | const actual = getScoreboard(); 393 | 394 | expect(actual).toEqual(expected); 395 | }); 396 | 397 | test('should get values for mixed operators', () => { 398 | db.getScores.mockReturnValueOnce([{ 399 | levelIndex: 0, 400 | correctCount: 10, 401 | opIndexes: [0, 1], 402 | timePerQuestion: 6, 403 | }]); 404 | 405 | const expected = { 406 | levels: [{ '01': [0, 0, 0, 1] }], // Blue badge for Level A 407 | ops: new Set(['01']), // Operations 0 & 1 - addition/subtraction 408 | totals: [0, 0, 0, 1], // One blue (index 3) badge in total 409 | }; 410 | 411 | const actual = getScoreboard(); 412 | 413 | expect(actual).toEqual(expected); 414 | }); 415 | 416 | test('should get values for multiple operations', () => { 417 | db.getScores.mockReturnValueOnce([{ 418 | levelIndex: 0, 419 | correctCount: 10, 420 | opIndexes: [0], 421 | timePerQuestion: 6, 422 | }, { 423 | levelIndex: 0, 424 | correctCount: 10, 425 | opIndexes: [1], 426 | timePerQuestion: 6, 427 | }]); 428 | 429 | const expected = { 430 | levels: [{ 0: [0, 0, 0, 1], 1: [0, 0, 0, 1] }], // Blue badge for Level A 431 | ops: new Set(['0', '1']), // Operations 0 & 1 - addition & subtraction 432 | totals: [0, 0, 0, 2], // One blue (index 3) badge in total 433 | }; 434 | 435 | const actual = getScoreboard(); 436 | 437 | expect(actual).toEqual(expected); 438 | }); 439 | 440 | test('should get null, false and 0 for correct answers under 10', () => { 441 | db.getScores.mockReturnValueOnce([{ 442 | levelIndex: 0, 443 | correctCount: 9, 444 | opIndexes: [0], 445 | timePerQuestion: 6, 446 | }]); 447 | 448 | const expected = { 449 | levels: [], 450 | ops: new Set(), 451 | totals: fillArray(4, 0), 452 | }; 453 | 454 | const actual = getScoreboard(); 455 | 456 | expect(actual).toEqual(expected); 457 | }); 458 | }); 459 | 460 | describe('newRecord()', () => { 461 | test('should return false and NaN if no data', () => { 462 | const { newRecord } = helper; 463 | const input = { 464 | currentRecord: {}, 465 | previousResults: [], 466 | startTime: 0, 467 | }; 468 | const expected = { 469 | isNewRecord: false, 470 | currentTimePerQuestion: NaN, 471 | existingRecordTimePerQuestion: NaN, 472 | }; 473 | const actual = newRecord(input); 474 | 475 | expect(actual).toEqual(expected); 476 | }); 477 | 478 | test('should return false and NaN if current record missing', () => { 479 | const { newRecord } = helper; 480 | const input = { 481 | previousResults: [], 482 | startTime: 0, 483 | }; 484 | const expected = { 485 | isNewRecord: false, 486 | currentTimePerQuestion: NaN, 487 | existingRecordTimePerQuestion: NaN, 488 | }; 489 | const actual = newRecord(input); 490 | 491 | expect(actual).toEqual(expected); 492 | }); 493 | 494 | test('should return valid results with valid input', () => { 495 | // 2017-01-01 01:01:01 496 | const start = new Date(2017, 0, 1, 1, 1, 1); 497 | // 2017-01-01 01:01:40 498 | const end = new Date(2017, 0, 1, 1, 1, 40); 499 | const startTime = moment(start); 500 | Date.now = jest.fn(() => end.valueOf()); 501 | const { newRecord } = helper; 502 | const input = { 503 | currentRecord: { 504 | timePerQuestion: 6, 505 | }, 506 | previousResults: [{ 507 | task: [0, 0, 0, 55], 508 | actuals: [55], 509 | }], 510 | startTime, 511 | }; 512 | const expected = { 513 | isNewRecord: false, 514 | currentTimePerQuestion: 39, 515 | existingRecordTimePerQuestion: 6, 516 | }; 517 | const actual = newRecord(input); 518 | 519 | expect(actual).toEqual(expected); 520 | Date.now.mockClear(); 521 | }); 522 | }); 523 | }); 524 | --------------------------------------------------------------------------------