├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── bundle_fonts.sh ├── docs ├── groups.md └── pdf-fonts.md ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico ├── index.html ├── manifest.json └── wcif-extensions │ ├── ActivityConfig.json │ ├── CompetitionConfig.json │ └── RoomConfig.json └── src ├── components ├── App │ ├── App.js │ └── App.test.js ├── Competition │ ├── Competition.js │ ├── CompetitionMenu │ │ └── CompetitionMenu.js │ ├── ConfigManager │ │ ├── ConfigManager.js │ │ ├── GeneralConfig │ │ │ └── GeneralConfig.js │ │ ├── RoomConfig │ │ │ └── RoomConfig.js │ │ ├── RoomsConfig │ │ │ └── RoomsConfig.js │ │ ├── RoundActivityConfig │ │ │ └── RoundActivityConfig.js │ │ ├── RoundConfig │ │ │ └── RoundConfig.js │ │ └── RoundsConfig │ │ │ └── RoundsConfig.js │ ├── GroupsManager │ │ ├── GroupDialog │ │ │ └── GroupDialog.js │ │ ├── GroupsEditor │ │ │ ├── AllDraggableCompetitors │ │ │ │ └── AllDraggableCompetitors.js │ │ │ ├── CompetitorsPanel │ │ │ │ └── CompetitorsPanel.js │ │ │ ├── DraggableCompetitor │ │ │ │ └── DraggableCompetitor.js │ │ │ ├── DraggableCompetitorAssignments │ │ │ │ └── DraggableCompetitorAssignments.js │ │ │ ├── GroupActivityEditor │ │ │ │ └── GroupActivityEditor.js │ │ │ └── GroupsEditor.js │ │ ├── GroupsManager.js │ │ └── GroupsNavigation │ │ │ └── GroupsNavigation.js │ ├── NewAssignableRoundNotification │ │ └── NewAssignableRoundNotification.js │ ├── PrintingManager │ │ ├── CompetitorCards │ │ │ └── CompetitorCards.js │ │ ├── PrintingManager.js │ │ └── Scorecards │ │ │ └── Scorecards.js │ └── RolesManager │ │ └── RolesManager.js ├── CompetitionList │ ├── CompetitionList.js │ └── CompetitionList.test.js ├── Footer │ └── Footer.js ├── Header │ ├── Header.js │ └── Header.test.js ├── Home │ └── Home.js └── common │ ├── CubingIcon │ └── CubingIcon.js │ ├── EventSelect │ └── EventSelect.js │ ├── PositiveIntegerInput │ └── PositiveIntegerInput.js │ ├── RoomName │ └── RoomName.js │ ├── RoundsNavigation │ ├── RoundPanel │ │ └── RoundPanel.js │ └── RoundsNavigation.js │ ├── SaveWcifButton │ └── SaveWcifButton.js │ └── ZeroablePositiveIntegerInput │ └── ZeroablePositiveIntegerInput.js ├── index.css ├── index.js ├── logic ├── activities.js ├── assignments.js ├── auth.js ├── competitors.js ├── documents │ ├── competitor-cards.js │ ├── group-overview.js │ ├── pdf-utils.js │ ├── pdfmake.js │ └── scorecards.js ├── events.js ├── formatters.js ├── formulas.js ├── groups.js ├── history.js ├── tests │ ├── competitors.test.js │ ├── formatters.test.js │ ├── groups.test.js │ ├── wcif-builders.js │ └── wcif-validations.test.js ├── translations.js ├── utils.js ├── wca-api.js ├── wca-env.js ├── wcif-extensions.js ├── wcif-validation.js └── wcif.js ├── polyfills.js ├── registerServiceWorker.js └── setupTests.js /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | push: 5 | jobs: 6 | test: 7 | name: Main 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Install Node 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: '14.x' 15 | - name: Install npm dependencies 16 | run: npm ci 17 | - name: Run tests 18 | run: npm test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # NPM dependencies 2 | /node_modules 3 | 4 | # Testing 5 | /coverage 6 | 7 | # Production 8 | /build 9 | 10 | # Fonts bundle 11 | /public/vfs-fonts.bundle*.json 12 | 13 | # Environment configuration 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 30 | 31 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 32 | 33 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 34 | 35 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 36 | You must cause any modified files to carry prominent notices stating that You changed the files; and 37 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 38 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 39 | 40 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 41 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 42 | 43 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 44 | 45 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 46 | 47 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 48 | 49 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 50 | 51 | END OF TERMS AND CONDITIONS 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Groupifier [![Actions Status](https://github.com/jonatanklosko/groupifier/workflows/Test/badge.svg)](https://github.com/jonatanklosko/groupifier/actions) 2 | 3 | Task and group management tool for WCA competition organizers. 4 | It's designed to be highly customizable and work well with complex schedules. 5 | Check out [the website](https://groupifier.jonatanklosko.com) to find out more. 6 | 7 | ## Development 8 | 9 | ```bash 10 | git clone https://github.com/jonatanklosko/groupifier.git && cd groupifier 11 | npm install 12 | npm start 13 | ``` 14 | 15 | ## Testing 16 | 17 | Run `npm test`. 18 | 19 | ## Documentation 20 | 21 | Some implementation details are documented under the [`docs`](docs) directory. 22 | Also the [wiki](https://github.com/jonatanklosko/groupifier/wiki) 23 | describes some usage related aspects. 24 | -------------------------------------------------------------------------------- /bin/bundle_fonts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | font_files=( 6 | https://github.com/google/fonts/raw/59f86d8fc9353b362d45c981917024bc45a64145/apache/roboto/static/Roboto-Regular.ttf 7 | https://github.com/google/fonts/raw/59f86d8fc9353b362d45c981917024bc45a64145/apache/roboto/static/Roboto-Medium.ttf 8 | https://github.com/google/fonts/raw/59f86d8fc9353b362d45c981917024bc45a64145/apache/roboto/static/Roboto-Italic.ttf 9 | https://github.com/google/fonts/raw/59f86d8fc9353b362d45c981917024bc45a64145/apache/roboto/static/Roboto-MediumItalic.ttf 10 | https://github.com/layerssss/wqy/raw/c808324d36e9836bb4c9052e27e7db99633673ff/fonts/WenQuanYiZenHei.ttf 11 | https://github.com/googlefonts/noto-fonts/raw/fa6a9f1d0ac6cb67fc70958a2713d4b47c89dcf7/hinted/ttf/NotoSansThaiUI/NotoSansThaiUI-Regular.ttf 12 | https://github.com/googlefonts/noto-fonts/raw/fa6a9f1d0ac6cb67fc70958a2713d4b47c89dcf7/hinted/ttf/NotoSansArabicUI/NotoSansArabicUI-Regular.ttf 13 | https://github.com/googlefonts/noto-fonts/raw/fa6a9f1d0ac6cb67fc70958a2713d4b47c89dcf7/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf 14 | https://github.com/googlefonts/noto-fonts/raw/fa6a9f1d0ac6cb67fc70958a2713d4b47c89dcf7/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf 15 | https://github.com/notofonts/notofonts.github.io/raw/main/fonts/NotoSansDevanagari/hinted/ttf/NotoSansDevanagari-Regular.ttf 16 | https://github.com/notofonts/notofonts.github.io/raw/main/fonts/NotoSansMalayalam/hinted/ttf/NotoSansMalayalam-Regular.ttf 17 | https://github.com/notofonts/notofonts.github.io/raw/main/fonts/NotoSansKannada/hinted/ttf/NotoSansKannada-Regular.ttf 18 | ) 19 | target="$(pwd)/public/vfs-fonts.bundle.v3.json" 20 | tmpdir=$(mktemp -d) 21 | 22 | if [ -f $target ]; then 23 | echo 'Fonts bundle already exists' && exit 0 24 | fi 25 | 26 | cd $tmpdir 27 | echo 'Building fonts' 28 | for file in ${font_files[@]}; do 29 | echo "- Downloading $(basename $file)..." 30 | wget -q $file 31 | done 32 | echo '- Creating fonts bundle...' 33 | ( 34 | echo '{' 35 | file_number=1 36 | for file in *.ttf; do 37 | echo -n " \"$file\": \"$(openssl base64 -A -in $file)\"" 38 | if [ $file_number -ne ${#font_files[@]} ]; then echo ','; else echo ''; fi 39 | ((file_number++)) 40 | done 41 | echo '}' 42 | ) > $target 43 | echo "Created bundle at $target" 44 | rm -rf $tmpdir 45 | -------------------------------------------------------------------------------- /docs/groups.md: -------------------------------------------------------------------------------- 1 | ## Grouping 2 | 3 | ### Goals 4 | 1. Make sure everyone does one thing at a time (across all possible activities - rooms/stages). 5 | 2. Sort competitors by their official results. 6 | 3. If there are multiple organizers/delegates/dataentries, 7 | try to make one person of the given role available all the time 8 | (i.e. don't assign two people of the same role to overlapping activities). 9 | 10 | ### Algorithm 11 | Given rounds to assign, sort them by the number of groups, so that rounds with 12 | few possible timeframes are assigned first. For each round: 13 | 1. Create temporary group objects of the form `{ id, activity, size, competitors }`, 14 | where `id` reflects activity.id (just for convenience), `activity` is the group activity, 15 | `size` is the computed number of competitors in that group and `competitors` is an array of *WCIF Person*. 16 | These objects are referred to as *groups* from now on. 17 | 2. Sort groups by their number (which also implies chronology). 18 | 3. Iterate over sorted competitors and for each of them: 19 | 1. Find all the groups during which he's the most available (*most available groups*) 20 | (usually these are just the groups during which he doesn't have any other assignments, 21 | but if there are no such groups then we want to pick the ones that overlap the less with other assignments) **[Goal 1]** 22 | 2. If he's an organizer/delegate/dataentry, from *most available groups* select all the groups 23 | during which he doesn't overlap every other organizer/delegate/dataentry (*preferred groups*). **[Goal 3]** 24 | 3. Consider *preferred groups* if any and *most available groups* otherwise (*potential groups*). 25 | 4. Look up the first *potential group* that is not full. If there is one, assign the competitor to it. We're done. 26 | 5. Given all *potential groups* are full, assign the competitor to the last one (so it becomes over-populated). Then: 27 | 1. Try moving someone from the over-populated group to one of further groups. If successful, we're done. 28 | 2. Try moving someone from the over-populated group to one of previous groups 29 | and someone from that previous group to one of further groups (just like in *1.*). If successful, we're done. 30 | 3. Otherwise leave the group over-populated (highly unlikely). 31 | 4. Update WCIF and proceed with the next round. This way the new assignments 32 | are taken into account during the further process. 33 | -------------------------------------------------------------------------------- /docs/pdf-fonts.md: -------------------------------------------------------------------------------- 1 | ## PDF Fonts 2 | 3 | The library used for PDF creation is [`pdfmake`](https://github.com/bpampuch/pdfmake). 4 | In order to display competitor local names correctly, we need to add additional fonts to `pdfmake`. 5 | 6 | ### Fonts bundling 7 | `bin/bundle_fonts.sh` downloads all the fonts we need and creates a JSON file 8 | representing a virtual file system, which has the following form: 9 | ```json 10 | { "filename": "Base-64 encoded file", ... } 11 | ``` 12 | This file goes to the `public` directory, so that we can download it asynchronously in `src/logic/pdfmake`. 13 | 14 | ### Applying the fonts 15 | Unfortunately a font is used only when we specify it explicitly. 16 | In general it's fine to use the default Roboto font and do some additional work 17 | only for competitor local names. 18 | We determine which font should be used in `src/logic/documents/pdf-utils.js` 19 | on the basis of [Unicode block ranges](https://en.wikipedia.org/wiki/Unicode_block). 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "groupifier", 3 | "version": "2.1.0", 4 | "description": "Task and group management tool for WCA competition organizers.", 5 | "keywords": [ 6 | "speedcubing", 7 | "WCA", 8 | "competition", 9 | "scorecards" 10 | ], 11 | "homepage": "https://groupifier.jonatanklosko.com", 12 | "author": "Jonatan Kłosko", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/jonatanklosko/groupifier.git" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "bin/bundle_fonts.sh && react-scripts build", 21 | "test": "react-scripts test --env=jsdom", 22 | "eject": "react-scripts eject", 23 | "postinstall": "bin/bundle_fonts.sh" 24 | }, 25 | "dependencies": { 26 | "@cubing/icons": "^1.0.6", 27 | "@material-ui/core": "^4.9.0", 28 | "@material-ui/icons": "^4.5.1", 29 | "@material-ui/styles": "^4.9.0", 30 | "classnames": "^2.2.6", 31 | "pdfmake": "^0.1.54", 32 | "react": "^16.12.0", 33 | "react-app-polyfill": "^1.0.3", 34 | "react-beautiful-dnd": "^12.2.0", 35 | "react-dom": "^16.12.0", 36 | "react-router-dom": "^5.0.1", 37 | "react-scripts": "^3.1.1", 38 | "smoothscroll-polyfill": "^0.4.4" 39 | }, 40 | "devDependencies": { 41 | "enzyme": "^3.10.0", 42 | "enzyme-adapter-react-16": "^1.14.0", 43 | "husky": "^3.0.5", 44 | "lint-staged": "^9.2.5", 45 | "prettier": "^1.18.2", 46 | "react-test-renderer": "^16.9.0" 47 | }, 48 | "browserslist": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "husky": { 54 | "hooks": { 55 | "pre-commit": "lint-staged" 56 | } 57 | }, 58 | "lint-staged": { 59 | "src/**/*.{js,jsx,json,css,md}": [ 60 | "prettier --single-quote --trailing-comma es5 --write", 61 | "git add" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # See https://www.netlify.com/docs/redirects/#history-pushstate-and-single-page-apps 2 | /* /index.html 200 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonatanklosko/groupifier/a00eb36645372adfe0a801605fab388e11a4eb0c/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Groupifier 10 | 11 | 12 | 13 | 14 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Groupifier", 3 | "name": "Groupifier", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/wcif-extensions/ActivityConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "https://groupifier.jonatanklosko.com/wcif-extensions/ActivityConfig.json", 3 | "title": "ActivityConfig", 4 | "description": "WCIF extension with additional round activity configuration.", 5 | "type": "object", 6 | "properties": { 7 | "capacity": { 8 | "description": "The fraction of competitors of the corresponding round that should be assigned to the given activity.", 9 | "type": "number", 10 | "minimum": 0, 11 | "maximum": 1 12 | }, 13 | "groups": { 14 | "description": "The number of groups for the given activity.", 15 | "type": "integer", 16 | "minimum": 1 17 | }, 18 | "scramblers": { 19 | "description": "The number of scramblers that should be assigned to the given activity.", 20 | "type": "integer", 21 | "minimum": 0 22 | }, 23 | "runners": { 24 | "description": "The number of runners that should be assigned to the given activity.", 25 | "type": "integer", 26 | "minimum": 0 27 | }, 28 | "assignJudges": { 29 | "description": "A flag indicating whether judges should be assigned to the given activity.", 30 | "type": "boolean" 31 | }, 32 | "featuredCompetitorWcaUserIds": { 33 | "description": "A list of competitors' WCA user IDs who should have a star printed on their scorecard, for special handling by runners or scramblers.", 34 | "type": "list", 35 | } 36 | }, 37 | "required": ["capacity", "groups", "scramblers", "runners", "assignJudges"] 38 | } 39 | -------------------------------------------------------------------------------- /public/wcif-extensions/CompetitionConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "https://groupifier.jonatanklosko.com/wcif-extensions/CompetitionConfig.json", 3 | "title": "CompetitionConfig", 4 | "description": "WCIF extension with additional competition configuration.", 5 | "type": "object", 6 | "properties": { 7 | "localNamesFirst": { 8 | "description": "A flag indicating whether to swap competitor latin names with local ones in generated PDF documents.", 9 | "type": "boolean" 10 | }, 11 | "printOneName": { 12 | "description": "A flag indicating whether the local/latin name should be printed in parentheses.", 13 | "type": "boolean" 14 | }, 15 | "scorecardsBackgroundUrl": { 16 | "description": "URL of an image to be placed in the background of scorecards.", 17 | "type": "string" 18 | }, 19 | "competitorsSortingRule": { 20 | "description": "Indicates how competitors should be assigned to groups.", 21 | "type": "string", 22 | "enum": ["ranks", "balanced", "symmetric", "name-optimised"] 23 | }, 24 | "noTasksForNewcomers": { 25 | "description": "A flag indicating whether newcomers should be assigned any tasks.", 26 | "type": "boolean" 27 | }, 28 | "tasksForOwnEventsOnly": { 29 | "description": "A flag indicating whether competitors should be assigned tasks only in events they registered for.", 30 | "type": "boolean" 31 | }, 32 | "noRunningForForeigners": { 33 | "description": "A flag indicating whether foreigners should be assigned running.", 34 | "type": "boolean" 35 | }, 36 | "printStations": { 37 | "description": "A flag indicating whether competitors should have printed stations on their scorecards in generated PDF documents.", 38 | "type": "boolean" 39 | }, 40 | "scorecardPaperSize": { 41 | "description": "The size of paper that should be used for printing scorecards.", 42 | "type": "string", 43 | "enum": ["a4", "a6", "letter"] 44 | }, 45 | "scorecardOrder": { 46 | "description": "Whether scorecards should be printed in overall ascending order (1-2-3-4 5-6-7-8 9-10-11-12), or ascending order within each section of the page (1-4-7-10 2-5-8-11 3-6-9-12)", 47 | "type": "string", 48 | "enum": ["natural", "stacked"] 49 | }, 50 | "printScorecardsCoverSheets": { 51 | "description": "A flag indicating whether score cards should have printed cover sheets in generated PDF documents.", 52 | "type": "boolean" 53 | }, 54 | "printScrambleCheckerForTopRankedCompetitors": { 55 | "description": "A flag indicating whether the box for scramble checker signature should be printed for top-ranked competitors (WR100/NR25 in single or WR50/NR15 in average).", 56 | "type": "boolean" 57 | }, 58 | "printScrambleCheckerForFinalRounds": { 59 | "description": "A flag indicating whether the box for scrambler checker signature should be printed for final rounds.", 60 | "type": "boolean" 61 | }, 62 | "printScrambleCheckerForBlankScorecards": { 63 | "description": "A flag indicating whether the box for scrambler checker signature should be printed for blank scorecards.", 64 | "type": "boolean" 65 | } 66 | }, 67 | "required": ["localNamesFirst", "printOneName", "scorecardsBackgroundUrl", "competitorsSortingRule", "noTasksForNewcomers", "tasksForOwnEventsOnly"] 68 | } 69 | -------------------------------------------------------------------------------- /public/wcif-extensions/RoomConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "https://groupifier.jonatanklosko.com/wcif-extensions/RoomConfig.json", 3 | "title": "RoomConfig", 4 | "description": "WCIF extension with additional room configuration.", 5 | "type": "object", 6 | "properties": { 7 | "stations": { 8 | "description": "The number of stations in the given room.", 9 | "type": "integer", 10 | "minimum": 0 11 | } 12 | }, 13 | "required": ["stations"] 14 | } 15 | -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Router, Route, Switch, Redirect } from 'react-router-dom'; 3 | import CssBaseline from '@material-ui/core/CssBaseline'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import { createTheme } from '@material-ui/core/styles'; 6 | import { ThemeProvider } from '@material-ui/styles'; 7 | import blueGrey from '@material-ui/core/colors/blueGrey'; 8 | import blue from '@material-ui/core/colors/blue'; 9 | import { makeStyles } from '@material-ui/core/styles'; 10 | 11 | import history from '../../logic/history'; 12 | import Competition from '../Competition/Competition'; 13 | import CompetitionList from '../CompetitionList/CompetitionList'; 14 | import Footer from '../Footer/Footer'; 15 | import Header from '../Header/Header'; 16 | import Home from '../Home/Home'; 17 | 18 | import { isSignedIn, signIn, signOut } from '../../logic/auth'; 19 | 20 | const theme = createTheme({ 21 | palette: { 22 | primary: { 23 | main: blueGrey[900], 24 | }, 25 | secondary: blue, 26 | }, 27 | }); 28 | 29 | const useStyles = makeStyles(theme => ({ 30 | root: { 31 | display: 'flex', 32 | minHeight: '100vh', 33 | flexDirection: 'column', 34 | }, 35 | grow: { 36 | flexGrow: 1, 37 | }, 38 | main: { 39 | padding: theme.spacing(2), 40 | }, 41 | })); 42 | 43 | const App = () => { 44 | const classes = useStyles(); 45 | const [signedIn, setSignedIn] = useState(isSignedIn()); 46 | 47 | const handleSignIn = () => { 48 | signIn(); 49 | }; 50 | 51 | const handleSignOut = () => { 52 | signOut(); 53 | setSignedIn(false); 54 | }; 55 | 56 | return ( 57 | 58 | 59 |
60 | 61 |
66 | 67 | 68 | {signedIn ? ( 69 | 70 | 74 | 75 | 76 | 77 | ) : ( 78 | 79 | 80 | 81 | 82 | )} 83 | 84 | 85 |
87 |
88 |
89 | ); 90 | }; 91 | 92 | export default App; 93 | -------------------------------------------------------------------------------- /src/components/App/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import App from './App'; 5 | 6 | jest.mock('../../logic/auth'); 7 | 8 | it('renders without crashing', () => { 9 | mount(); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/Competition/Competition.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | import LinearProgress from '@material-ui/core/LinearProgress'; 4 | import List from '@material-ui/core/List'; 5 | import ListItem from '@material-ui/core/ListItem'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import Grid from '@material-ui/core/Grid'; 8 | import Typography from '@material-ui/core/Typography'; 9 | 10 | import CompetitionMenu from './CompetitionMenu/CompetitionMenu'; 11 | import NewAssignableRoundNotification from './NewAssignableRoundNotification/NewAssignableRoundNotification'; 12 | import ConfigManager from './ConfigManager/ConfigManager'; 13 | import GroupsManager from './GroupsManager/GroupsManager'; 14 | import PrintingManager from './PrintingManager/PrintingManager'; 15 | import RolesManager from './RolesManager/RolesManager'; 16 | 17 | import { getWcif } from '../../logic/wca-api'; 18 | import { sortWcifEvents } from '../../logic/events'; 19 | import { updateIn } from '../../logic/utils'; 20 | import { validateWcif } from '../../logic/wcif-validation'; 21 | 22 | const Competition = ({ match }) => { 23 | const [wcif, setWcif] = useState(null); 24 | const [loading, setLoading] = useState(true); 25 | const [errors, setErrors] = useState([]); 26 | 27 | useEffect(() => { 28 | getWcif(match.params.competitionId) 29 | /* Sort events, so that we don't need to remember about this everywhere. */ 30 | .then(wcif => updateIn(wcif, ['events'], sortWcifEvents)) 31 | .then(wcif => { 32 | setWcif(wcif); 33 | setErrors(validateWcif(wcif)); 34 | }) 35 | .catch(error => setErrors([error.message])) 36 | .finally(() => setLoading(false)); 37 | }, [match.params.competitionId]); 38 | 39 | return loading ? ( 40 | 41 | ) : ( 42 |
43 | {errors.length === 0 ? ( 44 | 45 | 46 | {wcif.name} 47 | 48 | 49 | ( 53 | 54 | 55 | 60 | 61 | 62 | 66 | 67 | 68 | )} 69 | /> 70 | ( 73 | 74 | )} 75 | /> 76 | ( 79 | 80 | )} 81 | /> 82 | ( 85 | 86 | )} 87 | /> 88 | } 91 | /> 92 | 93 | 94 | ) : ( 95 | 96 | Failed to load competition data 97 | 98 | {errors.map(error => ( 99 | 100 | {error} 101 | 102 | ))} 103 | 104 | 105 | )} 106 |
107 | ); 108 | }; 109 | 110 | export default Competition; 111 | -------------------------------------------------------------------------------- /src/components/Competition/CompetitionMenu/CompetitionMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import List from '@material-ui/core/List'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import Paper from '@material-ui/core/Paper'; 8 | import PeopleIcon from '@material-ui/icons/People'; 9 | import PermContactCalendarIcon from '@material-ui/icons/PermContactCalendar'; 10 | import PrintIcon from '@material-ui/icons/Print'; 11 | import SettingsIcon from '@material-ui/icons/Settings'; 12 | 13 | import { 14 | roomsConfigComplete, 15 | activitiesConfigComplete, 16 | } from '../../../logic/activities'; 17 | import { anyCompetitorAssignment } from '../../../logic/assignments'; 18 | 19 | const menuItems = [ 20 | { 21 | path: '/roles', 22 | text: 'Edit roles', 23 | icon: PermContactCalendarIcon, 24 | enabled: wcif => true, 25 | }, 26 | { 27 | path: '/config', 28 | text: 'Configure', 29 | icon: SettingsIcon, 30 | enabled: wcif => true, 31 | }, 32 | { 33 | path: '/groups', 34 | text: 'Manage groups', 35 | icon: PeopleIcon, 36 | enabled: wcif => 37 | roomsConfigComplete(wcif) && activitiesConfigComplete(wcif), 38 | }, 39 | { 40 | path: '/printing', 41 | text: 'Print documents', 42 | icon: PrintIcon, 43 | enabled: wcif => anyCompetitorAssignment(wcif), 44 | }, 45 | ]; 46 | 47 | const CompetitionMenu = ({ wcif, baseUrl }) => ( 48 | 49 | 50 | {menuItems.map(menuItem => ( 51 | 58 | 59 | 60 | 61 | 62 | 63 | ))} 64 | 65 | 66 | ); 67 | 68 | export default CompetitionMenu; 69 | -------------------------------------------------------------------------------- /src/components/Competition/ConfigManager/ConfigManager.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Button from '@material-ui/core/Button'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import Tab from '@material-ui/core/Tab'; 6 | import Tabs from '@material-ui/core/Tabs'; 7 | 8 | import RoomsConfig from './RoomsConfig/RoomsConfig'; 9 | import RoundsConfig from './RoundsConfig/RoundsConfig'; 10 | import GeneralConfig from './GeneralConfig/GeneralConfig'; 11 | import SaveWcifButton from '../../common/SaveWcifButton/SaveWcifButton'; 12 | import { getExpectedCompetitorsByRound } from '../../../logic/competitors'; 13 | import { 14 | roomsConfigComplete, 15 | activitiesConfigComplete, 16 | anyGroupAssignedOrCreated, 17 | } from '../../../logic/activities'; 18 | import { removeExtensionData } from '../../../logic/wcif-extensions'; 19 | import { mapIn } from '../../../logic/utils'; 20 | 21 | const ConfigManager = ({ wcif, onWcifUpdate }) => { 22 | const [localWcif, setLocalWcif] = useState(wcif); 23 | const [tabValue, setTabValue] = useState(0); 24 | const expectedCompetitorsByRound = useMemo( 25 | () => getExpectedCompetitorsByRound(wcif), 26 | [wcif] 27 | ); 28 | 29 | const clearConfig = () => { 30 | const withoutCompetitionConfig = removeExtensionData( 31 | 'CompetitionConfig', 32 | localWcif 33 | ); 34 | setLocalWcif( 35 | mapIn(withoutCompetitionConfig, ['schedule', 'venues'], venue => 36 | mapIn(venue, ['rooms'], room => 37 | removeExtensionData( 38 | 'RoomConfig', 39 | mapIn(room, ['activities'], activity => 40 | removeExtensionData('ActivityConfig', activity) 41 | ) 42 | ) 43 | ) 44 | ) 45 | ); 46 | setTabValue(0); 47 | }; 48 | 49 | const wcifConfigComplete = 50 | roomsConfigComplete(localWcif) && activitiesConfigComplete(localWcif); 51 | 52 | return ( 53 | 54 | 55 | setTabValue(value)}> 56 | 57 | 58 | 59 | 60 | 61 | 62 | {tabValue === 0 && ( 63 | 64 | )} 65 | {tabValue === 1 && ( 66 | 71 | )} 72 | {tabValue === 2 && ( 73 | 74 | )} 75 | 76 | 77 | 84 | 85 | 86 | 93 | 94 | 95 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default ConfigManager; 107 | -------------------------------------------------------------------------------- /src/components/Competition/ConfigManager/GeneralConfig/GeneralConfig.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Checkbox from '@material-ui/core/Checkbox'; 3 | import FormControl from '@material-ui/core/FormControl'; 4 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 5 | import FormHelperText from '@material-ui/core/FormHelperText'; 6 | import Grid from '@material-ui/core/Grid'; 7 | import InputLabel from '@material-ui/core/InputLabel'; 8 | import MenuItem from '@material-ui/core/MenuItem'; 9 | import Paper from '@material-ui/core/Paper'; 10 | import Select from '@material-ui/core/Select'; 11 | import TextField from '@material-ui/core/TextField'; 12 | import Typography from '@material-ui/core/Typography'; 13 | 14 | import { 15 | getExtensionData, 16 | setExtensionData, 17 | } from '../../../../logic/wcif-extensions'; 18 | import { setIn } from '../../../../logic/utils'; 19 | 20 | const competitorsSortingRules = [ 21 | { 22 | id: 'ranks', 23 | name: 'Official rankings', 24 | description: 'Sort competitors by their official rankings.', 25 | }, 26 | { 27 | id: 'balanced', 28 | name: 'Balanced', 29 | description: 30 | 'Sort competitors in 3x3x3 (any variation), 2x2x2, Pyraminx, Skewb, Square-1 and Clock by their official rankings. For other events put best people in different groups, so that there are good scramblers for each group.', 31 | }, 32 | { 33 | id: 'symmetric', 34 | name: 'Symmetric', 35 | description: 36 | 'Put best people in different groups, so that there are good scramblers for each group.', 37 | }, 38 | { 39 | id: 'name-optimised', 40 | name: 'Name-optimised', 41 | description: 42 | 'Sort competitors by their official rankings, but also minimize the number of people with the same name in each group. Use it when many competitors have the same name.', 43 | }, 44 | ]; 45 | 46 | const scorecardPaperSizes = [ 47 | { 48 | id: 'a4', 49 | name: 'Four scorecards per page (A4 paper)', 50 | }, 51 | { 52 | id: 'letter', 53 | name: 'Four scorecards per page (Letter paper, used in North America)', 54 | }, 55 | { 56 | id: 'a6', 57 | name: 'One scorecard per page (A6 paper)', 58 | }, 59 | ]; 60 | 61 | const scorecardSortingRules = [ 62 | { 63 | id: 'natural', 64 | name: 65 | 'Scorecards are arranged by row, page by page (1/2/3/4 5/6/7/8 9/10/11/12)', 66 | }, 67 | { 68 | id: 'stacked', 69 | name: 70 | 'Scorecards are arranged such that each stack of scorecards is sorted (1/4/7/10 2/5/8/11 3/6/9/12)', 71 | }, 72 | ]; 73 | 74 | const GeneralConfig = ({ wcif, onWcifChange }) => { 75 | const handlePropertyChange = (property, value) => { 76 | onWcifChange( 77 | setExtensionData( 78 | 'CompetitionConfig', 79 | wcif, 80 | setIn(getExtensionData('CompetitionConfig', wcif), [property], value) 81 | ) 82 | ); 83 | }; 84 | 85 | const handleCheckboxChange = event => { 86 | const { name, checked } = event.target; 87 | handlePropertyChange(name, checked); 88 | }; 89 | 90 | const handleTextFieldChange = event => { 91 | const { name, value } = event.target; 92 | handlePropertyChange(name, value); 93 | }; 94 | 95 | const { 96 | competitorsSortingRule, 97 | noTasksForNewcomers, 98 | tasksForOwnEventsOnly, 99 | noRunningForForeigners, 100 | localNamesFirst, 101 | printOneName, 102 | scorecardsBackgroundUrl, 103 | printStations, 104 | scorecardPaperSize, 105 | scorecardOrder, 106 | printScorecardsCoverSheets, 107 | printScrambleCheckerForTopRankedCompetitors, 108 | printScrambleCheckerForFinalRounds, 109 | printScrambleCheckerForBlankScorecards, 110 | } = getExtensionData('CompetitionConfig', wcif); 111 | 112 | return ( 113 | 114 | 115 | 116 | 117 | Assignments 118 | 119 | 120 | 121 | 122 | 123 | Competitors sorting rule 124 | 125 | 139 | 140 | {competitorsSortingRules.find( 141 | ({ id }) => id === competitorsSortingRule 142 | ).description + ' Note: this applies to first rounds only.'} 143 | 144 | 145 | 146 | 147 | 154 | } 155 | label="Don't assign tasks to newcomers" 156 | /> 157 | 158 | 159 | 166 | } 167 | label="Assign tasks to competitors only in events they registered for" 168 | /> 169 | 170 | 171 | 178 | } 179 | label="Don't assign running to foreigners" 180 | /> 181 | 182 | 183 | 184 | 185 | 186 | Printing 187 | 188 | 189 | 190 | 191 | Scorecard Paper Size 192 | 193 | 207 | 208 | 209 | 210 | 211 | 212 | Scorecard order 213 | 214 | 228 | 229 | 230 | 231 | 238 | } 239 | label="Print cover sheets for scorecards" 240 | /> 241 | 242 | 243 | 250 | } 251 | label="Swap latin names with local ones" 252 | /> 253 | 254 | 255 | 262 | } 263 | label="Only one name (does not put local/latin name in parentheses)" 264 | /> 265 | 266 | 267 | 274 | } 275 | label="Print out scramble checker sign box for top ranked competitors (WR100/NR25 in single or WR50/NR15 in average)" 276 | /> 277 | 278 | 279 | 286 | } 287 | label="Print out scramble checker sign box for final rounds" 288 | /> 289 | 290 | 291 | 298 | } 299 | label="Print out scrambler checker sign box for blank scorecards" 300 | /> 301 | 302 | 303 | 310 | } 311 | label="Print out station number" 312 | /> 313 | 314 | Note that this is printing only, you have to control if there is 315 | enough stations for everyone manually 316 | 317 | 318 | 326 | 327 | 328 | 329 | ); 330 | }; 331 | 332 | export default GeneralConfig; 333 | -------------------------------------------------------------------------------- /src/components/Competition/ConfigManager/RoomConfig/RoomConfig.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import RoomName from '../../../common/RoomName/RoomName'; 4 | import ZeroablePositiveIntegerInput from '../../../common/ZeroablePositiveIntegerInput/ZeroablePositiveIntegerInput'; 5 | import { setIn } from '../../../../logic/utils'; 6 | import { 7 | getExtensionData, 8 | setExtensionData, 9 | } from '../../../../logic/wcif-extensions'; 10 | 11 | const RoomConfig = ({ room, onChange, disabled }) => { 12 | const handleInputChange = (event, value) => { 13 | onChange( 14 | setExtensionData( 15 | 'RoomConfig', 16 | room, 17 | setIn(getExtensionData('RoomConfig', room), [event.target.name], value) 18 | ) 19 | ); 20 | }; 21 | 22 | const { stations } = getExtensionData('RoomConfig', room); 23 | 24 | return ( 25 |
26 | 27 | 34 |
35 | ); 36 | }; 37 | 38 | export default RoomConfig; 39 | -------------------------------------------------------------------------------- /src/components/Competition/ConfigManager/RoomsConfig/RoomsConfig.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import Paper from '@material-ui/core/Paper'; 4 | 5 | import RoomConfig from '../RoomConfig/RoomConfig'; 6 | import { mapIn } from '../../../../logic/utils'; 7 | import { anyActivityConfigInRoom, rooms } from '../../../../logic/activities'; 8 | 9 | const RoomsConfig = ({ wcif, onWcifChange }) => { 10 | const handleRoomChange = updatedRoom => { 11 | onWcifChange( 12 | mapIn(wcif, ['schedule', 'venues'], venue => 13 | mapIn(venue, ['rooms'], room => 14 | room.id === updatedRoom.id ? updatedRoom : room 15 | ) 16 | ) 17 | ); 18 | }; 19 | 20 | return ( 21 | 22 | 23 | {rooms(wcif).map(room => ( 24 | 25 | {/* Disable rooms configuration once activities config has been populated. */} 26 | 31 | 32 | ))} 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default RoomsConfig; 39 | -------------------------------------------------------------------------------- /src/components/Competition/ConfigManager/RoundActivityConfig/RoundActivityConfig.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Checkbox from '@material-ui/core/Checkbox'; 3 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import Tooltip from '@material-ui/core/Tooltip'; 6 | 7 | import PositiveIntegerInput from '../../../common/PositiveIntegerInput/PositiveIntegerInput'; 8 | import ZeroablePositiveIntegerInput from '../../../common/ZeroablePositiveIntegerInput/ZeroablePositiveIntegerInput'; 9 | import { setIn, pluralize } from '../../../../logic/utils'; 10 | import { 11 | getExtensionData, 12 | setExtensionData, 13 | } from '../../../../logic/wcif-extensions'; 14 | import { activityAssigned } from '../../../../logic/activities'; 15 | 16 | const DisabledReasonTooltip = ({ children, reasons }) => { 17 | const trueReasons = Object.keys(reasons).filter(reason => reasons[reason]); 18 | 19 | const message = 20 | trueReasons.length === 0 21 | ? '' 22 | : `Disabled for the following reasons: ${trueReasons.join(', ')}`; 23 | 24 | return ( 25 | 26 | {children} 27 | 28 | ); 29 | }; 30 | 31 | const RoundActivityConfig = React.memo( 32 | ({ activity, room, wcif, onChange, expectedCompetitors }) => { 33 | const { 34 | groups, 35 | scramblers, 36 | runners, 37 | assignJudges, 38 | capacity, 39 | } = getExtensionData('ActivityConfig', activity); 40 | const stations = getExtensionData('RoomConfig', room).stations; 41 | const competitors = Math.round(expectedCompetitors.length * capacity); 42 | const groupSize = Math.round(competitors / groups); 43 | 44 | const groupsHelperText = groups 45 | ? pluralize(groupSize, 'person', 'people') + ' in group' 46 | : ' '; 47 | const scramblersHelperText = scramblers 48 | ? pluralize(Math.round(groupSize / scramblers), 'cube') + ' per scrambler' 49 | : ' '; 50 | const runnersHelperText = runners 51 | ? pluralize( 52 | Math.round(Math.min(stations, groupSize) / runners), 53 | 'station' 54 | ) + ' per runner' 55 | : ' '; 56 | 57 | const groupsCreated = activity.childActivities.length > 0; 58 | const groupsAssigned = activity.childActivities.some(({ id }) => 59 | activityAssigned(wcif, id) 60 | ); 61 | 62 | const handlePropertyChange = (property, value) => { 63 | onChange( 64 | setExtensionData( 65 | 'ActivityConfig', 66 | activity, 67 | setIn(getExtensionData('ActivityConfig', activity), [property], value) 68 | ) 69 | ); 70 | }; 71 | 72 | const handleInputChange = (event, value) => { 73 | handlePropertyChange(event.target.name, value); 74 | }; 75 | 76 | const handleCheckboxChange = event => { 77 | const { name, checked } = event.target; 78 | handlePropertyChange(name, checked); 79 | }; 80 | 81 | return ( 82 | 83 | 84 | 90 | 99 | 100 | 101 | 102 | 105 | 114 | 115 | 116 | 117 | 120 | 129 | 130 | 131 | 132 | 138 | 146 | } 147 | label="Assign judges" 148 | /> 149 | 150 | 151 | 152 | ); 153 | } 154 | ); 155 | 156 | export default RoundActivityConfig; 157 | -------------------------------------------------------------------------------- /src/components/Competition/ConfigManager/RoundConfig/RoundConfig.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | 4 | import RoundActivityConfig from '../RoundActivityConfig/RoundActivityConfig'; 5 | import RoomName from '../../../common/RoomName/RoomName'; 6 | import { flatMap } from '../../../../logic/utils'; 7 | import { updateActivity, rooms } from '../../../../logic/activities'; 8 | 9 | const RoundConfig = React.memo( 10 | ({ roundId, wcif, onWcifChange, expectedCompetitorsByRound }) => { 11 | const activitiesWithRooms = flatMap(rooms(wcif), room => 12 | room.activities 13 | .filter(activity => activity.activityCode === roundId) 14 | .map(activity => [activity, room]) 15 | ); 16 | 17 | return ( 18 | 19 | {activitiesWithRooms.map(([activity, room]) => ( 20 | 21 | 22 | 27 | onWcifChange(updateActivity(wcif, activity)) 28 | } 29 | expectedCompetitors={expectedCompetitorsByRound[roundId]} 30 | /> 31 | 32 | ))} 33 | 34 | ); 35 | } 36 | ); 37 | 38 | export default RoundConfig; 39 | -------------------------------------------------------------------------------- /src/components/Competition/ConfigManager/RoundsConfig/RoundsConfig.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Checkbox from '@material-ui/core/Checkbox'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 6 | import Paper from '@material-ui/core/Paper'; 7 | import Typography from '@material-ui/core/Typography'; 8 | 9 | import RoundsNavigation from '../../../common/RoundsNavigation/RoundsNavigation'; 10 | import RoundConfig from '../RoundConfig/RoundConfig'; 11 | import { 12 | populateRoundActivitiesConfig, 13 | anyActivityConfig, 14 | hasDistributedAttempts, 15 | activitiesWithUnpopulatedConfig, 16 | activityCodeToName, 17 | } from '../../../../logic/activities'; 18 | import { uniq } from '../../../../logic/utils'; 19 | 20 | const RoundsConfig = ({ wcif, onWcifChange, expectedCompetitorsByRound }) => { 21 | const [options, setOptions] = useState({ 22 | assignScramblers: true, 23 | assignRunners: true, 24 | assignJudges: true, 25 | }); 26 | 27 | const handleNextClick = () => { 28 | onWcifChange( 29 | populateRoundActivitiesConfig(wcif, expectedCompetitorsByRound, options) 30 | ); 31 | }; 32 | 33 | const events = wcif.events.filter(event => !hasDistributedAttempts(event.id)); 34 | 35 | const activityCodesMissingConfig = uniq( 36 | activitiesWithUnpopulatedConfig(wcif).map(activity => activity.activityCode) 37 | ); 38 | 39 | const renderRound = useCallback( 40 | roundId => ( 41 | 47 | ), 48 | [wcif, onWcifChange, expectedCompetitorsByRound] 49 | ); 50 | 51 | return activityCodesMissingConfig.length === 0 ? ( 52 | 53 | ) : ( 54 | 55 | 56 | {anyActivityConfig(wcif) 57 | ? `Generate missing configuration for 58 | ${activityCodesMissingConfig.map(activityCodeToName).join(', ')}` 59 | : `Generate initial configuration`} 60 | 61 | 62 | 63 | 64 | Which of the following roles would you like to assign? 65 | 66 | 67 | {['Scramblers', 'Runners', 'Judges'].map(roleLabel => ( 68 | 69 | { 75 | const { checked, name } = event.target; 76 | setOptions({ ...options, [name]: checked }); 77 | }} 78 | /> 79 | } 80 | label={roleLabel} 81 | /> 82 | 83 | ))} 84 | 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default RoundsConfig; 91 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupDialog/GroupDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Dialog from '@material-ui/core/Dialog'; 4 | import DialogActions from '@material-ui/core/DialogActions'; 5 | import DialogContent from '@material-ui/core/DialogContent'; 6 | import DialogTitle from '@material-ui/core/DialogTitle'; 7 | import Grid from '@material-ui/core/Grid'; 8 | import List from '@material-ui/core/List'; 9 | import ListItem from '@material-ui/core/ListItem'; 10 | import ListItemText from '@material-ui/core/ListItemText'; 11 | import Typography from '@material-ui/core/Typography'; 12 | 13 | import { activityCodeToName } from '../../../../logic/activities'; 14 | import { sortBy } from '../../../../logic/utils'; 15 | import { 16 | assignmentCodes, 17 | assignmentName, 18 | hasAssignment, 19 | } from '../../../../logic/assignments'; 20 | 21 | const GroupDialog = ({ groupActivity, wcif, onClose }) => { 22 | const assignmentCodesWithPeople = assignmentCodes 23 | .map(assignmentCode => [ 24 | assignmentCode, 25 | wcif.persons.filter(person => 26 | hasAssignment(person, groupActivity.id, assignmentCode) 27 | ), 28 | ]) 29 | .filter(([assignmentCode, people]) => people.length > 0); 30 | 31 | return ( 32 | 33 | 34 | {activityCodeToName(groupActivity.activityCode)} 35 | 36 | 37 | 38 | {assignmentCodesWithPeople.map(([assignmentCode, people]) => ( 39 | 40 | 41 | {assignmentName(assignmentCode)}s 42 | 43 | 44 | {sortBy(people, person => person.name).map(person => ( 45 | 46 | 47 | 48 | ))} 49 | 50 | 51 | ))} 52 | {assignmentCodesWithPeople.length === 0 && ( 53 | 54 | 55 | No assignments for this round. 56 | 57 | 58 | )} 59 | 60 | 61 | 62 | 65 | 66 | 67 | ); 68 | }; 69 | 70 | export default GroupDialog; 71 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsEditor/AllDraggableCompetitors/AllDraggableCompetitors.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useMemo } from 'react'; 2 | import ListSubheader from '@material-ui/core/ListSubheader'; 3 | import { makeStyles } from '@material-ui/styles'; 4 | 5 | import DraggableCompetitor from '../DraggableCompetitor/DraggableCompetitor'; 6 | 7 | import { 8 | parseActivityCode, 9 | groupActivitiesByRound, 10 | } from '../../../../../logic/activities'; 11 | import { partition, sortByArray } from '../../../../../logic/utils'; 12 | import { 13 | competitorsForRound, 14 | acceptedPeople, 15 | bestAverageAndSingle, 16 | } from '../../../../../logic/competitors'; 17 | import { COMPETITOR_ASSIGNMENT_CODE } from '../../../../../logic/assignments'; 18 | 19 | const useStyles = makeStyles(theme => ({ 20 | opacity: { 21 | opacity: 0.5, 22 | }, 23 | listSubheader: { 24 | backgroundColor: 'inherit', 25 | }, 26 | })); 27 | 28 | const searchPeople = (people, search) => { 29 | return people.filter(person => 30 | search 31 | .toLowerCase() 32 | .split(/\s+/) 33 | .every(searchPart => person.name.toLowerCase().includes(searchPart)) 34 | ); 35 | }; 36 | 37 | const AllDraggableCompetitors = React.memo(({ wcif, roundId, search }) => { 38 | const classes = useStyles(); 39 | const { eventId } = parseActivityCode(roundId); 40 | const [withGroup, withoutGroup, otherPeople] = useMemo(() => { 41 | const groupActivityIds = groupActivitiesByRound(wcif, roundId).map( 42 | activity => activity.id 43 | ); 44 | const competitors = competitorsForRound(wcif, roundId) || []; 45 | const [withGroup, withoutGroup] = partition(competitors, competitor => 46 | competitor.assignments.some( 47 | assignment => 48 | assignment.assignmentCode === COMPETITOR_ASSIGNMENT_CODE && 49 | groupActivityIds.includes(assignment.activityId) 50 | ) 51 | ); 52 | const otherPeople = acceptedPeople(wcif).filter( 53 | person => !competitors.includes(person) 54 | ); 55 | const sortedOtherPeople = sortByArray(otherPeople, person => [ 56 | ...bestAverageAndSingle(person, eventId).map(result => -result), 57 | person.name, 58 | ]); 59 | return [withGroup, withoutGroup, sortedOtherPeople]; 60 | }, [wcif, roundId, eventId]); 61 | 62 | const withoutGroupItems = searchPeople(withoutGroup, search).map( 63 | (person, index) => ( 64 | 71 | ) 72 | ); 73 | 74 | const withGroupItems = searchPeople(withGroup, search).map( 75 | (person, index) => ( 76 | 84 | ) 85 | ); 86 | 87 | const otherPeopleItems = searchPeople(otherPeople, search).map( 88 | (person, index) => ( 89 | 97 | ) 98 | ); 99 | 100 | return ( 101 | 102 | 103 | Without group 104 | 105 | {withoutGroupItems} 106 | 107 | With group 108 | 109 | {withGroupItems} 110 | 111 | Other people 112 | 113 | {otherPeopleItems} 114 | 115 | ); 116 | }); 117 | 118 | export default AllDraggableCompetitors; 119 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsEditor/CompetitorsPanel/CompetitorsPanel.js: -------------------------------------------------------------------------------- 1 | import React, { useState, Fragment } from 'react'; 2 | import Box from '@material-ui/core/Box'; 3 | import TextField from '@material-ui/core/TextField'; 4 | import List from '@material-ui/core/List'; 5 | import { makeStyles } from '@material-ui/styles'; 6 | import { Droppable } from 'react-beautiful-dnd'; 7 | 8 | import AllDraggableCompetitors from '../AllDraggableCompetitors/AllDraggableCompetitors'; 9 | 10 | export const COMPETITORS_PANEL_DROPPABLE_ID = 'competitors'; 11 | 12 | const useStyles = makeStyles(theme => ({ 13 | list: { 14 | backgroundColor: theme.palette.background.paper, 15 | }, 16 | })); 17 | 18 | const CompetitorsPanel = React.memo(({ wcif, roundId }) => { 19 | const classes = useStyles(); 20 | const [search, setSearch] = useState(''); 21 | 22 | return ( 23 | 24 | 25 | setSearch(event.target.value)} 30 | /> 31 | 32 | 33 | {provided => ( 34 | 40 | 45 | {provided.placeholder} 46 | 47 | )} 48 | 49 | 50 | ); 51 | }); 52 | 53 | export default CompetitorsPanel; 54 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsEditor/DraggableCompetitor/DraggableCompetitor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ListItem from '@material-ui/core/ListItem'; 3 | import ListItemText from '@material-ui/core/ListItemText'; 4 | import { Draggable } from 'react-beautiful-dnd'; 5 | 6 | import { best } from '../../../../../logic/competitors'; 7 | import { centisecondsToClockFormat } from '../../../../../logic/formatters'; 8 | 9 | const DraggableCompetitor = React.memo( 10 | ({ person, draggableId, index, averageLabelEventId = null, ...props }) => { 11 | const average = 12 | averageLabelEventId && best(person, averageLabelEventId, 'average'); 13 | const averageDescription = 14 | average < Infinity ? ` (${centisecondsToClockFormat(average)})` : ''; 15 | 16 | return ( 17 | 18 | {provided => ( 19 | 26 | 27 | 28 | )} 29 | 30 | ); 31 | } 32 | ); 33 | 34 | export default DraggableCompetitor; 35 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsEditor/DraggableCompetitorAssignments/DraggableCompetitorAssignments.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import DraggableCompetitor from '../DraggableCompetitor/DraggableCompetitor'; 3 | 4 | import { parseActivityCode } from '../../../../../logic/activities'; 5 | import { bestAverageAndSingle } from '../../../../../logic/competitors'; 6 | import { sortByArray } from '../../../../../logic/utils'; 7 | import { toAssignmentKey } from '../../../../../logic/assignments'; 8 | 9 | const DraggableCompetitorAssignments = React.memo( 10 | ({ people, assignmentCode, groupActivity }) => { 11 | const { eventId } = parseActivityCode(groupActivity.activityCode); 12 | 13 | const sortedPeople = sortByArray(people, person => [ 14 | ...bestAverageAndSingle(person, eventId).map(result => -result), 15 | person.name, 16 | ]); 17 | 18 | return sortedPeople.map((person, index) => { 19 | const assignment = person.assignments.find( 20 | assignment => 21 | assignment.activityId === groupActivity.id && 22 | assignment.assignmentCode === assignmentCode 23 | ); 24 | return ( 25 | 32 | ); 33 | }); 34 | } 35 | ); 36 | 37 | export default DraggableCompetitorAssignments; 38 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsEditor/GroupActivityEditor/GroupActivityEditor.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import List from '@material-ui/core/List'; 4 | import ListSubheader from '@material-ui/core/ListSubheader'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import { Droppable } from 'react-beautiful-dnd'; 7 | 8 | import DraggableCompetitorAssignments from '../DraggableCompetitorAssignments/DraggableCompetitorAssignments'; 9 | 10 | import { activityCodeToGroupName } from '../../../../../logic/activities'; 11 | import { 12 | assignmentCodes, 13 | COMPETITOR_ASSIGNMENT_CODE, 14 | assignmentName, 15 | hasAssignment, 16 | } from '../../../../../logic/assignments'; 17 | 18 | const GroupActivityEditor = React.memo( 19 | ({ groupActivity, wcif, size }) => { 20 | const assignmentCodesWithPeople = assignmentCodes.map(assignmentCode => [ 21 | assignmentCode, 22 | wcif.persons.filter(person => 23 | hasAssignment(person, groupActivity.id, assignmentCode) 24 | ), 25 | ]); 26 | 27 | return ( 28 | 29 | 30 | {activityCodeToGroupName(groupActivity.activityCode)} 31 | 32 | 33 | {assignmentCodesWithPeople.map(([assignmentCode, people]) => ( 34 | 35 | 36 | {provided => ( 37 | 43 | {assignmentCode === COMPETITOR_ASSIGNMENT_CODE 44 | ? `${assignmentName(assignmentCode)}s (${ 45 | people.length 46 | } of ${size})` 47 | : `${assignmentName(assignmentCode)}s (${ 48 | people.length 49 | })`} 50 | 51 | } 52 | > 53 | 58 | {provided.placeholder} 59 | 60 | )} 61 | 62 | 63 | ))} 64 | 65 | 66 | ); 67 | }, 68 | (prevProps, nextProps) => { 69 | /* Consider props equal if the number of competitors with each assignment type is the same. 70 | That's a heuristic preventing from re-rendering on unrelated wcif changes. */ 71 | return assignmentCodes.every( 72 | assignmentCode => 73 | prevProps.wcif.persons.filter(person => 74 | hasAssignment(person, prevProps.groupActivity.id, assignmentCode) 75 | ).length === 76 | nextProps.wcif.persons.filter(person => 77 | hasAssignment(person, nextProps.groupActivity.id, assignmentCode) 78 | ).length 79 | ); 80 | } 81 | ); 82 | 83 | export default GroupActivityEditor; 84 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsEditor/GroupsEditor.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import AppBar from '@material-ui/core/AppBar'; 3 | import Button from '@material-ui/core/Button'; 4 | import Dialog from '@material-ui/core/Dialog'; 5 | import Grid from '@material-ui/core/Grid'; 6 | import Snackbar from '@material-ui/core/Snackbar'; 7 | import Toolbar from '@material-ui/core/Toolbar'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import { DragDropContext } from 'react-beautiful-dnd'; 10 | import { makeStyles } from '@material-ui/styles'; 11 | import classNames from 'classnames'; 12 | 13 | import CompetitorsPanel, { 14 | COMPETITORS_PANEL_DROPPABLE_ID, 15 | } from './CompetitorsPanel/CompetitorsPanel'; 16 | import GroupActivityEditor from './GroupActivityEditor/GroupActivityEditor'; 17 | 18 | import { activityCodeToName } from '../../../../logic/activities'; 19 | import { toInt } from '../../../../logic/utils'; 20 | import { competitorsForRound } from '../../../../logic/competitors'; 21 | import { 22 | newAssignmentError, 23 | updateAssignments, 24 | toAssignmentKey, 25 | } from '../../../../logic/assignments'; 26 | import { 27 | sortedGroupActivitiesWithSize, 28 | updateAssignmentStationNumbers, 29 | } from '../../../../logic/groups'; 30 | 31 | const useStyles = makeStyles(theme => ({ 32 | scrollDisabled: { 33 | overflowY: 'initial', 34 | }, 35 | fullHeightScrollable: { 36 | height: 'calc(100vh - 64px)', 37 | overflowY: 'auto', 38 | }, 39 | leftArea: { 40 | maxWidth: 250, 41 | }, 42 | rightArea: { 43 | flexGrow: 1, 44 | padding: theme.spacing(3), 45 | }, 46 | grow: { 47 | flexGrow: 1, 48 | }, 49 | })); 50 | 51 | const GroupsEditor = ({ roundId, wcif, onClose }) => { 52 | const classes = useStyles(); 53 | const [localWcif, setLocalWcif] = useState(wcif); 54 | const [errorMessage, setErrorMessage] = useState(null); 55 | 56 | const groupActivitiesWithSize = useMemo(() => { 57 | const competitors = competitorsForRound(wcif, roundId) || []; 58 | return sortedGroupActivitiesWithSize(wcif, roundId, competitors.length); 59 | }, [wcif, roundId]); 60 | 61 | const handleDragEnd = result => { 62 | const { draggableId, source, destination } = result; 63 | if (!destination || source.droppableId === destination.droppableId) return; 64 | const draggableData = draggableId.split(':'); 65 | const personId = toInt(draggableData[0]); 66 | const assignmentKey = draggableData[1]; 67 | const destinationData = destination.droppableId.split(':'); 68 | const destinationGroupActivityId = toInt(destinationData[0]); 69 | const destinationAssignmentCode = destinationData[1]; 70 | if (destination.droppableId === COMPETITORS_PANEL_DROPPABLE_ID) { 71 | setLocalWcif( 72 | updateAssignments(localWcif, personId, assignments => 73 | assignments.filter( 74 | assignment => toAssignmentKey(assignment) !== assignmentKey 75 | ) 76 | ) 77 | ); 78 | } else if (source.droppableId === COMPETITORS_PANEL_DROPPABLE_ID) { 79 | setLocalWcif( 80 | updateAssignments(localWcif, personId, assignments => { 81 | const newAssignment = { 82 | activityId: destinationGroupActivityId, 83 | assignmentCode: destinationAssignmentCode, 84 | }; 85 | const errorMessage = newAssignmentError( 86 | localWcif, 87 | assignments, 88 | newAssignment 89 | ); 90 | if (errorMessage) { 91 | setErrorMessage(errorMessage); 92 | return assignments; 93 | } else { 94 | return [...assignments, newAssignment]; 95 | } 96 | }) 97 | ); 98 | } else { 99 | setLocalWcif( 100 | updateAssignments(localWcif, personId, assignments => { 101 | const newAssignment = { 102 | activityId: destinationGroupActivityId, 103 | assignmentCode: destinationAssignmentCode, 104 | }; 105 | const otherAssignments = assignments.filter( 106 | assignment => toAssignmentKey(assignment) !== assignmentKey 107 | ); 108 | const errorMessage = newAssignmentError( 109 | localWcif, 110 | otherAssignments, 111 | newAssignment 112 | ); 113 | if (errorMessage) { 114 | setErrorMessage(errorMessage); 115 | return assignments; 116 | } else { 117 | return assignments.map(assignment => 118 | toAssignmentKey(assignment) === assignmentKey 119 | ? newAssignment 120 | : assignment 121 | ); 122 | } 123 | }) 124 | ); 125 | } 126 | }; 127 | 128 | return ( 129 | 130 | 131 | 132 | 133 | {`Editing groups for ${activityCodeToName(roundId)}`} 134 | 135 |
136 | 139 | 147 | 148 | 149 | 150 | 151 | 158 | 159 | 160 | 167 | 168 | {groupActivitiesWithSize.map(([groupActivity, size]) => ( 169 | 170 | 175 | 176 | ))} 177 | 178 | 179 | 180 | 181 | setErrorMessage(null)} 187 | /> 188 |
189 | ); 190 | }; 191 | 192 | export default GroupsEditor; 193 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsManager.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Button from '@material-ui/core/Button'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import SnackbarContent from '@material-ui/core/SnackbarContent'; 6 | import Tooltip from '@material-ui/core/Tooltip'; 7 | import pink from '@material-ui/core/colors/pink'; 8 | 9 | import GroupsNavigation from './GroupsNavigation/GroupsNavigation'; 10 | import SaveWcifButton from '../../common/SaveWcifButton/SaveWcifButton'; 11 | 12 | import { 13 | createGroupActivities, 14 | updateScrambleSetCount, 15 | assignTasks, 16 | } from '../../../logic/groups'; 17 | import { 18 | allGroupsCreated, 19 | roundsMissingAssignments, 20 | clearGroupsAndAssignmentsWithoutResults, 21 | } from '../../../logic/activities'; 22 | 23 | const GroupsManager = ({ wcif, onWcifUpdate }) => { 24 | const [localWcif, setLocalWcif] = useState(wcif); 25 | 26 | const handleCreateGroupActivities = () => { 27 | setLocalWcif(updateScrambleSetCount(createGroupActivities(localWcif))); 28 | }; 29 | 30 | const handleAssignTasks = () => { 31 | setLocalWcif(assignTasks(localWcif)); 32 | }; 33 | 34 | const handleClearGroups = () => { 35 | setLocalWcif(clearGroupsAndAssignmentsWithoutResults(localWcif)); 36 | }; 37 | 38 | const groupsCreated = allGroupsCreated(localWcif); 39 | 40 | return ( 41 | 42 | 43 | {!groupsCreated && ( 44 | 53 | Create groups 54 | 55 | } 56 | /> 57 | )} 58 | {groupsCreated && roundsMissingAssignments(localWcif).length > 0 && ( 59 | 68 | Assign tasks 69 | 70 | } 71 | /> 72 | )} 73 | 74 | 75 | 76 | 77 | 78 | 85 | 86 | 87 | 91 | 94 | 95 | 96 | 97 | 102 | 103 | 104 | ); 105 | }; 106 | 107 | export default GroupsManager; 108 | -------------------------------------------------------------------------------- /src/components/Competition/GroupsManager/GroupsNavigation/GroupsNavigation.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useCallback } from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import List from '@material-ui/core/List'; 5 | import ListItem from '@material-ui/core/ListItem'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | 8 | import RoundsNavigation from '../../../common/RoundsNavigation/RoundsNavigation'; 9 | import RoomName from '../../../common/RoomName/RoomName'; 10 | import GroupDialog from '../GroupDialog/GroupDialog'; 11 | import GroupsEditor from '../GroupsEditor/GroupsEditor'; 12 | import { 13 | activityDurationString, 14 | activityCodeToGroupName, 15 | hasDistributedAttempts, 16 | roomsWithTimezoneAndGroups, 17 | groupActivitiesByRound, 18 | clearGroupsAndAssignments, 19 | } from '../../../../logic/activities'; 20 | 21 | const GroupsNavigation = ({ wcif, onWcifChange }) => { 22 | const [openedGroupActivity, setOpenGroupActivity] = useState(null); 23 | const [editedRoundId, setEditedRoundId] = useState(null); 24 | const events = wcif.events.filter(event => !hasDistributedAttempts(event.id)); 25 | 26 | const renderRound = useCallback( 27 | roundId => ( 28 | 29 | {roomsWithTimezoneAndGroups(wcif, roundId).map( 30 | ([room, timezone, groupActivities]) => 31 | groupActivities.length > 0 && ( 32 | 33 | 34 | 35 | {groupActivities.map(groupActivity => ( 36 | setOpenGroupActivity(groupActivity)} 40 | > 41 | 50 | 51 | ))} 52 | 53 | 54 | ) 55 | )} 56 | 57 | ), 58 | [wcif] 59 | ); 60 | 61 | const renderActions = roundId => { 62 | const anyGroup = groupActivitiesByRound(wcif, roundId).length > 0; 63 | if (!anyGroup) return false; 64 | return ( 65 | <> 66 | 73 | 74 | 75 | ); 76 | }; 77 | 78 | return ( 79 | 80 | 85 | {openedGroupActivity && ( 86 | setOpenGroupActivity(null)} 90 | /> 91 | )} 92 | {editedRoundId && ( 93 | { 97 | onWcifChange(wcif); 98 | setEditedRoundId(null); 99 | }} 100 | /> 101 | )} 102 | 103 | ); 104 | }; 105 | 106 | export default GroupsNavigation; 107 | -------------------------------------------------------------------------------- /src/components/Competition/NewAssignableRoundNotification/NewAssignableRoundNotification.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import SnackbarContent from '@material-ui/core/SnackbarContent'; 4 | import pink from '@material-ui/core/colors/pink'; 5 | 6 | import { 7 | roundsMissingAssignments, 8 | activityCodeToName, 9 | rooms, 10 | } from '../../../logic/activities'; 11 | import { anyCompetitorAssignment } from '../../../logic/assignments'; 12 | import { assignTasks, createGroupActivities } from '../../../logic/groups'; 13 | import { downloadScorecards } from '../../../logic/documents/scorecards'; 14 | import { saveWcifChanges } from '../../../logic/wca-api'; 15 | 16 | const NewAssignableRoundNotification = ({ wcif, onWcifUpdate }) => { 17 | /* Don't show the notification if there are no assignments at all. */ 18 | const newRoundsToAssign = anyCompetitorAssignment(wcif) 19 | ? roundsMissingAssignments(wcif) 20 | : []; 21 | 22 | const newRoundsToAssignNames = newRoundsToAssign.map(round => 23 | activityCodeToName(round.id) 24 | ); 25 | 26 | const handleActionClick = () => { 27 | // Group activites should be created, but in case there are missing 28 | // ones, we create them. 29 | const updatedWcif = assignTasks(createGroupActivities(wcif)); 30 | /* Just make the request in the background. */ 31 | saveWcifChanges(wcif, updatedWcif); 32 | downloadScorecards( 33 | updatedWcif, 34 | newRoundsToAssign, 35 | rooms(updatedWcif), 36 | 'en' 37 | ); 38 | onWcifUpdate(updatedWcif); 39 | }; 40 | 41 | if (newRoundsToAssign.length === 0) return null; 42 | 43 | return ( 44 | 55 | Yessir! 56 | 57 | } 58 | /> 59 | ); 60 | }; 61 | 62 | export default NewAssignableRoundNotification; 63 | -------------------------------------------------------------------------------- /src/components/Competition/PrintingManager/CompetitorCards/CompetitorCards.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Checkbox from '@material-ui/core/Checkbox'; 4 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 5 | import Grid from '@material-ui/core/Grid'; 6 | import Paper from '@material-ui/core/Paper'; 7 | import Typography from '@material-ui/core/Typography'; 8 | 9 | import { downloadCompetitorCards } from '../../../../logic/documents/competitor-cards'; 10 | 11 | const CompetitorCards = ({ wcif }) => { 12 | const [evenlySpaced, setEvenlySpaced] = useState(false); 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | First round task assignments for every competitor. Useful to be 20 | distributed along with name tags. 21 | 22 | 23 | 24 | setEvenlySpaced(!evenlySpaced)} 30 | /> 31 | } 32 | label="Evenly distribute cards (4 per page)" 33 | /> 34 | 35 | 36 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default CompetitorCards; 46 | -------------------------------------------------------------------------------- /src/components/Competition/PrintingManager/PrintingManager.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Button from '@material-ui/core/Button'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import SnackbarContent from '@material-ui/core/SnackbarContent'; 6 | import Tab from '@material-ui/core/Tab'; 7 | import Tabs from '@material-ui/core/Tabs'; 8 | import pink from '@material-ui/core/colors/pink'; 9 | 10 | import Scorecards from './Scorecards/Scorecards'; 11 | import CompetitorCards from './CompetitorCards/CompetitorCards'; 12 | import { 13 | roundsMissingAssignments, 14 | activityCodeToName, 15 | } from '../../../logic/activities'; 16 | 17 | const PrintingManager = ({ wcif }) => { 18 | const [tabValue, setTabValue] = useState(0); 19 | 20 | const roundsMissingAssignmentsNames = roundsMissingAssignments( 21 | wcif 22 | ).map(round => activityCodeToName(round.id)); 23 | 24 | return ( 25 | 26 | 27 | {roundsMissingAssignmentsNames.length > 0 && ( 28 | 42 | Manage groups 43 | 44 | } 45 | /> 46 | )} 47 | 48 | 49 | setTabValue(value)}> 50 | 51 | 52 | 53 | 54 | 55 | {tabValue === 0 && } 56 | {tabValue === 1 && } 57 | 58 | 59 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default PrintingManager; 73 | -------------------------------------------------------------------------------- /src/components/Competition/PrintingManager/Scorecards/Scorecards.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Checkbox from '@material-ui/core/Checkbox'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import List from '@material-ui/core/List'; 6 | import ListItem from '@material-ui/core/ListItem'; 7 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 8 | import ListItemText from '@material-ui/core/ListItemText'; 9 | import Paper from '@material-ui/core/Paper'; 10 | import Typography from '@material-ui/core/Typography'; 11 | import Select from '@material-ui/core/Select'; 12 | import MenuItem from '@material-ui/core/MenuItem'; 13 | import FormControl from '@material-ui/core/FormControl'; 14 | import InputLabel from '@material-ui/core/InputLabel'; 15 | 16 | import CubingIcon from '../../../common/CubingIcon/CubingIcon'; 17 | import { 18 | downloadScorecards, 19 | downloadBlankScorecards, 20 | } from '../../../../logic/documents/scorecards'; 21 | import { downloadGroupOverview } from '../../../../logic/documents/group-overview'; 22 | import { 23 | roundsWithoutResults, 24 | roundsMissingScorecards, 25 | parseActivityCode, 26 | activityCodeToName, 27 | rooms, 28 | } from '../../../../logic/activities'; 29 | import { difference, sortBy } from '../../../../logic/utils'; 30 | import languageInfo from '../../../../logic/translations'; 31 | 32 | const Scorecards = ({ wcif }) => { 33 | const missingScorecards = roundsMissingScorecards(wcif); 34 | const [selectedRounds, setSelectedRounds] = useState( 35 | missingScorecards.every( 36 | round => parseActivityCode(round.id).roundNumber === 1 37 | ) 38 | ? missingScorecards 39 | : [] 40 | ); 41 | const rounds = sortBy( 42 | roundsWithoutResults(wcif).filter( 43 | round => parseActivityCode(round.id).eventId !== '333fm' 44 | ), 45 | round => parseActivityCode(round.id).roundNumber 46 | ); 47 | 48 | const handleRoundClick = round => { 49 | setSelectedRounds( 50 | selectedRounds.includes(round) 51 | ? difference(selectedRounds, [round]) 52 | : [...selectedRounds, round] 53 | ); 54 | }; 55 | 56 | const allRooms = rooms(wcif); 57 | 58 | const [selectedRooms, setSelectedRooms] = useState(allRooms); 59 | 60 | const handleRoomClick = room => { 61 | setSelectedRooms( 62 | selectedRooms.includes(room) 63 | ? difference(selectedRooms, [room]) 64 | : [...selectedRooms, room] 65 | ); 66 | }; 67 | 68 | const isSelectionEmpty = 69 | selectedRounds.length === 0 || selectedRooms.length === 0; 70 | 71 | const [language, setLanguage] = useState('en'); 72 | 73 | return ( 74 | 75 | 76 | 77 | Select rounds 78 | 79 | {rounds.map(round => ( 80 | handleRoundClick(round)} 84 | style={ 85 | missingScorecards.includes(round) ? {} : { opacity: 0.5 } 86 | } 87 | > 88 | 89 | 90 | 91 | 92 | 98 | 99 | ))} 100 | 101 | 102 | {allRooms.length > 1 && ( 103 | 104 | Select rooms 105 | 106 | {allRooms.map(room => ( 107 | handleRoomClick(room)} 111 | style={selectedRooms.includes(room) ? {} : { opacity: 0.5 }} 112 | > 113 | 114 | 120 | 121 | ))} 122 | 123 | 124 | )} 125 | 126 | 127 | 128 | 129 | Scorecards language 130 | 143 | 144 | 145 | 146 | 147 | 148 | 156 | 157 | 158 | 166 | 167 | 168 | 169 | 172 | 173 | 174 | 175 | ); 176 | }; 177 | 178 | export default Scorecards; 179 | -------------------------------------------------------------------------------- /src/components/Competition/RolesManager/RolesManager.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Button from '@material-ui/core/Button'; 4 | import Checkbox from '@material-ui/core/Checkbox'; 5 | import Grid from '@material-ui/core/Grid'; 6 | import Paper from '@material-ui/core/Paper'; 7 | import Table from '@material-ui/core/Table'; 8 | import TableBody from '@material-ui/core/TableBody'; 9 | import TableCell from '@material-ui/core/TableCell'; 10 | import TableHead from '@material-ui/core/TableHead'; 11 | import TablePagination from '@material-ui/core/TablePagination'; 12 | import TableRow from '@material-ui/core/TableRow'; 13 | import TextField from '@material-ui/core/TextField'; 14 | import Toolbar from '@material-ui/core/Toolbar'; 15 | import Tooltip from '@material-ui/core/Tooltip'; 16 | import Typography from '@material-ui/core/Typography'; 17 | import InfoIcon from '@material-ui/icons/Info'; 18 | 19 | import SaveWcifButton from '../../common/SaveWcifButton/SaveWcifButton'; 20 | import { acceptedPeople } from '../../../logic/competitors'; 21 | import { difference, sortBy } from '../../../logic/utils'; 22 | 23 | const roles = [ 24 | { id: 'staff-scrambler', name: 'Scrambler' }, 25 | { id: 'staff-judge', name: 'Judge' }, 26 | { id: 'staff-runner', name: 'Runner' }, 27 | { id: 'staff-dataentry', name: 'Data entry' }, 28 | // Someone with any other role that should be skipped during 29 | // task assignment whenever possible 30 | { id: 'staff-other', name: 'Other staff' }, 31 | ]; 32 | 33 | const RolesManager = ({ wcif, onWcifUpdate }) => { 34 | const [localWcif, setLocalWcif] = useState(wcif); 35 | const [page, setPage] = useState(0); 36 | const [rowsPerPage, setRowsPerPage] = useState(5); 37 | const [searchString, setSearchString] = useState(''); 38 | 39 | const allSortedPeople = sortBy( 40 | acceptedPeople(localWcif), 41 | person => person.name 42 | ); 43 | const people = !searchString 44 | ? allSortedPeople 45 | : allSortedPeople.filter(person => 46 | searchString 47 | .split(/\s*,\s*/) 48 | .some( 49 | searchPart => 50 | searchPart && person.name.match(new RegExp(searchPart, 'i')) 51 | ) 52 | ); 53 | 54 | const handlePageChange = (event, newPage) => { 55 | setPage(newPage); 56 | }; 57 | 58 | const handleRowsPerPageChange = event => { 59 | setRowsPerPage(parseInt(event.target.value, 10)); 60 | setPage(0); 61 | }; 62 | 63 | const handleSearchStringChange = event => { 64 | setSearchString(event.target.value); 65 | setPage(0); 66 | }; 67 | 68 | const handleRoleChange = (roleId, personWcaUserId, event) => { 69 | const { checked } = event.target; 70 | setLocalWcif({ 71 | ...localWcif, 72 | persons: localWcif.persons.map(person => 73 | person.wcaUserId === personWcaUserId 74 | ? { 75 | ...person, 76 | roles: checked 77 | ? [...person.roles, roleId] 78 | : difference(person.roles, [roleId]), 79 | } 80 | : person 81 | ), 82 | }); 83 | }; 84 | 85 | const clearRoles = () => { 86 | const roleIds = roles.map(role => role.id); 87 | setLocalWcif({ 88 | ...localWcif, 89 | persons: localWcif.persons.map(person => ({ 90 | ...person, 91 | roles: difference(person.roles, roleIds), 92 | })), 93 | }); 94 | }; 95 | 96 | return ( 97 | 98 | 99 | 100 | 101 | 102 | 103 | handleSearchStringChange(event)} 107 | /> 108 | 109 | 110 | 114 | 115 | 116 | 117 | 118 | 119 |
120 | 121 | 122 | 123 | Person 124 | {roles.map(role => ( 125 | 131 | {role.name} 132 | 133 | ))} 134 | 135 | 136 | 137 | {people 138 | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) 139 | .map(person => ( 140 | 141 | {person.name} 142 | {roles.map(role => ( 143 | 148 | 156 | 157 | ))} 158 | 159 | ))} 160 | 161 |
162 |
163 | 172 |
173 |
174 | 175 | 176 | {`Use this section only if you have a group of staff among competitors. 177 | People with the given role will be prioritized during task assignment.`} 178 | 179 | 180 | {`Note: if you don't set any roles, people will still be assigned tasks if configured to.`} 181 | 182 | 183 | 184 | 191 | 192 | 193 | 196 | 197 | 198 | 203 | 204 |
205 | ); 206 | }; 207 | 208 | export default RolesManager; 209 | -------------------------------------------------------------------------------- /src/components/CompetitionList/CompetitionList.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import LinearProgress from '@material-ui/core/LinearProgress'; 4 | import List from '@material-ui/core/List'; 5 | import ListItem from '@material-ui/core/ListItem'; 6 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 7 | import ListItemText from '@material-ui/core/ListItemText'; 8 | import ListSubheader from '@material-ui/core/ListSubheader'; 9 | import Paper from '@material-ui/core/Paper'; 10 | import ErrorIcon from '@material-ui/icons/Error'; 11 | import SentimentVeryDissatisfiedIcon from '@material-ui/icons/SentimentVeryDissatisfied'; 12 | 13 | import { getUpcomingManageableCompetitions } from '../../logic/wca-api'; 14 | import { sortBy } from '../../logic/utils'; 15 | 16 | const CompetitionList = () => { 17 | const [competitions, setCompetitions] = useState([]); 18 | const [loading, setLoading] = useState(true); 19 | const [error, setError] = useState(null); 20 | 21 | useEffect(() => { 22 | getUpcomingManageableCompetitions() 23 | .then(competitions => { 24 | setCompetitions( 25 | sortBy(competitions, competition => competition['start_date']) 26 | ); 27 | }) 28 | .catch(error => setError(error.message)) 29 | .finally(() => setLoading(false)); 30 | }, []); 31 | 32 | return ( 33 | 34 | Your competitions}> 35 | {error && ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | )} 43 | {!loading && !error && competitions.length === 0 && ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | )} 51 | {competitions.map(competition => ( 52 | 58 | 59 | 60 | ))} 61 | 62 | {loading && } 63 | 64 | ); 65 | }; 66 | 67 | export default CompetitionList; 68 | -------------------------------------------------------------------------------- /src/components/CompetitionList/CompetitionList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | import LinearProgress from '@material-ui/core/LinearProgress'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | 8 | import CompetitionList from './CompetitionList'; 9 | import { getUpcomingManageableCompetitions } from '../../logic/wca-api'; 10 | 11 | jest.mock('../../logic/wca-api'); 12 | 13 | describe('CompetitionList', () => { 14 | it('renders linear progress until competitions are fetched', async () => { 15 | const competitions = Promise.resolve([]); 16 | getUpcomingManageableCompetitions.mockReturnValue(competitions); 17 | let wrapper; 18 | await act(async () => { 19 | wrapper = mount(); 20 | }); 21 | expect(wrapper.contains()).toEqual(true); 22 | act(() => { 23 | wrapper.update(); 24 | }); 25 | expect(wrapper.contains()).toEqual(false); 26 | }); 27 | 28 | it('renders appropriate message if there are no competitions', async () => { 29 | const competitions = Promise.resolve([]); 30 | getUpcomingManageableCompetitions.mockReturnValue(competitions); 31 | let wrapper; 32 | await act(async () => { 33 | wrapper = mount(); 34 | }); 35 | act(() => { 36 | wrapper.update(); 37 | }); 38 | expect( 39 | wrapper.contains( 40 | 41 | ) 42 | ).toEqual(true); 43 | }); 44 | 45 | it('renders list of competitions if there are any', async () => { 46 | const competitions = Promise.resolve([ 47 | { id: 'Example2018', name: 'Example 2018' }, 48 | ]); 49 | getUpcomingManageableCompetitions.mockReturnValue(competitions); 50 | let wrapper; 51 | await act(async () => { 52 | wrapper = mount( 53 | 54 | 55 | 56 | ); 57 | }); 58 | act(() => { 59 | wrapper.update(); 60 | }); 61 | expect(wrapper.contains()).toEqual( 62 | true 63 | ); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import Link from '@material-ui/core/Link'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import red from '@material-ui/core/colors/red'; 6 | import grey from '@material-ui/core/colors/grey'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import FavoriteIcon from '@material-ui/icons/Favorite'; 9 | 10 | import { version } from '../../../package.json'; 11 | 12 | const useStyles = makeStyles(theme => ({ 13 | root: { 14 | padding: theme.spacing(2), 15 | }, 16 | icon: { 17 | verticalAlign: 'middle', 18 | color: red[700], 19 | }, 20 | grow: { 21 | flexGrow: 1, 22 | }, 23 | link: { 24 | verticalAlign: 'middle', 25 | fontWeight: 500, 26 | color: grey['900'], 27 | '&:hover': { 28 | textDecoration: 'none', 29 | opacity: 0.7, 30 | }, 31 | }, 32 | })); 33 | 34 | const links = [ 35 | { 36 | text: 'Guide', 37 | url: 'https://github.com/jonatanklosko/groupifier/wiki/Guide', 38 | }, 39 | { text: 'GitHub', url: 'https://github.com/jonatanklosko/groupifier' }, 40 | { text: 'Contact', url: 'mailto:jonatanklosko@gmail.com' }, 41 | { 42 | text: `v${version}`, 43 | url: 'https://github.com/jonatanklosko/groupifier', 44 | }, 45 | ]; 46 | 47 | const Footer = () => { 48 | const classes = useStyles(); 49 | return ( 50 | 51 | 52 | 53 | Made with by{' '} 54 | 60 | Jonatan Kłosko 61 | 62 | 63 | 64 | 65 | 66 | 67 | {links.map(({ text, url }) => ( 68 | 69 | 76 | {text} 77 | 78 | 79 | ))} 80 | 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default Footer; 87 | -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import AppBar from '@material-ui/core/AppBar'; 4 | import Button from '@material-ui/core/Button'; 5 | import Toolbar from '@material-ui/core/Toolbar'; 6 | import Typography from '@material-ui/core/Typography'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import PeopleIcon from '@material-ui/icons/People'; 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | title: { 12 | flexGrow: 1, 13 | }, 14 | titleLink: { 15 | color: 'inherit', 16 | textDecoration: 'none', 17 | }, 18 | titleIcon: { 19 | fontSize: '1.5em', 20 | verticalAlign: 'middle', 21 | marginRight: theme.spacing(1), 22 | }, 23 | })); 24 | 25 | const Header = ({ isSignedIn, onSignIn, onSignOut }) => { 26 | const classes = useStyles(); 27 | return ( 28 | 29 | 30 | 31 | 35 | 36 | Groupifier 37 | 38 | 39 | {isSignedIn ? ( 40 | 43 | ) : ( 44 | 47 | )} 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default Header; 54 | -------------------------------------------------------------------------------- /src/components/Header/Header.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Link } from 'react-router-dom'; 4 | import Button from '@material-ui/core/Button'; 5 | 6 | import Header from './Header'; 7 | 8 | describe('Header', () => { 9 | describe('given isSignedIn of false', () => { 10 | it('renders sign in button', () => { 11 | const wrapper = shallow(
); 12 | expect(wrapper.find(Button).contains('Sign in')).toEqual(true); 13 | }); 14 | 15 | it('renders link to homepage', () => { 16 | const wrapper = shallow(
); 17 | expect(wrapper.find(Link).prop('to')).toEqual('/'); 18 | }); 19 | 20 | it('calls onSignIn once button is clicked', () => { 21 | const handleSignIn = jest.fn(); 22 | const wrapper = shallow( 23 |
24 | ); 25 | wrapper.find('[children="Sign in"]').simulate('click'); 26 | expect(handleSignIn.mock.calls.length).toEqual(1); 27 | }); 28 | }); 29 | 30 | describe('given isSignedIn of true', () => { 31 | it('renders sign out button', () => { 32 | const wrapper = shallow(
); 33 | expect(wrapper.find(Button).contains('Sign out')).toEqual(true); 34 | }); 35 | 36 | it('renders link to competitions list', () => { 37 | const wrapper = shallow(
); 38 | expect(wrapper.find(Link).prop('to')).toEqual('/competitions'); 39 | }); 40 | 41 | it('calls onSignOut once button is clicked', () => { 42 | const handleSignOut = jest.fn(); 43 | const wrapper = shallow( 44 |
45 | ); 46 | wrapper.find('[children="Sign out"]').simulate('click'); 47 | expect(handleSignOut.mock.calls.length).toEqual(1); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/components/Home/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import List from '@material-ui/core/List'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import Typography from '@material-ui/core/Typography'; 8 | import CheckIcon from '@material-ui/icons/Check'; 9 | 10 | const features = [ 11 | 'Allows for marking competitors as different kinds of staff and handles them in a special manner.', 12 | 'Supports multiple rooms/stages running simultaneously.', 13 | 'Suggests number of groups and necessary roles, while leaving the final decision to the user.', 14 | 'Once configured, creates groups and does its best to optimally assign people to these groups.', 15 | 'Generates documents like scorecards and competitor cards with task assignments.', 16 | ]; 17 | 18 | const Home = () => ( 19 | 20 | 21 | What is Groupifier? 22 | 23 | 24 | 25 | {`Task and group management tool for WCA competition organizers. It's 26 | designed to be highly customizable and work well with complex schedules.`} 27 | 28 | 29 | 30 | What does it do? 31 | 32 | 33 | 34 | {features.map(feature => ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | ))} 42 | 43 | 44 | 45 | ); 46 | 47 | export default Home; 48 | -------------------------------------------------------------------------------- /src/components/common/CubingIcon/CubingIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const CubingIcon = ({ eventId, ...props }) => ( 4 | 9 | ); 10 | 11 | export default CubingIcon; 12 | -------------------------------------------------------------------------------- /src/components/common/EventSelect/EventSelect.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Tooltip from '@material-ui/core/Tooltip'; 3 | import IconButton from '@material-ui/core/IconButton'; 4 | 5 | import CubingIcon from '../CubingIcon/CubingIcon'; 6 | import { eventNameById } from '../../../logic/events'; 7 | 8 | const EventSelect = ({ events, selected, onChange }) => { 9 | return ( 10 |
11 | {events.map(event => ( 12 | 13 | onChange(event.id)}> 14 | 18 | 19 | 20 | ))} 21 |
22 | ); 23 | }; 24 | 25 | export default EventSelect; 26 | -------------------------------------------------------------------------------- /src/components/common/PositiveIntegerInput/PositiveIntegerInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TextField from '@material-ui/core/TextField'; 3 | 4 | const PositiveIntegerInput = React.memo( 5 | React.forwardRef(({ value, onChange, ...props }, ref) => { 6 | /* Prevent from entering characters like minus, plus and dot. 7 | These don't trigger the change event for numeric input. */ 8 | const handleKeyPress = event => { 9 | if (event.key.match(/\D/)) event.preventDefault(); 10 | }; 11 | 12 | const handleTextFieldChange = event => { 13 | const { value } = event.target; 14 | const newValue = value.length > 0 ? parseInt(value, 10) : null; 15 | if (newValue === null || (!Number.isNaN(newValue) && newValue >= 1)) { 16 | onChange(event, newValue); 17 | } 18 | }; 19 | 20 | return ( 21 | 29 | ); 30 | }) 31 | ); 32 | 33 | export default PositiveIntegerInput; 34 | -------------------------------------------------------------------------------- /src/components/common/RoomName/RoomName.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@material-ui/core/Typography'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | const useStyles = makeStyles(theme => ({ 6 | roomDot: { 7 | display: 'inline-block', 8 | width: 10, 9 | height: 10, 10 | marginRight: 5, 11 | borderRadius: '100%', 12 | }, 13 | })); 14 | 15 | const RoomName = ({ room }) => { 16 | const classes = useStyles(); 17 | return ( 18 | 19 | 23 | {room.name} 24 | 25 | ); 26 | }; 27 | 28 | export default RoomName; 29 | -------------------------------------------------------------------------------- /src/components/common/RoundsNavigation/RoundPanel/RoundPanel.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import Divider from '@material-ui/core/Divider'; 3 | import ExpansionPanel from '@material-ui/core/ExpansionPanel'; 4 | import ExpansionPanelDetails from '@material-ui/core/ExpansionPanelDetails'; 5 | import ExpansionPanelSummary from '@material-ui/core/ExpansionPanelSummary'; 6 | import ExpansionPanelActions from '@material-ui/core/ExpansionPanelActions'; 7 | import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; 8 | import Typography from '@material-ui/core/Typography'; 9 | 10 | import { activityCodeToName } from '../../../../logic/activities'; 11 | 12 | const RoundPanel = ({ 13 | expanded, 14 | onChange, 15 | roundId, 16 | render, 17 | renderActions = null, 18 | }) => { 19 | const scrollToElement = node => { 20 | /* Node is the panel content, so the panel is its parent. */ 21 | window.scrollTo({ 22 | top: node.parentNode.getBoundingClientRect().top - 8, 23 | behavior: 'smooth', 24 | }); 25 | }; 26 | 27 | const actions = renderActions && renderActions(roundId); 28 | 29 | return ( 30 | onChange(expanded ? roundId : null)} 32 | expanded={expanded} 33 | TransitionProps={{ onEntered: scrollToElement }} 34 | > 35 | }> 36 | 37 | {activityCodeToName(roundId)} 38 | 39 | 40 | 41 | {expanded && render(roundId)} 42 | 43 | {actions && ( 44 | 45 | 46 | {actions} 47 | 48 | )} 49 | 50 | ); 51 | }; 52 | 53 | export default RoundPanel; 54 | -------------------------------------------------------------------------------- /src/components/common/RoundsNavigation/RoundsNavigation.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | 4 | import EventSelect from '../../common/EventSelect/EventSelect'; 5 | import RoundPanel from './RoundPanel/RoundPanel'; 6 | 7 | const RoundsNavigation = ({ events, render, renderActions = null }) => { 8 | const [selectedEventId, setSelectedEventId] = useState(events[0].id); 9 | const [expandedRoundId, setExpandedRoundId] = useState(null); 10 | 11 | const selectedEvent = events.find(event => event.id === selectedEventId); 12 | 13 | return ( 14 | 15 | 16 | 21 | 22 | 23 | {selectedEvent.rounds.map(round => ( 24 | 32 | ))} 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default RoundsNavigation; 39 | -------------------------------------------------------------------------------- /src/components/common/SaveWcifButton/SaveWcifButton.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import Button from '@material-ui/core/Button'; 4 | import CircularProgress from '@material-ui/core/CircularProgress'; 5 | import Snackbar from '@material-ui/core/Snackbar'; 6 | 7 | import { saveWcifChanges } from '../../../logic/wca-api'; 8 | 9 | const SaveWcifButton = ({ 10 | wcif, 11 | updatedWcif, 12 | onWcifUpdate, 13 | disabled, 14 | history, 15 | }) => { 16 | const [saving, setSaving] = useState(false); 17 | const [failed, setFailed] = useState(false); 18 | 19 | const handleSaveClick = () => { 20 | setSaving(true); 21 | setFailed(false); 22 | saveWcifChanges(wcif, updatedWcif) 23 | .then(() => { 24 | onWcifUpdate(updatedWcif); 25 | history.push(`/competitions/${updatedWcif.id}`); 26 | }) 27 | .catch(() => { 28 | setSaving(false); 29 | setFailed(true); 30 | }); 31 | }; 32 | 33 | return ( 34 | 35 | setFailed(false)} 44 | /> 45 | 56 | 57 | ); 58 | }; 59 | 60 | export default withRouter(SaveWcifButton); 61 | -------------------------------------------------------------------------------- /src/components/common/ZeroablePositiveIntegerInput/ZeroablePositiveIntegerInput.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import Checkbox from '@material-ui/core/Checkbox'; 3 | 4 | import PositiveIntegerInput from '../PositiveIntegerInput/PositiveIntegerInput'; 5 | 6 | const ZeroablePositiveIntegerInput = React.memo( 7 | React.forwardRef(({ value, disabled, ...props }, ref) => { 8 | const inputRef = useRef(null); 9 | const shouldFocusRef = useRef(false); 10 | 11 | const handleCheckboxChange = event => { 12 | const { checked } = event.target; 13 | props.onChange(event, checked ? null : 0); 14 | if (checked) { 15 | shouldFocusRef.current = true; 16 | } 17 | }; 18 | 19 | useEffect(() => { 20 | if (value === null && shouldFocusRef.current) { 21 | inputRef.current.focus(); 22 | shouldFocusRef.current = false; 23 | } 24 | }, [value]); 25 | 26 | return ( 27 |
28 | 35 | 41 |
42 | ); 43 | }) 44 | ); 45 | 46 | export default ZeroablePositiveIntegerInput; 47 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonatanklosko/groupifier/a00eb36645372adfe0a801605fab388e11a4eb0c/src/index.css -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './polyfills'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | import '@cubing/icons'; 6 | 7 | import './index.css'; 8 | import App from './components/App/App'; 9 | import { unregister as unregisterServiceWorker } from './registerServiceWorker'; 10 | import { initializeAuth } from './logic/auth'; 11 | 12 | initializeAuth(); 13 | ReactDOM.render(, document.getElementById('root')); 14 | unregisterServiceWorker(); 15 | -------------------------------------------------------------------------------- /src/logic/activities.js: -------------------------------------------------------------------------------- 1 | import { 2 | mapIn, 3 | updateIn, 4 | setIn, 5 | flatMap, 6 | zip, 7 | scaleToOne, 8 | shortTime, 9 | isPresentDeep, 10 | } from './utils'; 11 | import { getExtensionData, setExtensionData } from './wcif-extensions'; 12 | import { 13 | suggestedGroupCount, 14 | suggestedScramblerCount, 15 | suggestedRunnerCount, 16 | } from './formulas'; 17 | import { eventNameById } from './events'; 18 | 19 | export const parseActivityCode = activityCode => { 20 | const [, e, r, g, a] = activityCode.match( 21 | /(\w+)(?:-r(\d+))?(?:-g(\d+))?(?:-a(\d+))?/ 22 | ); 23 | return { 24 | eventId: e, 25 | roundNumber: r && parseInt(r, 10), 26 | groupNumber: g && parseInt(g, 10), 27 | attemptNumber: a && parseInt(a, 10), 28 | }; 29 | }; 30 | 31 | export const activityCodeToName = activityCode => { 32 | const { 33 | eventId, 34 | roundNumber, 35 | groupNumber, 36 | attemptNumber, 37 | } = parseActivityCode(activityCode); 38 | return [ 39 | eventId && eventNameById(eventId), 40 | roundNumber && `Round ${roundNumber}`, 41 | groupNumber && `Group ${groupNumber}`, 42 | attemptNumber && `Attempt ${attemptNumber}`, 43 | ] 44 | .filter(x => x) 45 | .join(', '); 46 | }; 47 | 48 | export const activityCodeToGroupName = activityCode => { 49 | const { groupNumber } = parseActivityCode(activityCode); 50 | return groupNumber ? `Group ${groupNumber}` : ''; 51 | }; 52 | 53 | export const sameRoundActivityCode = (activityCode1, activityCode2) => { 54 | const code1 = parseActivityCode(activityCode1); 55 | const code2 = parseActivityCode(activityCode2); 56 | return ( 57 | code1.eventId && 58 | code2.eventId && 59 | code1.eventId === code2.eventId && 60 | code1.roundNumber && 61 | code2.roundNumber && 62 | code1.roundNumber === code2.roundNumber 63 | ); 64 | }; 65 | 66 | export const hasDistributedAttempts = activityCode => 67 | ['333fm', '333mbf'].includes(parseActivityCode(activityCode).eventId); 68 | 69 | export const activityDuration = ({ startTime, endTime }) => 70 | new Date(endTime) - new Date(startTime); 71 | 72 | export const activityDurationString = ( 73 | { startTime, endTime }, 74 | timezone = 'UTC' 75 | ) => `${shortTime(startTime, timezone)} - ${shortTime(endTime, timezone)}`; 76 | 77 | export const activitiesOverlap = (first, second) => 78 | new Date(first.startTime) < new Date(second.endTime) && 79 | new Date(second.startTime) < new Date(first.endTime); 80 | 81 | export const activitiesIntersection = (first, second) => { 82 | if (!activitiesOverlap(first, second)) return 0; 83 | const [, middleStart, middleEnd] = [ 84 | first.startTime, 85 | first.endTime, 86 | second.startTime, 87 | second.endTime, 88 | ].sort(); 89 | /* Time distance between the two middle points in time. */ 90 | return new Date(middleEnd) - new Date(middleStart); 91 | }; 92 | 93 | export const rooms = wcif => 94 | flatMap(wcif.schedule.venues, venue => venue.rooms); 95 | 96 | export const roomByActivity = (wcif, activityId) => 97 | rooms(wcif).find(room => 98 | room.activities.some(activity => hasActivity(activity, activityId)) 99 | ); 100 | 101 | const hasActivity = (activity, activityId) => 102 | activity.id === activityId || 103 | activity.childActivities.some(childActivity => 104 | hasActivity(childActivity, activityId) 105 | ); 106 | 107 | export const stationsByActivity = (wcif, activityId) => 108 | getExtensionData('RoomConfig', roomByActivity(wcif, activityId)).stations; 109 | 110 | const allActivities = wcif => { 111 | const allChildActivities = ({ childActivities }) => 112 | childActivities.length > 0 113 | ? [...childActivities, ...flatMap(childActivities, allChildActivities)] 114 | : childActivities; 115 | const activities = flatMap(rooms(wcif), room => room.activities); 116 | return [...activities, ...flatMap(activities, allChildActivities)]; 117 | }; 118 | 119 | export const maxActivityId = wcif => 120 | Math.max(...allActivities(wcif).map(activity => activity.id)); 121 | 122 | /* Assigning tasks invokes activityById enormous number of times. 123 | But during that process activities (schedule) don't change. 124 | Caching is gives an invaluable speed boost in this case. */ 125 | const activitiesByIdCachedBySchedule = new Map(); 126 | 127 | export const activityById = (wcif, activityId) => { 128 | if (activitiesByIdCachedBySchedule.has(wcif.schedule)) { 129 | return activitiesByIdCachedBySchedule.get(wcif.schedule).get(activityId); 130 | } else { 131 | const activities = allActivities(wcif); 132 | const activitiesById = new Map( 133 | activities.map(activity => [activity.id, activity]) 134 | ); 135 | activitiesByIdCachedBySchedule.set(wcif.schedule, activitiesById); 136 | return activitiesById.get(activityId); 137 | } 138 | }; 139 | 140 | export const updateActivity = (wcif, updatedActivity) => 141 | mapIn(wcif, ['schedule', 'venues'], venue => 142 | mapIn(venue, ['rooms'], room => 143 | mapIn(room, ['activities'], activity => 144 | activity.id === updatedActivity.id ? updatedActivity : activity 145 | ) 146 | ) 147 | ); 148 | 149 | export const populateRoundActivitiesConfig = ( 150 | wcif, 151 | expectedCompetitorsByRound, 152 | defaults 153 | ) => { 154 | const activitiesWithConfig = flatMap(wcif.events, event => { 155 | return flatMap(event.rounds, round => { 156 | const { roundNumber } = parseActivityCode(round.id); 157 | const expectedRoundCompetitors = 158 | expectedCompetitorsByRound[round.id].length; 159 | const activities = roundActivities(wcif, round.id).filter( 160 | shouldHaveGroups 161 | ); 162 | const alreadyHaveConfig = activities.every(activity => 163 | getExtensionData('ActivityConfig', activity) 164 | ); 165 | if (alreadyHaveConfig) return activities; 166 | const capacities = scaleToOne( 167 | activities.map( 168 | activity => 169 | stationsByActivity(wcif, activity.id) * activityDuration(activity) 170 | ) 171 | ); 172 | return zip(activities, capacities).map(([activity, capacity]) => { 173 | const stations = stationsByActivity(wcif, activity.id); 174 | const competitors = Math.round(capacity * expectedRoundCompetitors); 175 | const groups = suggestedGroupCount(competitors, stations, roundNumber); 176 | const scramblers = defaults.assignScramblers 177 | ? suggestedScramblerCount(competitors / groups, stations) 178 | : 0; 179 | const runners = defaults.assignRunners 180 | ? suggestedRunnerCount(competitors / groups, stations) 181 | : 0; 182 | const assignJudges = stations > 0 && defaults.assignJudges; 183 | return setExtensionData('ActivityConfig', activity, { 184 | capacity, 185 | groups, 186 | scramblers, 187 | runners, 188 | assignJudges, 189 | }); 190 | }); 191 | }); 192 | }); 193 | return activitiesWithConfig.reduce(updateActivity, wcif); 194 | }; 195 | 196 | export const shouldHaveGroups = activity => { 197 | const { 198 | eventId, 199 | roundNumber, 200 | groupNumber, 201 | attemptNumber, 202 | } = parseActivityCode(activity.activityCode); 203 | return !!(eventId && roundNumber && !groupNumber && !attemptNumber); 204 | }; 205 | 206 | export const anyActivityConfig = wcif => 207 | rooms(wcif).some(anyActivityConfigInRoom); 208 | 209 | export const anyActivityConfigInRoom = room => 210 | room.activities.some(activity => 211 | getExtensionData('ActivityConfig', activity) 212 | ); 213 | 214 | export const activitiesWithUnpopulatedConfig = wcif => 215 | flatMap(rooms(wcif), room => 216 | room.activities 217 | .filter(shouldHaveGroups) 218 | .filter(activity => !getExtensionData('ActivityConfig', activity)) 219 | ); 220 | 221 | export const activitiesConfigComplete = wcif => 222 | rooms(wcif).every(room => 223 | room.activities 224 | .filter(shouldHaveGroups) 225 | .map(activity => getExtensionData('ActivityConfig', activity)) 226 | .every(isPresentDeep) 227 | ); 228 | 229 | export const roomsConfigComplete = wcif => 230 | rooms(wcif) 231 | .map(room => getExtensionData('RoomConfig', room)) 232 | .every(isPresentDeep); 233 | 234 | export const roundActivities = (wcif, roundId) => 235 | flatMap(rooms(wcif), room => 236 | room.activities.filter(({ activityCode }) => 237 | activityCode.startsWith(roundId) 238 | ) 239 | ); 240 | 241 | export const groupActivitiesByRound = (wcif, roundId) => 242 | flatMap(roundActivities(wcif, roundId), activity => 243 | hasDistributedAttempts(roundId) ? [activity] : activity.childActivities 244 | ); 245 | 246 | export const roomsWithTimezoneAndGroups = (wcif, roundId) => 247 | flatMap(wcif.schedule.venues, venue => 248 | venue.rooms.map(room => [ 249 | room, 250 | venue.timezone, 251 | flatMap( 252 | room.activities.filter(activity => activity.activityCode === roundId), 253 | activity => activity.childActivities 254 | ), 255 | ]) 256 | ); 257 | 258 | export const activityAssigned = (wcif, activityId) => 259 | wcif.persons.some(person => 260 | person.assignments.some(assignment => assignment.activityId === activityId) 261 | ); 262 | 263 | export const groupActivitiesAssigned = (wcif, roundId) => 264 | groupActivitiesByRound(wcif, roundId).some(activity => 265 | activityAssigned(wcif, activity.id) 266 | ); 267 | 268 | export const roundsWithoutResults = wcif => 269 | flatMap(wcif.events, event => event.rounds).filter( 270 | round => 271 | round.results.length === 0 || 272 | round.results.every(result => result.attempts.length === 0) 273 | ); 274 | 275 | /* Round is missing results if it has all results empty 276 | or it's the first round and has no results at all. 277 | In other words no one's competed in such round, but we know who should compete in it. */ 278 | const roundsMissingResults = wcif => 279 | wcif.events 280 | .map(event => 281 | event.rounds.find(round => { 282 | const { roundNumber } = parseActivityCode(round.id); 283 | return ( 284 | (round.results.length === 0 && roundNumber === 1) || 285 | (round.results.length > 0 && 286 | round.results.every(result => result.attempts.length === 0)) 287 | ); 288 | }) 289 | ) 290 | .filter(round => round); 291 | 292 | export const roundsMissingAssignments = wcif => 293 | roundsMissingResults(wcif).filter( 294 | round => !groupActivitiesAssigned(wcif, round.id) 295 | ); 296 | 297 | export const roundsMissingScorecards = wcif => 298 | roundsMissingResults(wcif) 299 | .filter(round => groupActivitiesAssigned(wcif, round.id)) 300 | .filter(round => parseActivityCode(round.id).eventId !== '333fm'); 301 | 302 | export const allGroupsCreated = wcif => 303 | wcif.events.every(event => 304 | event.rounds.every( 305 | round => groupActivitiesByRound(wcif, round.id).length > 0 306 | ) 307 | ); 308 | 309 | export const anyGroupAssignedOrCreated = wcif => 310 | wcif.events.some(event => 311 | event.rounds.some(round => 312 | hasDistributedAttempts(event.id) 313 | ? groupActivitiesAssigned(wcif, round.id) 314 | : groupActivitiesByRound(wcif, round.id).length > 0 315 | ) 316 | ); 317 | 318 | export const anyResults = wcif => 319 | wcif.events.some(event => 320 | event.rounds.some(round => round.results.length > 0) 321 | ); 322 | 323 | /* Clears groups and assignments only for rounds without results. */ 324 | export const clearGroupsAndAssignmentsWithoutResults = wcif => { 325 | const clearableRounds = roundsWithoutResults(wcif); 326 | const clearableRoundIds = clearableRounds.map(({ id }) => id); 327 | return clearGroupsAndAssignments(wcif, clearableRoundIds); 328 | }; 329 | 330 | /* Clears groups and assignments for the given rounds. */ 331 | export const clearGroupsAndAssignments = (wcif, roundIds) => { 332 | const activities = flatMap(roundIds, roundId => 333 | groupActivitiesByRound(wcif, roundId) 334 | ); 335 | 336 | const activityIds = activities.map(({ id }) => id); 337 | 338 | const persons = wcif.persons.map(person => 339 | updateIn(person, ['assignments'], assignments => 340 | assignments.filter( 341 | ({ activityId, assignmentCode }) => 342 | !activityIds.includes(activityId) || 343 | (!assignmentCode.startsWith('staff-') && 344 | assignmentCode !== 'competitor') 345 | ) 346 | ) 347 | ); 348 | const schedule = mapIn(wcif.schedule, ['venues'], venue => 349 | mapIn(venue, ['rooms'], room => 350 | mapIn(room, ['activities'], activity => 351 | roundIds.includes(activity.activityCode) 352 | ? setIn(activity, ['childActivities'], []) 353 | : activity 354 | ) 355 | ) 356 | ); 357 | return { ...wcif, persons, schedule }; 358 | }; 359 | -------------------------------------------------------------------------------- /src/logic/assignments.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseActivityCode, 3 | activityById, 4 | activitiesOverlap, 5 | activityCodeToGroupName, 6 | sameRoundActivityCode, 7 | } from './activities'; 8 | 9 | export const COMPETITOR_ASSIGNMENT_CODE = 'competitor'; 10 | export const SCRAMBLER_ASSIGNMENT_CODE = 'staff-scrambler'; 11 | export const RUNNER_ASSIGNMENT_CODE = 'staff-runner'; 12 | export const JUDGE_ASSIGNMENT_CODE = 'staff-judge'; 13 | 14 | export const assignmentCodes = [ 15 | COMPETITOR_ASSIGNMENT_CODE, 16 | SCRAMBLER_ASSIGNMENT_CODE, 17 | RUNNER_ASSIGNMENT_CODE, 18 | JUDGE_ASSIGNMENT_CODE, 19 | ]; 20 | 21 | export const assignmentName = assignmentCode => { 22 | switch (assignmentCode) { 23 | case COMPETITOR_ASSIGNMENT_CODE: 24 | return 'Competitor'; 25 | case SCRAMBLER_ASSIGNMENT_CODE: 26 | return 'Scrambler'; 27 | case RUNNER_ASSIGNMENT_CODE: 28 | return 'Runner'; 29 | case JUDGE_ASSIGNMENT_CODE: 30 | return 'Judge'; 31 | default: 32 | throw new Error(`Unrecognised assignment code: '${assignmentCode}'`); 33 | } 34 | }; 35 | 36 | export const anyCompetitorAssignment = wcif => { 37 | return wcif.persons.some(person => 38 | person.assignments.some( 39 | assignment => assignment.assignmentCode === COMPETITOR_ASSIGNMENT_CODE 40 | ) 41 | ); 42 | }; 43 | 44 | const isStaffAssignment = assignmentCode => { 45 | return assignmentCode.startsWith('staff-'); 46 | }; 47 | 48 | export const staffAssignments = person => { 49 | return person.assignments.filter(({ assignmentCode }) => 50 | isStaffAssignment(assignmentCode) 51 | ); 52 | }; 53 | 54 | export const staffAssignmentsForEvent = (wcif, person, eventId) => { 55 | return staffAssignments(person).filter(({ activityId }) => { 56 | const { activityCode } = activityById(wcif, activityId); 57 | return parseActivityCode(activityCode).eventId === eventId; 58 | }); 59 | }; 60 | 61 | export const hasAssignment = (person, activityId, assignmentCode) => { 62 | return person.assignments.some( 63 | assignment => 64 | assignment.activityId === activityId && 65 | assignment.assignmentCode === assignmentCode 66 | ); 67 | }; 68 | 69 | export const getAssignment = (person, activityId, assignmentCode) => { 70 | return person.assignments.find( 71 | assignment => 72 | assignment.activityId === activityId && 73 | assignment.assignmentCode === assignmentCode 74 | ); 75 | }; 76 | 77 | export const updateAssignments = (wcif, personId, updateFn) => { 78 | return { 79 | ...wcif, 80 | persons: wcif.persons.map(person => 81 | person.registrantId === personId 82 | ? { 83 | ...person, 84 | assignments: updateFn(person.assignments), 85 | } 86 | : person 87 | ), 88 | }; 89 | }; 90 | 91 | export const newAssignmentError = (wcif, assignments, newAssignment) => { 92 | const newActivity = activityById(wcif, newAssignment.activityId); 93 | const overlappingActivity = assignments 94 | .map(assignment => activityById(wcif, assignment.activityId)) 95 | .find(assignedActivity => activitiesOverlap(assignedActivity, newActivity)); 96 | if (overlappingActivity) { 97 | return `Has an overlapping assignment for 98 | ${activityCodeToGroupName(overlappingActivity.activityCode)} 99 | during that time. 100 | `; 101 | } 102 | if (newAssignment.assignmentCode === COMPETITOR_ASSIGNMENT_CODE) { 103 | const activityWhereCompetes = assignments 104 | .filter( 105 | assignment => assignment.assignmentCode === COMPETITOR_ASSIGNMENT_CODE 106 | ) 107 | .map(assignment => activityById(wcif, assignment.activityId)) 108 | .find(assignedActivity => 109 | sameRoundActivityCode( 110 | assignedActivity.activityCode, 111 | newActivity.activityCode 112 | ) 113 | ); 114 | if (activityWhereCompetes) { 115 | return `Already has competitor assignment for 116 | ${activityCodeToGroupName(activityWhereCompetes.activityCode)}. 117 | `; 118 | } 119 | } 120 | return null; 121 | }; 122 | 123 | // Returns an assignment identifier unique in the context of a single competitor 124 | export const toAssignmentKey = assignment => { 125 | return `${assignment.activityId}-${assignment.activityCode}`; 126 | }; 127 | -------------------------------------------------------------------------------- /src/logic/auth.js: -------------------------------------------------------------------------------- 1 | import { WCA_ORIGIN, WCA_OAUTH_CLIENT_ID } from './wca-env'; 2 | import history from './history'; 3 | import { copyQueryParams } from './utils'; 4 | 5 | /* Use separate set of keys for each OAuth client (e.g. for WCA production and staging). */ 6 | const localStorageKey = key => `GroupifierNext.${WCA_OAUTH_CLIENT_ID}.${key}`; 7 | 8 | /** 9 | * Checks the URL hash for presence of OAuth access token 10 | * and saves it in the local storage if it's found. 11 | * Should be called on application initialization (before any kind of router takes over the location). 12 | */ 13 | export const initializeAuth = () => { 14 | const hash = window.location.hash.replace(/^#/, ''); 15 | const hashParams = new URLSearchParams(hash); 16 | if (hashParams.has('access_token')) { 17 | localStorage.setItem( 18 | localStorageKey('accessToken'), 19 | hashParams.get('access_token') 20 | ); 21 | } 22 | if (hashParams.has('expires_in')) { 23 | /* Expire the token 15 minutes before it actually does, 24 | this way it doesn't expire right after the user enters the page. */ 25 | const expiresInSeconds = hashParams.get('expires_in') - 15 * 60; 26 | const expirationTime = new Date( 27 | new Date().getTime() + expiresInSeconds * 1000 28 | ); 29 | localStorage.setItem( 30 | localStorageKey('expirationTime'), 31 | expirationTime.toISOString() 32 | ); 33 | } 34 | /* If the token expired, sign the user out. */ 35 | const expirationTime = localStorage.getItem( 36 | localStorageKey('expirationTime') 37 | ); 38 | if (expirationTime && new Date() >= new Date(expirationTime)) { 39 | signOut(); 40 | } 41 | /* Clear the hash if there is a token. */ 42 | if (hashParams.has('access_token')) { 43 | history.replace({ ...history.location, hash: null }); 44 | } 45 | 46 | /* Check if we know what path to redirect to (after OAuth redirect). */ 47 | const redirectPath = localStorage.getItem(localStorageKey('redirectPath')); 48 | if (redirectPath) { 49 | history.replace(redirectPath); 50 | localStorage.removeItem(localStorageKey('redirectPath')); 51 | } 52 | /* If non-signed in user tries accessing a competition path, redirect to OAuth sign in straightaway. */ 53 | const path = window.location.pathname; 54 | if (path.startsWith('/competitions') && !isSignedIn()) { 55 | localStorage.setItem(localStorageKey('redirectPath'), path); 56 | signIn(); 57 | } 58 | }; 59 | 60 | export const wcaAccessToken = () => 61 | localStorage.getItem(localStorageKey('accessToken')); 62 | 63 | export const signIn = () => { 64 | const params = new URLSearchParams({ 65 | client_id: WCA_OAUTH_CLIENT_ID, 66 | response_type: 'token', 67 | redirect_uri: oauthRedirectUri(), 68 | scope: 'manage_competitions', 69 | }); 70 | window.location = `${WCA_ORIGIN}/oauth/authorize?${params.toString()}`; 71 | }; 72 | 73 | const oauthRedirectUri = () => { 74 | const appUri = window.location.origin; 75 | 76 | const query = copyQueryParams(window.location.search, '', [ 77 | 'staging', 78 | 'wca_prod_host', 79 | ]); 80 | 81 | return query ? `${appUri}?${query}` : appUri; 82 | }; 83 | 84 | export const signOut = () => 85 | localStorage.removeItem(localStorageKey('accessToken')); 86 | 87 | export const isSignedIn = () => !!wcaAccessToken(); 88 | -------------------------------------------------------------------------------- /src/logic/competitors.js: -------------------------------------------------------------------------------- 1 | import { parseActivityCode, activityCodeToName } from './activities'; 2 | import { personById, roundById, previousRound } from './wcif'; 3 | import { sortBy, sortByArray, uniq } from './utils'; 4 | 5 | export const best = (person, eventId, type) => { 6 | if (!['single', 'average'].includes(type)) { 7 | throw new Error( 8 | `Personal best type must be either 'single' or 'average'. Received '${type}'.` 9 | ); 10 | } 11 | const personalBest = person.personalBests.find( 12 | pb => pb.eventId === eventId && pb.type === type 13 | ); 14 | return personalBest ? personalBest.best : Infinity; 15 | }; 16 | 17 | export const bestAverageAndSingle = (competitor, eventId) => { 18 | if (['333bf', '444bf', '555bf', '333mbf'].includes(eventId)) { 19 | return [ 20 | best(competitor, eventId, 'single'), 21 | best(competitor, eventId, 'average'), 22 | ]; 23 | } else { 24 | return [ 25 | best(competitor, eventId, 'average'), 26 | best(competitor, eventId, 'single'), 27 | ]; 28 | } 29 | }; 30 | 31 | const competitorsExpectedToAdvance = ( 32 | sortedCompetitors, 33 | advancementCondition, 34 | eventId 35 | ) => { 36 | switch (advancementCondition.type) { 37 | case 'ranking': 38 | return sortedCompetitors.slice(0, advancementCondition.level); 39 | case 'percent': 40 | return sortedCompetitors.slice( 41 | 0, 42 | Math.floor(sortedCompetitors.length * advancementCondition.level * 0.01) 43 | ); 44 | case 'attemptResult': 45 | /* Assume that competitors having personal best better than the advancement condition will make it to the next round. */ 46 | return sortedCompetitors.filter( 47 | person => best(person, eventId, 'single') < advancementCondition.level 48 | ); 49 | default: 50 | throw new Error( 51 | `Unrecognised AdvancementCondition type: '${advancementCondition.type}'` 52 | ); 53 | } 54 | }; 55 | 56 | export const getExpectedCompetitorsByRound = wcif => 57 | wcif.events.reduce((expectedCompetitorsByRound, event) => { 58 | const [firstRound, ...nextRounds] = event.rounds; 59 | expectedCompetitorsByRound[ 60 | firstRound.id 61 | ] = sortByArray( 62 | acceptedPeopleRegisteredForEvent(wcif, event.id), 63 | competitor => bestAverageAndSingle(competitor, event.id) 64 | ); 65 | nextRounds.reduce( 66 | ([round, competitors], nextRound) => { 67 | const advancementCondition = round.advancementCondition; 68 | if (!advancementCondition) { 69 | throw new Error( 70 | `Mising advancement condition for ${activityCodeToName(round.id)}.` 71 | ); 72 | } 73 | const nextRoundCompetitors = competitorsExpectedToAdvance( 74 | competitors, 75 | advancementCondition, 76 | event.id 77 | ); 78 | expectedCompetitorsByRound[nextRound.id] = nextRoundCompetitors; 79 | return [nextRound, nextRoundCompetitors]; 80 | }, 81 | [firstRound, expectedCompetitorsByRound[firstRound.id]] 82 | ); 83 | return expectedCompetitorsByRound; 84 | }, {}); 85 | 86 | /* Returns competitors for the given round sorted from worst to best. */ 87 | export const competitorsForRound = (wcif, roundId) => { 88 | const { eventId, roundNumber } = parseActivityCode(roundId); 89 | const round = roundById(wcif, roundId); 90 | const competitorsInRound = round.results.map(({ personId }) => 91 | personById(wcif, personId) 92 | ); 93 | if (roundNumber === 1) { 94 | /* For first rounds, if there are no empty results to use, get whoever registered for the given event. */ 95 | const competitors = 96 | competitorsInRound.length > 0 97 | ? competitorsInRound 98 | : acceptedPeopleRegisteredForEvent(wcif, eventId); 99 | return sortByArray(competitors, competitor => [ 100 | ...bestAverageAndSingle(competitor, eventId).map(result => -result), 101 | competitor.name, 102 | ]); 103 | } else if (competitorsInRound.length > 0) { 104 | const previous = previousRound(wcif, roundId); 105 | return sortBy(competitorsInRound, person => { 106 | const previousResult = previous.results.find( 107 | result => result.personId === person.registrantId 108 | ); 109 | return -previousResult.ranking; 110 | }); 111 | } else { 112 | return null; 113 | } 114 | }; 115 | 116 | export const age = person => { 117 | const diffMs = Date.now() - new Date(person.birthdate).getTime(); 118 | return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 365.2425)); 119 | }; 120 | 121 | export const acceptedPeople = wcif => 122 | wcif.persons.filter( 123 | person => person.registration && person.registration.status === 'accepted' 124 | ); 125 | 126 | const acceptedPeopleRegisteredForEvent = (wcif, eventId) => 127 | acceptedPeople(wcif).filter(({ registration }) => 128 | registration.eventIds.includes(eventId) 129 | ); 130 | 131 | export const isForeigner = (wcif, competitor) => { 132 | const competitionCountryIso2 = competitionCountryIso2s(wcif)[0]; 133 | return competitor.countryIso2 !== competitionCountryIso2; 134 | }; 135 | 136 | const competitionCountryIso2s = wcif => { 137 | const iso2s = wcif.schedule.venues.map(venue => venue.countryIso2); 138 | return uniq(iso2s); 139 | }; 140 | -------------------------------------------------------------------------------- /src/logic/documents/competitor-cards.js: -------------------------------------------------------------------------------- 1 | import { chunk, sortBy, times, zip } from '../utils'; 2 | import { eventNameById } from '../events'; 3 | import { 4 | activityById, 5 | hasDistributedAttempts, 6 | parseActivityCode, 7 | } from '../activities'; 8 | import { acceptedPeople } from '../competitors'; 9 | import { getExtensionData } from '../wcif-extensions'; 10 | import pdfMake from './pdfmake'; 11 | import { pdfName } from './pdf-utils'; 12 | 13 | export const downloadCompetitorCards = (wcif, evenlySpaced = false) => { 14 | const pdfDefinition = evenlySpaced 15 | ? competitorCardsEvenlySpacedPdfDefinition(wcif) 16 | : competitorCardsPdfDefinition(wcif); 17 | pdfMake.createPdf(pdfDefinition).download(`${wcif.id}-competitor-cards.pdf`); 18 | }; 19 | 20 | const competitorCardsPdfDefinition = wcif => ({ 21 | pageMargins: [5, 5], 22 | content: chunk(competitorCards(wcif, 3), 3).map(cards => ({ 23 | columns: cards, 24 | margin: [5, 5], 25 | columnGap: 10, 26 | fontSize: 8, 27 | unbreakable: true, 28 | })), 29 | }); 30 | 31 | const competitorCardsEvenlySpacedPdfDefinition = wcif => { 32 | const pageWidth = 595.28; 33 | const pageHeight = 841.89; 34 | const cardsPerRow = 2; 35 | const horizontalMargin = 20; 36 | const verticalMargin = 20; 37 | 38 | return { 39 | pageSize: { width: pageWidth, height: pageHeight }, 40 | pageMargins: [horizontalMargin, verticalMargin], 41 | content: { 42 | fontSize: 8, 43 | layout: { 44 | /* Outer margin is done using pageMargins, we use padding for the remaining inner margins. */ 45 | paddingLeft: i => (i % cardsPerRow === 0 ? 0 : horizontalMargin), 46 | paddingRight: i => 47 | i % cardsPerRow === cardsPerRow - 1 ? 0 : horizontalMargin, 48 | paddingTop: i => (i % cardsPerRow === 0 ? 0 : verticalMargin), 49 | paddingBottom: i => 50 | i % cardsPerRow === cardsPerRow - 1 ? 0 : verticalMargin, 51 | /* Get rid of borders. */ 52 | hLineWidth: () => 0, 53 | vLineWidth: () => 0, 54 | }, 55 | table: { 56 | widths: Array(cardsPerRow).fill('*'), 57 | heights: pageHeight / cardsPerRow - 2 * verticalMargin, 58 | dontBreakRows: true, 59 | body: chunk(competitorCards(wcif, cardsPerRow), cardsPerRow), 60 | }, 61 | }, 62 | }; 63 | }; 64 | 65 | const competitorCards = (wcif, cardsPerRow) => { 66 | const cards = sortBy( 67 | acceptedPeople(wcif), 68 | person => person.name 69 | ).map(person => competitorCard(wcif, person)); 70 | const cardsInLastRow = cards.length % cardsPerRow; 71 | return cardsInLastRow === 0 72 | ? cards 73 | : cards.concat(times(cardsPerRow - cardsInLastRow, () => ({}))); 74 | }; 75 | 76 | const competitorCard = (wcif, person) => { 77 | const events = wcif.events.filter(event => !hasDistributedAttempts(event.id)); 78 | const { localNamesFirst, printOneName } = getExtensionData( 79 | 'CompetitionConfig', 80 | wcif 81 | ); 82 | const tasks = sortBy( 83 | person.assignments 84 | .map(({ activityId, assignmentCode }) => { 85 | const activity = activityById(wcif, activityId); 86 | const { eventId, roundNumber, groupNumber } = parseActivityCode( 87 | activity.activityCode 88 | ); 89 | return { assignmentCode, eventId, groupNumber, roundNumber }; 90 | }) 91 | .filter(({ roundNumber }) => roundNumber === 1), 92 | ({ groupNumber }) => groupNumber 93 | ); 94 | const groupsText = (eventId, assignmentCode) => ({ 95 | text: tasks 96 | .filter( 97 | task => 98 | task.eventId === eventId && task.assignmentCode === assignmentCode 99 | ) 100 | .map(task => task.groupNumber) 101 | .join(', '), 102 | alignment: 'center', 103 | }); 104 | const [assignmentCodes = [], headers = []] = zip( 105 | ...[ 106 | ['competitor', 'Comp'], 107 | ['staff-scrambler', 'Scr'], 108 | ['staff-runner', 'Run'], 109 | ['staff-judge', 'Judge'], 110 | ].filter(([assignmentCode]) => 111 | tasks.some(task => task.assignmentCode === assignmentCode) 112 | ) 113 | ); 114 | return { 115 | stack: [ 116 | { 117 | text: pdfName(person.name, { 118 | swapLatinWithLocalNames: localNamesFirst, 119 | short: printOneName, 120 | }), 121 | fontSize: 10, 122 | maxHeight: 20 /* See: https://github.com/bpampuch/pdfmake/issues/264#issuecomment-108347567 */, 123 | }, 124 | { 125 | columns: [ 126 | `ID: ${person.registrantId}`, 127 | { 128 | text: person.wcaId ? `WCA ID: ${person.wcaId}` : {}, 129 | alignment: 'right', 130 | }, 131 | ], 132 | }, 133 | { 134 | table: { 135 | widths: ['auto', ...headers.map(() => '*')], 136 | body: [ 137 | [ 138 | 'Event', 139 | ...headers.map(header => ({ text: header, alignment: 'center' })), 140 | ], 141 | ...events.map(event => [ 142 | eventNameById(event.id), 143 | ...assignmentCodes.map(assignmentCode => 144 | groupsText(event.id, assignmentCode) 145 | ), 146 | ]), 147 | ], 148 | }, 149 | layout: { 150 | paddingLeft: () => 2, 151 | paddingRight: () => 2, 152 | paddingTop: () => 1, 153 | paddingBottom: () => 1, 154 | }, 155 | }, 156 | ...times(Math.max(0, 12 - events.length), () => ({ 157 | text: ' ', 158 | })) /* Add some empty space if there are few events. */, 159 | ], 160 | }; 161 | }; 162 | -------------------------------------------------------------------------------- /src/logic/documents/group-overview.js: -------------------------------------------------------------------------------- 1 | import pdfMake from './pdfmake'; 2 | import { flatMap, sortBy, sortByArray } from '../utils'; 3 | import { 4 | activityCodeToName, 5 | activityDurationString, 6 | parseActivityCode, 7 | roomsWithTimezoneAndGroups, 8 | } from '../activities'; 9 | import { hasAssignment } from '../assignments'; 10 | import { pdfName } from './pdf-utils'; 11 | import { competitorsForRound } from '../competitors'; 12 | 13 | export const downloadGroupOverview = (wcif, rounds, rooms) => { 14 | const pdfDefinition = groupOverviewPdfDefinition(wcif, rounds, rooms); 15 | pdfMake.createPdf(pdfDefinition).download(`${wcif.id}-group-overview.pdf`); 16 | }; 17 | 18 | const groupOverviewPdfDefinition = (wcif, rounds, rooms) => ({ 19 | footer: (currentPage, pageCount) => ({ 20 | text: `${currentPage} of ${pageCount}`, 21 | alignment: 'center', 22 | fontSize: 10, 23 | }), 24 | content: sortByArray( 25 | flatMap( 26 | flatMap(rounds, round => 27 | roomsWithTimezoneAndGroups(wcif, round.id).filter( 28 | ([room, timezone, groupActivities]) => rooms.includes(room) 29 | ) 30 | ), 31 | ([room, timezone, groupActivities]) => 32 | groupActivities.map(groupActivity => [room, timezone, groupActivity]) 33 | ), 34 | ([room, timezone, { startTime, activityCode }]) => [ 35 | startTime, 36 | parseActivityCode(activityCode).groupNumber, 37 | ] 38 | ).map(([room, timezone, groupActivity]) => 39 | overviewForGroup(wcif, room, timezone, groupActivity) 40 | ), 41 | }); 42 | 43 | const overviewForGroup = (wcif, room, timezone, groupActivity) => { 44 | const headersWithPeople = [ 45 | ['Competitors', 'competitor'], 46 | ['Scramblers', 'staff-scrambler'], 47 | ['Runners', 'staff-runner'], 48 | ['Judges', 'staff-judge'], 49 | ] 50 | .map(([header, assignmentCode]) => { 51 | const { eventId, roundNumber } = parseActivityCode( 52 | groupActivity.activityCode 53 | ); 54 | const roundId = `${eventId}-r${roundNumber}`; 55 | // When listing competitors, sort by results, the same 56 | // way as we do with scorecards 57 | const sortedPersons = 58 | assignmentCode === 'competitor' 59 | ? (competitorsForRound(wcif, roundId) || []).reverse() 60 | : sortBy(wcif.persons, person => person.name); 61 | return [ 62 | header, 63 | sortedPersons.filter(person => 64 | hasAssignment(person, groupActivity.id, assignmentCode) 65 | ), 66 | ]; 67 | }) 68 | .filter(([header, people]) => people.length > 0); 69 | return { 70 | unbreakable: true, 71 | margin: [0, 0, 0, 10], 72 | stack: [ 73 | { 74 | text: activityCodeToName(groupActivity.activityCode), 75 | bold: true, 76 | fontSize: 14, 77 | }, 78 | { 79 | columns: [ 80 | `Time: ${activityDurationString(groupActivity, timezone)}`, 81 | `Room: ${room.name}`, 82 | ], 83 | margin: [0, 5, 0, 5], 84 | }, 85 | { 86 | fontSize: 8, 87 | columns: headersWithPeople.map(([header, people]) => [ 88 | { 89 | text: `${header} (${people.length})`, 90 | bold: true, 91 | fontSize: 10, 92 | margin: [0, 0, 0, 2], 93 | }, 94 | { 95 | [header === 'Competitors' ? 'ol' : 'ul']: people.map(person => 96 | pdfName(person.name, { short: true }) 97 | ), 98 | }, 99 | ]), 100 | }, 101 | ], 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /src/logic/documents/pdf-utils.js: -------------------------------------------------------------------------------- 1 | import { inRange } from '../utils'; 2 | 3 | const determineFont = text => { 4 | const code = text.charCodeAt(0); 5 | /* Based on https://en.wikipedia.org/wiki/Unicode_block */ 6 | if (inRange(code, 0x0000, 0x052f)) { 7 | return 'Roboto'; 8 | } else if (inRange(code, 0x0600, 0x06ff) || inRange(code, 0x0750, 0x077f)) { 9 | return 'NotoSansArabic'; 10 | } else if (inRange(code, 0x0e00, 0x0e7f)) { 11 | return 'NotoSansThai'; 12 | } else if (inRange(code, 0x0530, 0x058f)) { 13 | return 'NotoSansArmenian'; 14 | } else if (inRange(code, 0x10a0, 0x10ff)) { 15 | return 'NotoSansGeorgian'; 16 | } else if (inRange(code, 0x0900, 0x097f)) { 17 | return 'NotoSansDevanagari'; 18 | } else if (inRange(code, 0x0d00, 0x0d7f)) { 19 | return 'NotoSansMalayalam'; 20 | } else if (inRange(code, 0x0c80, 0x0cff)) { 21 | return 'NotoSansKannada'; 22 | } else { 23 | /* Default to WenQuanYiZenHei as it supports the most characters (mostly CJK). */ 24 | return 'WenQuanYiZenHei'; 25 | } 26 | }; 27 | 28 | export const pdfName = ( 29 | name, 30 | { swapLatinWithLocalNames = false, short = false } = {} 31 | ) => { 32 | /* Note: support normal and fullwidth parentheses. */ 33 | const [, latinName, localName] = name.match(/(.+)\s*[((](.+)[))]/) || [ 34 | null, 35 | name, 36 | null, 37 | ]; 38 | if (!localName) return latinName; 39 | const pdfNames = [ 40 | latinName, 41 | { text: localName, font: determineFont(localName) }, 42 | ]; 43 | const [first, second] = swapLatinWithLocalNames 44 | ? pdfNames.reverse() 45 | : pdfNames; 46 | return short ? first : [first, ' (', second, ')']; 47 | }; 48 | 49 | export const getImageDataUrl = url => { 50 | if (!url) return Promise.resolve(null); 51 | const params = new URLSearchParams({ 52 | url, 53 | bri: 50, 54 | bg: 'white', 55 | w: 800, 56 | h: 800, 57 | t: 'letterbox', 58 | output: 'jpg', 59 | encoding: 'base64', 60 | }); 61 | return fetch(`https://images.weserv.nl/?${params.toString()}`) 62 | .then(response => response.text()) 63 | .catch(() => null); 64 | }; 65 | -------------------------------------------------------------------------------- /src/logic/documents/pdfmake.js: -------------------------------------------------------------------------------- 1 | import pdfMake from 'pdfmake/build/pdfmake'; 2 | 3 | /* Asynchronously download fonts bundle for PDF Make. */ 4 | fetch('/vfs-fonts.bundle.v3.json', { mode: 'same-origin' }) 5 | .then(response => response.json()) 6 | .then(vfsFonts => (pdfMake.vfs = vfsFonts)); 7 | 8 | const singleFileFont = file => ({ 9 | normal: file, 10 | bold: file, 11 | italic: file, 12 | bolditalics: file, 13 | }); 14 | 15 | pdfMake.fonts = { 16 | Roboto: { 17 | normal: 'Roboto-Regular.ttf', 18 | bold: 'Roboto-Medium.ttf', 19 | italics: 'Roboto-Italic.ttf', 20 | bolditalics: 'Roboto-MediumItalic.ttf', 21 | }, 22 | WenQuanYiZenHei: singleFileFont('WenQuanYiZenHei.ttf'), 23 | /* Note: https://github.com/googlefonts/noto-fonts/blob/master/FAQ.md#whats-the-difference-between-the-ui-and-non-ui-versions */ 24 | NotoSansThai: singleFileFont('NotoSansThaiUI-Regular.ttf'), 25 | NotoSansArabic: singleFileFont('NotoSansArabicUI-Regular.ttf'), 26 | NotoSansGeorgian: singleFileFont('NotoSansGeorgian-Regular.ttf'), 27 | NotoSansArmenian: singleFileFont('NotoSansArmenian-Regular.ttf'), 28 | NotoSansDevanagari: singleFileFont('NotoSansDevanagari-Regular.ttf'), 29 | NotoSansMalayalam: singleFileFont('NotoSansMalayalam-Regular.ttf'), 30 | NotoSansKannada: singleFileFont('NotoSansKannada-Regular.ttf'), 31 | }; 32 | 33 | export default pdfMake; 34 | -------------------------------------------------------------------------------- /src/logic/events.js: -------------------------------------------------------------------------------- 1 | import { sortBy } from './utils'; 2 | 3 | const events = [ 4 | { id: '333', name: '3x3x3 Cube', shortName: '3x3' }, 5 | { id: '222', name: '2x2x2 Cube', shortName: '2x2' }, 6 | { id: '444', name: '4x4x4 Cube', shortName: '4x4' }, 7 | { id: '555', name: '5x5x5 Cube', shortName: '5x5' }, 8 | { id: '666', name: '6x6x6 Cube', shortName: '6x6' }, 9 | { id: '777', name: '7x7x7 Cube', shortName: '7x7' }, 10 | { id: '333bf', name: '3x3x3 Blindfolded', shortName: '3BLD' }, 11 | { id: '333fm', name: '3x3x3 Fewest Moves', shortName: 'FMC' }, 12 | { id: '333oh', name: '3x3x3 One-Handed', shortName: '3OH' }, 13 | { id: '333ft', name: '3x3x3 With Feet', shortName: '3WF' }, 14 | { id: 'minx', name: 'Megaminx', shortName: 'Minx' }, 15 | { id: 'pyram', name: 'Pyraminx', shortName: 'Pyra' }, 16 | { id: 'clock', name: 'Clock', shortName: 'Clock' }, 17 | { id: 'skewb', name: 'Skewb', shortName: 'Skewb' }, 18 | { id: 'sq1', name: 'Square-1', shortName: 'Sq1' }, 19 | { id: '444bf', name: '4x4x4 Blindfolded', shortName: '4BLD' }, 20 | { id: '555bf', name: '5x5x5 Blindfolded', shortName: '5BLD' }, 21 | { id: '333mbf', name: '3x3x3 Multi-Blind', shortName: 'MBLD' }, 22 | ]; 23 | 24 | export const eventNameById = eventId => propertyById('name', eventId); 25 | 26 | export const shortEventNameById = eventId => propertyById('shortName', eventId); 27 | 28 | const propertyById = (property, eventId) => 29 | events.find(event => event.id === eventId)[property]; 30 | 31 | export const sortWcifEvents = wcifEvents => 32 | sortBy(wcifEvents, wcifEvent => 33 | events.findIndex(event => event.id === wcifEvent.id) 34 | ); 35 | -------------------------------------------------------------------------------- /src/logic/formatters.js: -------------------------------------------------------------------------------- 1 | import { parseActivityCode } from './activities'; 2 | import { shortEventNameById } from './events'; 3 | 4 | export const cutoffToString = (cutoff, eventId) => { 5 | if (eventId === '333mbf') { 6 | return `> ${multibldAttemptResultToPoints(cutoff.attemptResult)} points`; 7 | } else if (eventId === '333fm') { 8 | return `< ${cutoff.attemptResult} moves`; 9 | } else { 10 | return `< ${centisecondsToClockFormat(cutoff.attemptResult)}`; 11 | } 12 | }; 13 | 14 | export const timeLimitToString = (timeLimit, options = {}) => { 15 | const { totalText = 'total' } = options; 16 | const { centiseconds, cumulativeRoundIds } = timeLimit; 17 | const clockFormat = centisecondsToClockFormat(centiseconds); 18 | if (cumulativeRoundIds.length === 0) { 19 | return clockFormat; 20 | } else if (cumulativeRoundIds.length === 1) { 21 | return `${clockFormat} ${totalText}`; 22 | } else { 23 | const roundStrings = cumulativeRoundIds.map(roundId => { 24 | const { eventId, roundNumber } = parseActivityCode(roundId); 25 | return `${shortEventNameById(eventId)} R${roundNumber}`; 26 | }); 27 | return `${clockFormat} ${totalText} (${roundStrings.join(' + ')})`; 28 | } 29 | }; 30 | 31 | const multibldAttemptResultToPoints = attemptResult => 32 | 99 - (Math.floor(attemptResult / 10000000) % 100); 33 | 34 | export const centisecondsToClockFormat = centiseconds => { 35 | const date = new Date(null); 36 | date.setUTCMilliseconds(centiseconds * 10); 37 | return date 38 | .toISOString() 39 | .substr(11, 11) 40 | .replace(/^[0:]*(?!\.)/g, ''); 41 | }; 42 | -------------------------------------------------------------------------------- /src/logic/formulas.js: -------------------------------------------------------------------------------- 1 | export const suggestedGroupCount = (competitors, stations, roundNumber) => { 2 | if (stations === 0) return 1; 3 | const preferredGroupSize = stations * 1.7; 4 | /* We calculate the number of perfectly-sized groups, and round it up starting from x.1, 5 | this way we don't end up with much more than the perfect amount of people in a single group. 6 | Having more small groups is preferred over having fewer big groups. */ 7 | const calculatedGroupCount = Math.round( 8 | competitors / preferredGroupSize + 0.4 9 | ); 10 | /* Suggest at least 2 groups for first rounds, so that there are people to scramble. */ 11 | return Math.max(calculatedGroupCount, roundNumber === 1 ? 2 : 1); 12 | }; 13 | 14 | /* Take min{groupCompetitors, stations} (i.e. stations in use) and suggest one scrambler for each 5 of them. */ 15 | export const suggestedScramblerCount = (groupCompetitors, stations) => 16 | Math.floor(1 + (Math.min(groupCompetitors, stations) - 1) / 5); 17 | 18 | /* Take min{groupCompetitors, stations} (i.e. stations in use) and suggest one runner for each 8 of them. */ 19 | export const suggestedRunnerCount = (groupCompetitors, stations) => 20 | Math.floor(1 + (Math.min(groupCompetitors, stations) - 1) / 8); 21 | -------------------------------------------------------------------------------- /src/logic/history.js: -------------------------------------------------------------------------------- 1 | /* Customized history preserving `staging` query parameter on location change. */ 2 | 3 | import { createBrowserHistory } from 'history'; 4 | import { copyQueryParams } from './utils'; 5 | 6 | const preserveQueryParams = (history, location) => { 7 | location.search = copyQueryParams(history.location.search, location.search, [ 8 | 'staging', 9 | 'wca_prod_host', 10 | ]); 11 | 12 | return location; 13 | }; 14 | 15 | const createLocationObject = (path, state) => { 16 | return typeof path === 'string' ? { pathname: path, state } : path; 17 | }; 18 | 19 | const history = createBrowserHistory(); 20 | 21 | const originalPush = history.push; 22 | history.push = (path, state) => { 23 | return originalPush.apply(history, [ 24 | preserveQueryParams(history, createLocationObject(path, state)), 25 | ]); 26 | }; 27 | 28 | const originalReplace = history.replace; 29 | history.replace = (path, state) => { 30 | return originalReplace.apply(history, [ 31 | preserveQueryParams(history, createLocationObject(path, state)), 32 | ]); 33 | }; 34 | 35 | export default history; 36 | -------------------------------------------------------------------------------- /src/logic/tests/competitors.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | Competition, 3 | Person, 4 | PersonalBest, 5 | Event, 6 | Round, 7 | Result, 8 | } from './wcif-builders'; 9 | import { competitorsForRound } from '../competitors'; 10 | 11 | describe('competitorsForRound', () => { 12 | const person1 = Person({ 13 | registration: { eventIds: ['333', '222'] }, 14 | personalBests: [ 15 | PersonalBest({ eventId: '333', type: 'single', worldRanking: 2 }), 16 | PersonalBest({ eventId: '333', type: 'average', worldRanking: 2 }), 17 | ], 18 | }); 19 | const person2 = Person({ 20 | registration: { eventIds: ['333'] }, 21 | personalBests: [ 22 | PersonalBest({ eventId: '333', type: 'single', worldRanking: 3 }), 23 | PersonalBest({ eventId: '333', type: 'average', worldRanking: 2 }), 24 | ], 25 | }); 26 | const personWithoutAverage = Person({ 27 | registration: { eventIds: ['333'] }, 28 | personalBests: [ 29 | PersonalBest({ eventId: '333', type: 'single', worldRanking: 1 }), 30 | ], 31 | }); 32 | const personWithoutPersonalBests = Person({ 33 | registration: { eventIds: ['333'] }, 34 | }); 35 | const personNotRegistered = Person({ 36 | registration: { eventIds: ['222'] }, 37 | personalBests: [ 38 | PersonalBest({ eventId: '333', type: 'single', worldRanking: 1 }), 39 | PersonalBest({ eventId: '333', type: 'average', worldRanking: 1 }), 40 | ], 41 | }); 42 | const personNotAccepted = Person({ 43 | registration: { eventIds: ['333'], status: 'pending' }, 44 | }); 45 | 46 | describe('when given a first round', () => { 47 | describe('when has no results', () => { 48 | const events = [ 49 | Event({ 50 | id: '333', 51 | rounds: [ 52 | Round({ id: '333-r1', results: [] }), 53 | Round({ id: '333-r2' }), 54 | ], 55 | }), 56 | ]; 57 | 58 | test('returns people ordered by official average then single descending', () => { 59 | const wcif = Competition({ 60 | events, 61 | persons: [ 62 | person1, 63 | personWithoutAverage, 64 | personWithoutPersonalBests, 65 | person2, 66 | ], 67 | }); 68 | expect(competitorsForRound(wcif, '333-r1')).toEqual([ 69 | personWithoutPersonalBests, 70 | personWithoutAverage, 71 | person2, 72 | person1, 73 | ]); 74 | }); 75 | 76 | test('returns only people who registered for the given event', () => { 77 | const wcif = Competition({ 78 | events, 79 | persons: [person1, personNotRegistered], 80 | }); 81 | expect(competitorsForRound(wcif, '333-r1')).toEqual([person1]); 82 | }); 83 | 84 | test('returns only people with accepted registration', () => { 85 | const wcif = Competition({ 86 | events, 87 | persons: [person1, personNotAccepted], 88 | }); 89 | expect(competitorsForRound(wcif, '333-r1')).toEqual([person1]); 90 | }); 91 | }); 92 | 93 | describe('when has results', () => { 94 | const events = [ 95 | Event({ 96 | id: '333', 97 | rounds: [ 98 | Round({ 99 | id: '333-r1', 100 | results: [ 101 | Result({ attempts: [], personId: person1.registrantId }), 102 | Result({ 103 | attempts: [], 104 | personId: personWithoutAverage.registrantId, 105 | }), 106 | Result({ 107 | attempts: [], 108 | personId: personWithoutPersonalBests.registrantId, 109 | }), 110 | ], 111 | }), 112 | Round({ id: '333-r2' }), 113 | ], 114 | }), 115 | ]; 116 | 117 | test('returns people ordered by official average then single descending', () => { 118 | const wcif = Competition({ 119 | events, 120 | persons: [person1, personWithoutAverage, personWithoutPersonalBests], 121 | }); 122 | expect(competitorsForRound(wcif, '333-r1')).toEqual([ 123 | personWithoutPersonalBests, 124 | personWithoutAverage, 125 | person1, 126 | ]); 127 | }); 128 | 129 | test('returns only people corresponding to the results', () => { 130 | const wcif = Competition({ 131 | events, 132 | persons: [ 133 | person2, 134 | person1, 135 | personWithoutAverage, 136 | personWithoutPersonalBests, 137 | personNotAccepted, 138 | ], 139 | }); 140 | expect(competitorsForRound(wcif, '333-r1')).toEqual([ 141 | personWithoutPersonalBests, 142 | personWithoutAverage, 143 | person1, 144 | ]); 145 | }); 146 | }); 147 | }); 148 | 149 | describe('when given a subsequent round', () => { 150 | test('returns advancing people ordered by previous round ranking descending', () => { 151 | const events = [ 152 | Event({ 153 | id: '333', 154 | rounds: [ 155 | Round({ 156 | id: '333-r1', 157 | results: [ 158 | Result({ ranking: 1, personId: person2.registrantId }), 159 | Result({ 160 | ranking: 2, 161 | personId: personWithoutPersonalBests.registrantId, 162 | }), 163 | Result({ ranking: 3, personId: person1.registrantId }), 164 | Result({ 165 | ranking: 4, 166 | personId: personWithoutAverage.registrantId, 167 | }), 168 | ], 169 | }), 170 | Round({ 171 | id: '333-r2', 172 | results: [ 173 | Result({ attempts: [], personId: person2.registrantId }), 174 | Result({ 175 | attempts: [], 176 | personId: personWithoutPersonalBests.registrantId, 177 | }), 178 | Result({ attempts: [], personId: person1.registrantId }), 179 | ], 180 | }), 181 | ], 182 | }), 183 | ]; 184 | const wcif = Competition({ 185 | persons: [ 186 | person1, 187 | personWithoutAverage, 188 | personWithoutPersonalBests, 189 | person2, 190 | ], 191 | events, 192 | }); 193 | expect(competitorsForRound(wcif, '333-r2')).toEqual([ 194 | person1, 195 | personWithoutPersonalBests, 196 | person2, 197 | ]); 198 | }); 199 | }); 200 | 201 | describe('when competitors for the given round cannot be determined yet', () => { 202 | test('returns null', () => { 203 | const wcif = Competition({ 204 | persons: [person1, person2], 205 | events: [ 206 | Event({ rounds: [Round({ id: '333-r1' }), Round({ id: '333-r2' })] }), 207 | ], 208 | }); 209 | expect(competitorsForRound(wcif, '333-r2')).toEqual(null); 210 | }); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /src/logic/tests/formatters.test.js: -------------------------------------------------------------------------------- 1 | import { cutoffToString, timeLimitToString } from '../formatters'; 2 | 3 | describe('cutoffToString', () => { 4 | test('returns poinst for MBLD', () => { 5 | const cutoff = { numberOfAttempts: 1, attemptResult: 910000000 }; 6 | expect(cutoffToString(cutoff, '333mbf')).toEqual('> 8 points'); 7 | }); 8 | 9 | test('returns moves for FMC', () => { 10 | const cutoff = { numberOfAttempts: 1, attemptResult: 40 }; 11 | expect(cutoffToString(cutoff, '333fm')).toEqual('< 40 moves'); 12 | }); 13 | 14 | test('returns clock format for ordinary events', () => { 15 | const cutoff = { numberOfAttempts: 2, attemptResult: 1.5 * 3600 * 100 }; 16 | expect(cutoffToString(cutoff, '333')).toEqual('< 1:30:00.00'); 17 | }); 18 | 19 | test('strips leading zeros', () => { 20 | const cutoff = { numberOfAttempts: 2, attemptResult: 30 * 100 }; 21 | expect(cutoffToString(cutoff, '333')).toEqual('< 30.00'); 22 | }); 23 | }); 24 | 25 | describe('timeLimitToString', () => { 26 | test('returns just the time for non-cumulative limit', () => { 27 | const timeLimit = { centiseconds: 15 * 100, cumulativeRoundIds: [] }; 28 | expect(timeLimitToString(timeLimit)).toEqual('15.00'); 29 | }); 30 | 31 | test('makes it clear that a limit is cumulative for all sovles', () => { 32 | const timeLimit = { 33 | centiseconds: 60 * 100, 34 | cumulativeRoundIds: ['333bf-r1'], 35 | }; 36 | expect(timeLimitToString(timeLimit)).toEqual('1:00.00 total'); 37 | }); 38 | 39 | test('includes list of short round names for multi-round cumulative limit', () => { 40 | const timeLimit = { 41 | centiseconds: 1.5 * 3600 * 100, 42 | cumulativeRoundIds: ['444bf-r1', '555bf-r1'], 43 | }; 44 | expect(timeLimitToString(timeLimit)).toEqual( 45 | '1:30:00.00 total (4BLD R1 + 5BLD R1)' 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/logic/tests/groups.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | Competition, 3 | Event, 4 | Round, 5 | Venue, 6 | Room, 7 | Activity, 8 | } from './wcif-builders'; 9 | import { createGroupActivities } from '../groups'; 10 | import { setExtensionData } from '../wcif-extensions'; 11 | 12 | describe('createGroupActivities', () => { 13 | test('does not create group activities for MBLD and FMC', () => { 14 | const activities = [ 15 | Activity({ activityCode: '333mbf-r1-a1' }), 16 | Activity({ activityCode: '333fm-r1-a1' }), 17 | ]; 18 | const wcif = Competition({ 19 | events: [ 20 | Event({ id: '333mbf', rounds: [Round({ id: '333mbf-r1' })] }), 21 | Event({ id: '333fm', rounds: [Round({ id: '333fm-r1' })] }), 22 | ], 23 | schedule: { 24 | venues: [Venue({ rooms: [Room({ activities })] })], 25 | }, 26 | }); 27 | expect(createGroupActivities(wcif)).toEqual(wcif); 28 | }); 29 | 30 | test('does not create group activities if there are any already', () => { 31 | const roundActivity = setExtensionData( 32 | 'ActivityConfig', 33 | Activity({ 34 | activityCode: '333-r1', 35 | startTime: '2020-01-01T10:00:00.000Z', 36 | endTime: '2020-01-01T11:00:00.000Z', 37 | childActivities: [Activity({ activityCode: '333-r1-g1' })], 38 | }), 39 | { groups: 4 } 40 | ); 41 | const wcif = Competition({ 42 | events: [Event({ id: '333', rounds: [Round({ id: '333-r1' })] })], 43 | schedule: { 44 | venues: [Venue({ rooms: [Room({ activities: [roundActivity] })] })], 45 | }, 46 | }); 47 | expect(createGroupActivities(wcif)).toEqual(wcif); 48 | }); 49 | 50 | test('creates child group activities for round activities according to the configuration', () => { 51 | const roundActivity = setExtensionData( 52 | 'ActivityConfig', 53 | Activity({ 54 | activityCode: '333-r1', 55 | startTime: '2020-01-01T10:00:00.000Z', 56 | endTime: '2020-01-01T11:00:00.000Z', 57 | }), 58 | { groups: 4 } 59 | ); 60 | const wcif = Competition({ 61 | events: [Event({ id: '333', rounds: [Round({ id: '333-r1' })] })], 62 | schedule: { 63 | venues: [Venue({ rooms: [Room({ activities: [roundActivity] })] })], 64 | }, 65 | }); 66 | const updatedActivity = createGroupActivities(wcif).schedule.venues[0] 67 | .rooms[0].activities[0]; 68 | expect(updatedActivity.childActivities).toHaveLength(4); 69 | const [ 70 | firstGroup, 71 | secondGroup, 72 | thirdGroup, 73 | fourthGroup, 74 | ] = updatedActivity.childActivities; 75 | expect(firstGroup).toEqual( 76 | expect.objectContaining({ 77 | activityCode: '333-r1-g1', 78 | startTime: '2020-01-01T10:00:00.000Z', 79 | endTime: '2020-01-01T10:15:00.000Z', 80 | }) 81 | ); 82 | expect(secondGroup).toEqual( 83 | expect.objectContaining({ 84 | activityCode: '333-r1-g2', 85 | startTime: '2020-01-01T10:15:00.000Z', 86 | endTime: '2020-01-01T10:30:00.000Z', 87 | }) 88 | ); 89 | expect(thirdGroup).toEqual( 90 | expect.objectContaining({ 91 | activityCode: '333-r1-g3', 92 | startTime: '2020-01-01T10:30:00.000Z', 93 | endTime: '2020-01-01T10:45:00.000Z', 94 | }) 95 | ); 96 | expect(fourthGroup).toEqual( 97 | expect.objectContaining({ 98 | activityCode: '333-r1-g4', 99 | startTime: '2020-01-01T10:45:00.000Z', 100 | endTime: '2020-01-01T11:00:00.000Z', 101 | }) 102 | ); 103 | }); 104 | 105 | test('keeps group numbers chronological when a round takes place in multiple rooms', () => { 106 | const roundActivityRoom1 = setExtensionData( 107 | 'ActivityConfig', 108 | Activity({ 109 | activityCode: '333-r1', 110 | startTime: '2020-01-01T10:00:00.000Z', 111 | endTime: '2020-01-01T10:45:00.000Z', 112 | }), 113 | { groups: 3 } 114 | ); 115 | const roundActivityRoom2 = setExtensionData( 116 | 'ActivityConfig', 117 | Activity({ 118 | activityCode: '333-r1', 119 | startTime: '2020-01-01T10:10:00.000Z', 120 | endTime: '2020-01-01T10:40:00.000Z', 121 | }), 122 | { groups: 2 } 123 | ); 124 | const wcif = Competition({ 125 | events: [Event({ id: '333', rounds: [Round({ id: '333-r1' })] })], 126 | schedule: { 127 | venues: [ 128 | Venue({ 129 | rooms: [ 130 | Room({ activities: [roundActivityRoom1] }), 131 | Room({ activities: [roundActivityRoom2] }), 132 | ], 133 | }), 134 | ], 135 | }, 136 | }); 137 | const updatedActivityRoom1 = createGroupActivities(wcif).schedule.venues[0] 138 | .rooms[0].activities[0]; 139 | const updatedActivityRoom2 = createGroupActivities(wcif).schedule.venues[0] 140 | .rooms[1].activities[0]; 141 | expect(updatedActivityRoom1.childActivities).toHaveLength(3); 142 | expect(updatedActivityRoom2.childActivities).toHaveLength(2); 143 | const [ 144 | firstGroup, 145 | thirdGroup, 146 | fifthGroup, 147 | ] = updatedActivityRoom1.childActivities; 148 | const [secondGroup, fourthGroup] = updatedActivityRoom2.childActivities; 149 | expect(firstGroup).toEqual( 150 | expect.objectContaining({ 151 | activityCode: '333-r1-g1', 152 | startTime: '2020-01-01T10:00:00.000Z', 153 | endTime: '2020-01-01T10:15:00.000Z', 154 | }) 155 | ); 156 | expect(secondGroup).toEqual( 157 | expect.objectContaining({ 158 | activityCode: '333-r1-g2', 159 | startTime: '2020-01-01T10:10:00.000Z', 160 | endTime: '2020-01-01T10:25:00.000Z', 161 | }) 162 | ); 163 | expect(thirdGroup).toEqual( 164 | expect.objectContaining({ 165 | activityCode: '333-r1-g3', 166 | startTime: '2020-01-01T10:15:00.000Z', 167 | endTime: '2020-01-01T10:30:00.000Z', 168 | }) 169 | ); 170 | expect(fourthGroup).toEqual( 171 | expect.objectContaining({ 172 | activityCode: '333-r1-g4', 173 | startTime: '2020-01-01T10:25:00.000Z', 174 | endTime: '2020-01-01T10:40:00.000Z', 175 | }) 176 | ); 177 | expect(fifthGroup).toEqual( 178 | expect.objectContaining({ 179 | activityCode: '333-r1-g5', 180 | startTime: '2020-01-01T10:30:00.000Z', 181 | endTime: '2020-01-01T10:45:00.000Z', 182 | }) 183 | ); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /src/logic/tests/wcif-builders.js: -------------------------------------------------------------------------------- 1 | const nextIdByBuilder = new Map(); 2 | const withId = builder => { 3 | nextIdByBuilder.set(builder, 1); 4 | return attributes => { 5 | const id = nextIdByBuilder.get(builder); 6 | nextIdByBuilder.set(builder, id + 1); 7 | return builder(id)(attributes); 8 | }; 9 | }; 10 | 11 | export const Competition = attributes => ({ 12 | formatVersion: '1.0', 13 | id: 'Example2019', 14 | name: 'Example Competition 2019', 15 | shortName: 'Example 2019', 16 | persons: [], 17 | events: [], 18 | extensions: [], 19 | ...attributes, 20 | schedule: { 21 | startDate: '2020-01-01', 22 | numberOfDays: 2, 23 | venues: [], 24 | ...attributes.schedule, 25 | }, 26 | }); 27 | 28 | export const Person = withId(id => attributes => ({ 29 | name: `Person ${id}`, 30 | wcaUserId: id, 31 | wcaId: `2019PERS${id % 100}`, 32 | registrantId: id, 33 | countryIso2: 'GB', 34 | gender: 'm', 35 | birthdate: '2000-01-01', 36 | email: `person${id}@example.com`, 37 | avatar: { 38 | url: 'https://example.com/avatar.jpg', 39 | thumbUrl: 'https://example.com/avatar-thumb.jpg', 40 | }, 41 | roles: [], 42 | assignments: [], 43 | personalBests: [], 44 | ...attributes, 45 | registration: { 46 | wcaRegistrationId: id, 47 | eventIds: [], 48 | status: 'accepted', 49 | guests: 0, 50 | comments: '', 51 | ...attributes.registration, 52 | }, 53 | })); 54 | 55 | export const PersonalBest = attributes => { 56 | const { eventId, worldRanking, type } = attributes; 57 | if (!eventId || !worldRanking || !type) 58 | throw new Error('PersonalBest requires eventId, worldRanking and type.'); 59 | return { 60 | best: worldRanking * 200, 61 | continentalRanking: worldRanking, 62 | nationalRanking: worldRanking, 63 | ...attributes, 64 | }; 65 | }; 66 | 67 | export const Event = attributes => ({ 68 | id: '333', 69 | rounds: [], 70 | competitorLimit: null, 71 | qualification: null, 72 | extensions: [], 73 | ...attributes, 74 | }); 75 | 76 | export const Round = attributes => ({ 77 | id: '333-r1', 78 | format: 'a', 79 | timeLimit: null, 80 | cutoff: null, 81 | advancementCondition: null, 82 | results: [], 83 | scrambleSetCount: 1, 84 | scrambleSets: [], 85 | extensions: [], 86 | ...attributes, 87 | }); 88 | 89 | export const Result = attributes => { 90 | const { personId, ranking } = attributes; 91 | if (!personId) throw new Error('Result requires personId.'); 92 | return { 93 | attempts: [ 94 | { result: ranking * 200 }, 95 | { result: ranking * 205 }, 96 | { result: ranking * 150 }, 97 | { result: ranking * 300 }, 98 | { result: ranking * 101 }, 99 | ], 100 | ...attributes, 101 | }; 102 | }; 103 | 104 | export const Venue = withId(id => attributes => ({ 105 | id, 106 | name: `Venue ${id}`, 107 | latitudeMicrodegrees: 0, 108 | longitudeMicrodegrees: 0, 109 | timezone: 'UTC', 110 | rooms: [], 111 | extensions: [], 112 | ...attributes, 113 | })); 114 | 115 | export const Room = withId(id => attributes => ({ 116 | id: id, 117 | name: `Room ${id}`, 118 | color: '#000000', 119 | activities: [], 120 | extensions: [], 121 | ...attributes, 122 | })); 123 | 124 | export const Activity = withId(id => attributes => ({ 125 | id: id, 126 | name: `Activity ${id}`, 127 | activityCode: 'other-misc-example', 128 | startTime: '2020-01-01T10:00:00.000Z', 129 | endTime: '2020-01-01T11:00:00.000Z', 130 | childActivities: [], 131 | scrambleSetId: null, 132 | extensions: [], 133 | ...attributes, 134 | })); 135 | -------------------------------------------------------------------------------- /src/logic/tests/wcif-validations.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | Competition, 3 | Event, 4 | Round, 5 | Venue, 6 | Room, 7 | Activity, 8 | } from './wcif-builders'; 9 | import { validateWcif } from '../wcif-validation'; 10 | 11 | describe('validateWcif', () => { 12 | test('returns an error when an even has no rounds', () => { 13 | const wcif = Competition({ events: [Event({ id: '333', rounds: [] })] }); 14 | expect(validateWcif(wcif)).toContain('No rounds specified for 3x3x3 Cube.'); 15 | }); 16 | 17 | test('returns an error when a round has no advancement condition unless it is the last one', () => { 18 | const wcif = Competition({ 19 | events: [ 20 | Event({ 21 | id: '333', 22 | rounds: [ 23 | Round({ id: '333-r1', advancementCondition: null }), 24 | Round({ id: '333-r2', advancementCondition: null }), 25 | ], 26 | }), 27 | ], 28 | }); 29 | expect(validateWcif(wcif)).toContain( 30 | 'No advancement condition specified for 3x3x3 Cube, Round 1.' 31 | ); 32 | expect(validateWcif(wcif)).not.toContain( 33 | 'No advancement condition specified for 3x3x3 Cube, Round 2.' 34 | ); 35 | }); 36 | 37 | test('returns an error when schedule does not include a round', () => { 38 | const wcif = Competition({ 39 | events: [ 40 | Event({ 41 | id: '333', 42 | rounds: [Round({ id: '333-r1' }), Round({ id: '333-r2' })], 43 | }), 44 | ], 45 | schedule: { 46 | venues: [ 47 | Venue({ 48 | rooms: [ 49 | Room({ activities: [Activity({ activityCode: '333-r1' })] }), 50 | ], 51 | }), 52 | ], 53 | }, 54 | }); 55 | expect(validateWcif(wcif)).toContain( 56 | 'No schedule activities for 3x3x3 Cube, Round 2.' 57 | ); 58 | expect(validateWcif(wcif)).not.toContain( 59 | 'No schedule activities for 3x3x3 Cube, Round 1.' 60 | ); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/logic/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a copy of the object with the value at the specified path transformed by the update function. 3 | * 4 | * @param {Object} object 5 | * @param {Array} propertyChain 6 | * @param {Function} updater 7 | * @returns {Object} 8 | */ 9 | export const updateIn = (object, [property, ...properyChain], updater) => 10 | properyChain.length === 0 11 | ? { ...object, [property]: updater(object[property]) } 12 | : { 13 | ...object, 14 | [property]: updateIn(object[property], properyChain, updater), 15 | }; 16 | 17 | /** 18 | * Returns a copy of the object with the value at the specified path set to the given one. 19 | * 20 | * @param {Object} object 21 | * @param {Array} propertyChain 22 | * @param {*} value 23 | * @returns {Object} 24 | */ 25 | export const setIn = (object, properyChain, value) => 26 | updateIn(object, properyChain, () => value); 27 | 28 | /** 29 | * Returns a copy of the object with the value at the specified path merged with the given one. 30 | * 31 | * @param {Object} object 32 | * @param {Array} propertyChain 33 | * @param {Object} newValue 34 | * @returns {Object} 35 | */ 36 | export const mergeIn = (object, properyChain, newValue) => 37 | updateIn(object, properyChain, currentValue => ({ 38 | ...currentValue, 39 | ...newValue, 40 | })); 41 | 42 | /** 43 | * Returns a copy of the object with the array at the specified path mapped with the given function. 44 | * 45 | * @param {Object} object 46 | * @param {Array} propertyChain 47 | * @param {Object} mapper 48 | * @returns {Object} 49 | */ 50 | export const mapIn = (object, properyChain, mapper) => 51 | updateIn(object, properyChain, array => array && array.map(mapper)); 52 | 53 | /** 54 | * Returns object's value at the specified path or the default value if it doesn't exist. 55 | * 56 | * @param {Object} object 57 | * @param {Array} propertyChain 58 | * @param {*} defaultValue 59 | * @returns {*} 60 | */ 61 | export const getIn = ( 62 | object, 63 | [property, ...propertyChain], 64 | defaultValue = null 65 | ) => 66 | object 67 | ? propertyChain.length === 0 68 | ? object.hasOwnProperty(property) 69 | ? object[property] 70 | : defaultValue 71 | : getIn(object[property], propertyChain, defaultValue) 72 | : defaultValue; 73 | 74 | /** 75 | * Checks if the given value is an object. 76 | * 77 | * @param {*} value 78 | * @returns {boolean} 79 | */ 80 | const isObject = obj => obj === Object(obj); 81 | 82 | /** 83 | * When given an object, deeply checks if it doesn't contain null values. 84 | * Otherwise, checks if the given value is not null. 85 | * 86 | * @param {*} value 87 | * @returns {boolean} 88 | */ 89 | export const isPresentDeep = value => 90 | isObject(value) ? Object.values(value).every(isPresentDeep) : value != null; 91 | 92 | /** 93 | * Pluralizes a word according to the given number. 94 | * When no plural form given, uses singular form with an 's' appended. 95 | * 96 | * @param {number} count 97 | * @param {string} singular 98 | * @param {string} plural 99 | * @returns {string} 100 | */ 101 | export const pluralize = (count, singular, plural) => 102 | `${count} ${count === 1 ? singular : plural || singular + 's'}`; 103 | 104 | /** 105 | * Returns a new array with items summing up to 1, preserving elements proportionality. 106 | * When the given array is empty, returns an empty array. 107 | * 108 | * @param {Array} arr 109 | * @returns {Array} 110 | */ 111 | export const scaleToOne = arr => { 112 | if (arr.length === 0) return []; 113 | const arrSum = sum(arr); 114 | return arr.map(x => (arrSum !== 0 ? x / arrSum : 1 / arr.length)); 115 | }; 116 | 117 | /** 118 | * Applies the given function to the elements and returns the first truthy value of these calls. 119 | * 120 | * @param {Array} arr 121 | * @returns {*} 122 | */ 123 | export const firstResult = (arr, fn) => 124 | arr.reduce((result, x) => result || fn(x), null); 125 | 126 | export const flatMap = (arr, fn) => arr.reduce((xs, x) => xs.concat(fn(x)), []); 127 | 128 | export const groupBy = (arr, fn) => 129 | arr.reduce( 130 | (obj, x) => updateIn(obj, [fn(x)], xs => (xs || []).concat(x)), 131 | {} 132 | ); 133 | 134 | export const zip = (...arrs) => 135 | arrs.length === 0 ? [] : arrs[0].map((_, i) => arrs.map(arr => arr[i])); 136 | 137 | export const findLast = (arr, predicate) => 138 | arr.reduceRight( 139 | (found, x) => (found !== undefined ? found : predicate(x) ? x : undefined), 140 | undefined 141 | ); 142 | 143 | export const intersection = (xs, ys) => xs.filter(x => ys.includes(x)); 144 | 145 | export const difference = (xs, ys) => xs.filter(x => !ys.includes(x)); 146 | 147 | export const partition = (xs, fn) => [xs.filter(fn), xs.filter(x => !fn(x))]; 148 | 149 | const sortCompare = (x, y) => (x < y ? -1 : x > y ? 1 : 0); 150 | 151 | export const sortBy = (arr, fn) => 152 | arr.slice().sort((x, y) => sortCompare(fn(x), fn(y))); 153 | 154 | export const sortByArray = (arr, fn) => { 155 | const values = new Map( 156 | arr.map(x => [x, fn(x)]) 157 | ); /* Compute every value once. */ 158 | return arr 159 | .slice() 160 | .sort((x, y) => 161 | firstResult(zip(values.get(x), values.get(y)), ([a, b]) => 162 | sortCompare(a, b) 163 | ) 164 | ); 165 | }; 166 | 167 | export const chunk = (arr, size) => 168 | arr.length <= size 169 | ? [arr] 170 | : [arr.slice(0, size), ...chunk(arr.slice(size), size)]; 171 | 172 | export const times = (n, fn) => 173 | Array.from({ length: n }, (_, index) => fn(index)); 174 | 175 | export const uniq = arr => [...new Set(arr)]; 176 | 177 | export const sum = arr => arr.reduce((x, y) => x + y, 0); 178 | 179 | export const pick = (obj, keys) => 180 | keys.reduce((newObj, key) => ({ ...newObj, [key]: obj[key] }), {}); 181 | 182 | export const inRange = (x, a, b) => a <= x && x <= b; 183 | 184 | export const addMilliseconds = (isoString, milliseconds) => 185 | new Date(new Date(isoString).getTime() + milliseconds).toISOString(); 186 | 187 | export const isoTimeDiff = (first, second) => 188 | Math.abs(new Date(first) - new Date(second)); 189 | 190 | export const shortTime = (isoString, timeZone = 'UTC') => 191 | new Date(isoString).toLocaleTimeString('en-US', { 192 | timeZone, 193 | hour: 'numeric', 194 | minute: 'numeric', 195 | }); 196 | 197 | export const toInt = string => parseInt(string, 10); 198 | 199 | export const copyQueryParams = (sourceQuery, targetQuery, keys) => { 200 | const query = new URLSearchParams(sourceQuery); 201 | const newQuery = new URLSearchParams(targetQuery); 202 | 203 | for (let key of keys) { 204 | if (query.has(key)) { 205 | newQuery.set(key, query.get(key)); 206 | } 207 | } 208 | 209 | return newQuery.toString(); 210 | }; 211 | -------------------------------------------------------------------------------- /src/logic/wca-api.js: -------------------------------------------------------------------------------- 1 | import { wcaAccessToken } from './auth'; 2 | import { WCA_ORIGIN } from './wca-env'; 3 | import { pick } from './utils'; 4 | 5 | export const getUpcomingManageableCompetitions = () => { 6 | const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); 7 | const params = new URLSearchParams({ 8 | managed_by_me: true, 9 | start: oneWeekAgo.toISOString(), 10 | sort: 'start_date', 11 | }); 12 | return wcaApiFetch(`/competitions?${params.toString()}`); 13 | }; 14 | 15 | export const getWcif = competitionId => 16 | wcaApiFetch(`/competitions/${competitionId}/wcif`); 17 | 18 | const updateWcif = (competitionId, wcif) => 19 | wcaApiFetch(`/competitions/${competitionId}/wcif`, { 20 | method: 'PATCH', 21 | body: JSON.stringify(wcif), 22 | }); 23 | 24 | export const saveWcifChanges = (previousWcif, newWcif) => { 25 | const keysDiff = Object.keys(newWcif).filter( 26 | key => previousWcif[key] !== newWcif[key] 27 | ); 28 | if (keysDiff.length === 0) return Promise.resolve(); 29 | 30 | let wcifDiff = pick(newWcif, keysDiff); 31 | 32 | // TODO: remove once WCIF registration status evaluation is resolved 33 | if (wcifDiff.persons) { 34 | const persons = wcifDiff.persons.map( 35 | ({ registration, ...person }) => person 36 | ); 37 | wcifDiff = { ...wcifDiff, persons }; 38 | } 39 | 40 | return updateWcif(newWcif.id, wcifDiff); 41 | }; 42 | 43 | const wcaApiFetch = (path, fetchOptions = {}) => { 44 | const baseApiUrl = `${WCA_ORIGIN}/api/v0`; 45 | 46 | return fetch( 47 | `${baseApiUrl}${path}`, 48 | Object.assign({}, fetchOptions, { 49 | headers: new Headers({ 50 | Authorization: `Bearer ${wcaAccessToken()}`, 51 | 'Content-Type': 'application/json', 52 | }), 53 | }) 54 | ) 55 | .then(response => { 56 | if (!response.ok) throw new Error(response.statusText); 57 | return response; 58 | }) 59 | .then(response => response.json()); 60 | }; 61 | -------------------------------------------------------------------------------- /src/logic/wca-env.js: -------------------------------------------------------------------------------- 1 | const searchParams = new URLSearchParams(window.location.search); 2 | 3 | const wcaProdHost = 4 | searchParams.get('wca_prod_host') || 'www.worldcubeassociation.org'; 5 | 6 | export const PRODUCTION = 7 | searchParams.has('wca_prod_host') || 8 | (process.env.NODE_ENV === 'production' && !searchParams.has('staging')); 9 | 10 | export const WCA_ORIGIN = PRODUCTION 11 | ? `https://${wcaProdHost}` 12 | : 'https://staging.worldcubeassociation.org'; 13 | 14 | export const WCA_OAUTH_CLIENT_ID = PRODUCTION 15 | ? 'ontNJO4UBV7P-ShigoAwaP1g4peOANll4hyZOUWawj0' 16 | : 'example-application-id'; 17 | -------------------------------------------------------------------------------- /src/logic/wcif-extensions.js: -------------------------------------------------------------------------------- 1 | const groupifierExtensionId = extensionName => `groupifier.${extensionName}`; 2 | 3 | const buildGroupifierExtension = (extensionName, data) => ({ 4 | id: groupifierExtensionId(extensionName), 5 | specUrl: `https://groupifier.jonatanklosko.com/wcif-extensions/${extensionName}.json`, 6 | data, 7 | }); 8 | 9 | export const setExtensionData = (extensionName, wcifEntity, data) => { 10 | const otherExtensions = wcifEntity.extensions.filter( 11 | extension => extension.id !== groupifierExtensionId(extensionName) 12 | ); 13 | return { 14 | ...wcifEntity, 15 | extensions: [ 16 | ...otherExtensions, 17 | buildGroupifierExtension(extensionName, data), 18 | ], 19 | }; 20 | }; 21 | 22 | const defaultExtensionData = { 23 | /* This always gets generated, so we keep it as null */ 24 | ActivityConfig: null, 25 | RoomConfig: { 26 | stations: null, 27 | }, 28 | CompetitionConfig: { 29 | localNamesFirst: false, 30 | printOneName: false, 31 | scorecardsBackgroundUrl: '', 32 | competitorsSortingRule: 'ranks', 33 | noTasksForNewcomers: false, 34 | tasksForOwnEventsOnly: false, 35 | noRunningForForeigners: false, 36 | printStations: false, 37 | scorecardPaperSize: 'a4', 38 | scorecardOrder: 'natural', 39 | printScorecardsCoverSheets: false, 40 | printScrambleCheckerForTopRankedCompetitors: false, 41 | printScrambleCheckerForFinalRounds: false, 42 | printScrambleCheckerForBlankScorecards: false, 43 | }, 44 | }; 45 | 46 | export const getExtensionData = (extensionName, wcifEntity) => { 47 | const extension = (wcifEntity.extensions || []).find( 48 | extension => extension.id === groupifierExtensionId(extensionName) 49 | ); 50 | const defaultData = defaultExtensionData[extensionName]; 51 | if (defaultData === null) return extension && extension.data; 52 | return extension ? { ...defaultData, ...extension.data } : defaultData; 53 | }; 54 | 55 | export const removeExtensionData = (extensionName, wcifEntity) => ({ 56 | ...wcifEntity, 57 | extensions: wcifEntity.extensions.filter( 58 | extension => extension.id !== groupifierExtensionId(extensionName) 59 | ), 60 | }); 61 | -------------------------------------------------------------------------------- /src/logic/wcif-validation.js: -------------------------------------------------------------------------------- 1 | import { flatMap } from './utils'; 2 | import { activityCodeToName, roundActivities } from './activities'; 3 | 4 | export const validateWcif = wcif => { 5 | const { events } = wcif; 6 | const eventRoundErrors = flatMap(events, event => { 7 | if (event.rounds.length === 0) 8 | return [`No rounds specified for ${activityCodeToName(event.id)}.`]; 9 | const advancementConditionErrors = flatMap( 10 | event.rounds.slice(0, -1), 11 | round => 12 | round.advancementCondition 13 | ? [] 14 | : [ 15 | `No advancement condition specified for ${activityCodeToName( 16 | round.id 17 | )}.`, 18 | ] 19 | ); 20 | const roundActivityErrors = flatMap(event.rounds, round => 21 | roundActivities(wcif, round.id).length > 0 22 | ? [] 23 | : [`No schedule activities for ${activityCodeToName(round.id)}.`] 24 | ); 25 | return [...advancementConditionErrors, ...roundActivityErrors]; 26 | }); 27 | return eventRoundErrors; 28 | }; 29 | -------------------------------------------------------------------------------- /src/logic/wcif.js: -------------------------------------------------------------------------------- 1 | import { parseActivityCode } from './activities'; 2 | 3 | export const eventById = (wcif, eventId) => { 4 | return wcif.events.find(event => event.id === eventId); 5 | }; 6 | 7 | export const personById = (wcif, personId) => { 8 | return wcif.persons.find(person => person.registrantId === personId); 9 | }; 10 | 11 | export const roundById = (wcif, roundId) => { 12 | const { eventId } = parseActivityCode(roundId); 13 | return eventById(wcif, eventId).rounds.find(round => round.id === roundId); 14 | }; 15 | 16 | export const previousRound = (wcif, roundId) => { 17 | const { eventId, roundNumber } = parseActivityCode(roundId); 18 | const event = eventById(wcif, eventId); 19 | return event.rounds.find( 20 | ({ id }) => parseActivityCode(id).roundNumber === roundNumber - 1 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/polyfills.js: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/stable'; 2 | 3 | import smoothscroll from 'smoothscroll-polyfill'; 4 | smoothscroll.polyfill(); 5 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | --------------------------------------------------------------------------------