├── .github └── CODEOWNERS ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── public ├── asset-manifest.json ├── assets │ ├── SCREEN.png │ └── announce.png ├── icons │ ├── favicon.ico │ ├── logo_128x128.png │ ├── logo_16x16.png │ └── logo_48x48.png ├── index.html ├── inject.css ├── inject.js ├── logo192.png ├── logo512.png ├── manifest.json └── scripts │ ├── background.js │ ├── classes │ ├── ApiData.js │ ├── EpitechData.js │ └── XPHubApi.js │ ├── data │ └── epitech_data.js │ ├── index.js │ ├── retrieveData │ ├── ModuleHandler.js │ ├── TEpitech.js │ ├── XPHub.js │ ├── retrieveData.js │ ├── roadBlock.js │ └── timeline.js │ └── utils │ ├── crypto.js │ ├── updateFrontend.js │ └── webStorage.js └── src ├── App.css ├── App.js ├── App.test.js ├── Layout.js ├── Pages ├── Dashboard.js ├── Hub.js ├── Roadblock.js ├── TEPitech.css ├── TEPitech.js └── TimelineBox.js ├── components ├── AboutModal.js ├── AnnouncementHandler.js ├── ChartComponent.js ├── CustomToolTip.js ├── HubCard.js ├── IconButton.js ├── RoadBlockCard.js ├── RowGenerator.js ├── Sidebar.js └── SummaryCard.js ├── contexts └── DataContext.js ├── index.css ├── index.js ├── logo.svg ├── reportWebVitals.js ├── setupTests.js ├── styles.js └── utils └── listeners.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @RedBoardDev @OoscarFrank -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules/ 3 | build/ 4 | *.zip 5 | **/SCREEN.png 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 RedBoard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

EpitechIntranetStatistics

4 |

Enhance your Epitech Intranet experience with advanced statistics and visualization.

5 | 6 | 7 | 8 | 9 |
10 | 11 | --- 12 | 13 | ## ⚠️ Disclaimer 14 | 15 | The extension is currently out of date, as there is no reliable way to retrieve the information contained in [this file](https://github.com/RedBoardDev/EpitechIntranetStatistics/blob/main/public/scripts/data/epitech_data.js). I remain highly motivated to revive the project once a viable solution is found. If you have any leads or suggestions, please open an issue. 16 | 17 | --- 18 | 19 | ## 📍 Overview 20 | 21 | EpitechIntranetStatistics is a web application providing statistics and information related to the Epitech Intranet.
22 | It offers functionalities for updating user data, retrieving API information, managing XP points, and displaying charts.
23 | The project aims to enhance the user experience by providing customizable information in an interactive dashboard, facilitating efficient tracking of progress and engagement. 24 | 25 | ## 📥 Download 26 | 27 | [Download from the Chrome Web Store](https://chrome.google.com/webstore/detail/fhelhbblcnpdfkiefkanbjjpkpejgodj) 28 | 29 | --- 30 | 31 | ### 🔧 Installation 32 | 33 | 1. Clone the EpitechIntranetStatistics repository: 34 | 35 | ```sh 36 | git clone https://github.com/RedBoardDev/EpitechIntranetStatistics 37 | ``` 38 | 39 | 2. Change to the project directory: 40 | 41 | ```sh 42 | cd EpitechIntranetStatistics 43 | ``` 44 | 45 | 3. Install the dependencies: 46 | 47 | ```sh 48 | npm install 49 | ``` 50 | 51 | 4. Running EpitechIntranetStatistics 52 | 53 | ```sh 54 | npm start 55 | ``` 56 | 57 | --- 58 | 59 | ## 🚧 Want to fix a module code ? 60 | 61 | - Create a Pull Request modifying the file `public/scripts/data/sortedModules.json` with the correct key and value 62 | 63 | ## 🤝 Contributing 64 | 65 | Contributions are always welcome! Please follow these steps: 66 | 67 | 1. Fork the project repository. This creates a copy of the project on your account that you can modify without affecting the original project. 68 | 2. Clone the forked repository to your local machine using a Git client like Git or GitHub Desktop. 69 | 3. Create a new branch with a descriptive name (e.g., `new-feature-branch` or `bugfix-issue-123`). 70 | 71 | ```sh 72 | git checkout -b new-feature-branch 73 | ``` 74 | 75 | 4. Make changes to the project's codebase. 76 | 5. Commit your changes to your local branch with a clear commit message that explains the changes you've made. 77 | 78 | ```sh 79 | git commit -m 'Implemented new feature.' 80 | ``` 81 | 82 | 6. Push your changes to your forked repository on GitHub using the following command 83 | 84 | ```sh 85 | git push origin new-feature-branch 86 | ``` 87 | 88 | 7. Create a new pull request to the original project repository. In the pull request, describe the changes you've made and why they're necessary. 89 | The project maintainers will review your changes and provide feedback or merge them into the main branch. 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "EpitechIntranetStatistics", 3 | "version": "1.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.11.3", 7 | "@emotion/styled": "^11.11.0", 8 | "@mui/icons-material": "^5.11.16", 9 | "@mui/material": "^5.15.6", 10 | "@testing-library/jest-dom": "^5.16.5", 11 | "@testing-library/react": "^13.4.0", 12 | "@testing-library/user-event": "^13.5.0", 13 | "html-react-parser": "^3.0.16", 14 | "interactjs": "^1.10.17", 15 | "moment": "^2.29.4", 16 | "react": "^18.2.0", 17 | "react-apexcharts": "^1.4.1", 18 | "react-calendar-timeline": "^0.28.0", 19 | "react-dom": "^18.2.0", 20 | "react-router-dom": "^6.11.2", 21 | "react-scripts": "5.0.1", 22 | "recharts": "^2.6.2", 23 | "web-vitals": "^2.1.4" 24 | }, 25 | "scripts": { 26 | "start": "react-scripts start", 27 | "build": "react-scripts build", 28 | "test": "react-scripts test", 29 | "eject": "react-scripts eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": [ 33 | "react-app", 34 | "react-app/jest" 35 | ] 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "devDependencies": { 50 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.js": "/static/js/main.12345678.js", 4 | "main.css": "/static/css/main.12345678.css", 5 | "logo.svg": "/static/media/logo.12345678.svg" 6 | }, 7 | "entrypoints": [ 8 | "static/js/main.12345678.js" 9 | ], 10 | "mapFiles": {} 11 | } 12 | -------------------------------------------------------------------------------- /public/assets/SCREEN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/assets/SCREEN.png -------------------------------------------------------------------------------- /public/assets/announce.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/assets/announce.png -------------------------------------------------------------------------------- /public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/icons/favicon.ico -------------------------------------------------------------------------------- /public/icons/logo_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/icons/logo_128x128.png -------------------------------------------------------------------------------- /public/icons/logo_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/icons/logo_16x16.png -------------------------------------------------------------------------------- /public/icons/logo_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/icons/logo_48x48.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | -------------------------------------------------------------------------------- /public/inject.css: -------------------------------------------------------------------------------- 1 | .popup-extension { 2 | z-index: 43000; 3 | width: 100%; 4 | height: 100%; 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | border: none; 9 | box-shadow: none; 10 | background-color: none; 11 | font-family: sans-serif; 12 | } 13 | 14 | .button-stats { 15 | background-image: url("https://i.imgur.com/9Aqd0j4.png"); 16 | background-repeat: no-repeat; 17 | background-size: 64%; 18 | background-position: center; 19 | } 20 | 21 | .button-stats:hover { 22 | background-image: url("https://i.imgur.com/QPXs7TI.png"); 23 | } 24 | 25 | .button-stats:active { 26 | background-image: url("https://i.imgur.com/QPXs7TI.png"); 27 | } -------------------------------------------------------------------------------- /public/inject.js: -------------------------------------------------------------------------------- 1 | function createButton(document, type, className, text) { 2 | var button = document.createElement(type); 3 | button.className = className; 4 | 5 | var label = document.createElement("span"); 6 | label.classList.add("label"); 7 | label.innerText = text; 8 | 9 | button.appendChild(label); 10 | 11 | return button; 12 | } 13 | 14 | const insertAfter = (newNode, referenceNode) => { 15 | if (referenceNode) { 16 | referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); 17 | } 18 | }; 19 | 20 | function getElementByXpath(path) { 21 | return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; 22 | } 23 | 24 | /* global chrome */ 25 | function createPopup() { 26 | var iframe = document.createElement("iframe"); 27 | iframe.className = "popup-extension"; 28 | iframe.src = chrome.runtime.getURL("index.html"); 29 | document.body.appendChild(iframe); 30 | 31 | document.addEventListener('click', (event) => { 32 | if (iframe && iframe.contains(event.target)) { 33 | iframe.style.display = 'none'; 34 | if (document.body.contains(iframe)) { 35 | document.body.removeChild(iframe); 36 | } 37 | } 38 | }); 39 | 40 | window.addEventListener('message', function (event) { 41 | if (event.data.type === 'outsideClick') { 42 | iframe.style.display = 'none'; 43 | 44 | if (document.body.contains(iframe)) { 45 | document.body.removeChild(iframe); 46 | } 47 | } 48 | }); 49 | } 50 | 51 | function addButtonToPage() { 52 | const neartag = getElementByXpath('//*[@id="header"]/div[2]/div/div[6]/table/tbody/tr/td[4]'); 53 | let newButton = createButton(document, "a", "button", " Statistics"); 54 | newButton.title = " Statistics"; 55 | var td = document.createElement("td"); 56 | td.className = "button-stats"; 57 | 58 | td.classList.add("Statistics"); 59 | td.appendChild(newButton); 60 | insertAfter(td, neartag); 61 | newButton.addEventListener('click', () => { 62 | setTimeout(() => { 63 | createPopup(); 64 | }, 0); 65 | }); 66 | } 67 | 68 | window.addEventListener('load', () => { 69 | const navbar = document.querySelector("#sidebar"); 70 | if (navbar) { 71 | navbar.style.zIndex = "950"; 72 | } 73 | addButtonToPage(); 74 | }); 75 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedBoardDev/EpitechIntranetStatistics/c0cc78e7e699747b2c760c7a8c0d8e12605c92e9/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Epitech Intranet Statistics", 4 | "version": "1.1.0", 5 | "description": "Graphical interface for Epitech intranet with more statistics", 6 | "icons": { 7 | "16": "icons/logo_16x16.png", 8 | "48": "icons/logo_48x48.png", 9 | "128": "icons/logo_128x128.png" 10 | }, 11 | "permissions": [ 12 | "tabs", 13 | "cookies", 14 | "activeTab", 15 | "storage" 16 | ], 17 | "host_permissions": [ 18 | "https://intra.epitech.eu/*", 19 | "https://*.microsoftonline.com/*", 20 | "https://*.live.com/*" 21 | ], 22 | "background": { 23 | "service_worker": "scripts/background.js" 24 | }, 25 | "content_scripts": [ 26 | { 27 | "matches": [ 28 | "https://intra.epitech.eu/*" 29 | ], 30 | "js": [ 31 | "inject.js" 32 | ], 33 | "css": [ 34 | "inject.css" 35 | ] 36 | } 37 | ], 38 | "web_accessible_resources": [ 39 | { 40 | "resources": [ 41 | "inject.js", 42 | "index.html" 43 | ], 44 | "matches": [ 45 | "https://intra.epitech.eu/*" 46 | ] 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /public/scripts/background.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | 3 | function getCookiesForEpitech() { 4 | return new Promise((resolve, reject) => { 5 | chrome.cookies.getAll({}, (cookies) => { 6 | const epitechCookies = cookies.filter((cookie) => { 7 | return cookie.domain === "intra.epitech.eu" && cookie.name === "user"; 8 | }); 9 | 10 | if (epitechCookies && epitechCookies.length > 0) { 11 | resolve(epitechCookies); 12 | } else { 13 | reject(new Error("No Epitech user cookie found.")); 14 | } 15 | }); 16 | }); 17 | } 18 | 19 | function handleGetToken(sendResponse) { 20 | getCookiesForEpitech() 21 | .then((cookies) => { 22 | const data = { 23 | refresh_token: cookies[0]['value'], 24 | status: true 25 | }; 26 | sendResponse(data); 27 | }) 28 | .catch((error) => { 29 | const data = { 30 | status: false, 31 | error: error.message 32 | }; 33 | sendResponse(data); 34 | }); 35 | return true; 36 | } 37 | 38 | 39 | chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { 40 | switch (request.command) { 41 | case "GET_TOKEN": 42 | return handleGetToken(sendResponse); 43 | default: 44 | return false; 45 | } 46 | }); -------------------------------------------------------------------------------- /public/scripts/classes/ApiData.js: -------------------------------------------------------------------------------- 1 | import { generateIdFromStr } from '../utils/crypto.js'; 2 | import { storeData, getData } from '../utils/webStorage.js'; 3 | class ApiData { 4 | #scolarYear; 5 | #location; 6 | #studentYear; 7 | 8 | #preLoad_generalUserData; 9 | #preLoad_generalNotesData; 10 | #preLoad_generalCourseData; 11 | 12 | constructor() { 13 | this.#scolarYear = undefined; 14 | this.#location = undefined; 15 | this.#studentYear = undefined; 16 | 17 | this.#preLoad_generalUserData = {}; 18 | this.#preLoad_generalNotesData = {}; 19 | this.#preLoad_generalCourseData = {}; 20 | } 21 | 22 | async init(refreshToken, email) { 23 | if (!refreshToken || !email) return undefined; 24 | storeData('refresh_token', refreshToken); 25 | storeData('user_email', email); 26 | 27 | const userData = await this.#callApi('GET', `user/${email}?format=json`) 28 | this.#setScolarYear(userData['scolaryear']) 29 | this.#setUserLocation(userData['location']); 30 | this.#preLoad_generalUserData = userData; 31 | this.#studentYear = userData['studentyear']; 32 | 33 | const notesData = await this.#callApi('GET', `user/${email}/notes?format=json`); 34 | this.#preLoad_generalNotesData = notesData; 35 | 36 | const dataCursus = await this.#callApi('GET', `course/filter?format=json`); 37 | const filteredDataCursus = dataCursus 38 | // voir si c'est pas active_promo qu'il faut garder au lieu de check scolarYear 39 | .filter(item => item.id && Number(item.scolaryear) === Number(this.getScolarYear())) 40 | .map(item => { 41 | return { 42 | id: (item.id ?? undefined), 43 | semester: (item.semester ?? undefined), 44 | num: (item.num ?? undefined), 45 | begin: (item.begin ?? undefined), 46 | end: (item.end ?? undefined), 47 | end_register: (item.end_register ?? undefined), 48 | scolaryear: (item.scolaryear ?? undefined), 49 | code: (item.code ?? undefined), 50 | codeinstance: (item.codeinstance ?? undefined), 51 | location_title: (item.location_title ?? undefined), 52 | instance_location: (item.instance_location ?? undefined), 53 | flags: (item.flags ?? undefined), 54 | credits: (item.credits ?? undefined), 55 | status: (item.status ?? undefined), 56 | active_promo: (item.active_promo ?? undefined), 57 | open: (item.open ?? undefined), 58 | title: (item.title ?? undefined), 59 | complete_data: undefined, 60 | for_timeline: 0 61 | }; 62 | }); 63 | this.#preLoad_generalCourseData = filteredDataCursus; 64 | } 65 | 66 | // getter / setter function 67 | 68 | getStudentYear() { 69 | return this.#studentYear; 70 | } 71 | 72 | #getUserToken() { 73 | return getData('refresh_token'); 74 | } 75 | 76 | getUserEmail() { 77 | return getData('user_email'); 78 | } 79 | 80 | getScolarYear() { 81 | return this.#scolarYear.toString(); 82 | } 83 | 84 | #setScolarYear(scolarYear) { 85 | this.#scolarYear = scolarYear; 86 | } 87 | 88 | getUserLocation() { 89 | return this.#location.toString().split('/'); 90 | } 91 | 92 | #setUserLocation(location) { 93 | this.#location = location; 94 | } 95 | 96 | getGeneralUserData() { 97 | return this.#preLoad_generalUserData; 98 | } 99 | 100 | getGeneralNotesData() { 101 | return this.#preLoad_generalNotesData; 102 | } 103 | 104 | // general_course - getter / setter function 105 | getGeneralCourseData() { 106 | const courseData = this.#preLoad_generalCourseData; 107 | 108 | if (!courseData) return null; 109 | return courseData; 110 | } 111 | getNodeOnCourseCompleteData(criteria) { 112 | const courseData = this.#preLoad_generalCourseData; 113 | if (!courseData) return null; 114 | 115 | // voir la ligne d'en dessous 116 | // return courseData.find(item => Object.keys(criteria).every(key => item[key] === criteria[key]))?.complete_data; 117 | for (let item of courseData) { 118 | if (Object.keys(criteria).every(key => item[key] === criteria[key])) { 119 | return item.complete_data; 120 | } 121 | } 122 | return null; 123 | } 124 | 125 | getNodeOnCourseData(criteria) { 126 | const courseData = this.#preLoad_generalCourseData; 127 | 128 | if (!courseData) return null; 129 | return courseData.filter(item => item.code === criteria.code && item.semester === criteria.semester); 130 | } 131 | 132 | setNodeCourseCompleteData(criteria, newCompleteData) { 133 | const courseData = this.#preLoad_generalCourseData; 134 | if (!courseData) return; 135 | 136 | // courseData.find(item => Object.keys(criteria).every(key => item[key] === criteria[key])).complete_data = newCompleteData; 137 | for (let item of courseData) { 138 | if (Object.keys(criteria).every(key => item[key] === criteria[key])) { 139 | item.complete_data = newCompleteData; 140 | } 141 | } 142 | this.#preLoad_generalCourseData = courseData; 143 | } 144 | 145 | // // general API function 146 | 147 | async getCurrent() { 148 | const general = await this.#callApi('GET', `?format=json`); 149 | if (!general) return null; 150 | const current = general['current'] ?? null; 151 | return current; 152 | } 153 | 154 | async getCompleteDataFromApi(codeModule, codeInstance) { 155 | return await this.#callApi('GET', `module/${this.getScolarYear()}/${codeModule}/${codeInstance}/?format=json`); 156 | } 157 | 158 | // // preload data function 159 | 160 | async #callApi(method, endpoint) { 161 | if (endpoint === undefined || endpoint === null) 162 | return null; 163 | var config = { 164 | method: method, 165 | headers: { 166 | Cookies: this.#getUserToken() 167 | } 168 | }; 169 | const apiBase = 'https://intra.epitech.eu/'; 170 | var request = new Request(apiBase + endpoint, config); 171 | 172 | try { 173 | const response = await fetch(request, config); 174 | return response.json(); 175 | } catch (error) { 176 | return null; 177 | } 178 | } 179 | 180 | async sendTracking() { 181 | if (!this.getUserEmail()) return; 182 | const storedTrackingId = getData('tracker_id') 183 | 184 | let encryptedEmail; 185 | if (storedTrackingId) { 186 | encryptedEmail = storedTrackingId; 187 | } else { 188 | encryptedEmail = await generateIdFromStr(this.getUserEmail()); 189 | storeData('tracker_id', encryptedEmail); 190 | } 191 | var config = { 192 | method: 'POST', 193 | headers: { 194 | 'Content-Type': 'application/json', 195 | }, 196 | body: JSON.stringify({ 197 | "userId": encryptedEmail, 198 | }) 199 | }; 200 | 201 | try { 202 | var request = new Request('https://tracker.thomasott.fr/api/track', config); 203 | const response = await fetch(request); 204 | return (!response.ok && response.status !== 429); 205 | } catch (error) { 206 | return 1; 207 | } 208 | } 209 | } 210 | 211 | export { ApiData }; 212 | -------------------------------------------------------------------------------- /public/scripts/classes/EpitechData.js: -------------------------------------------------------------------------------- 1 | import { epitechData } from '../data/epitech_data.js'; 2 | 3 | class EpitechData { 4 | #data; 5 | 6 | constructor() { 7 | this.#data = epitechData; 8 | } 9 | 10 | getUpdateDate() { 11 | return this.#data?.update_date ?? '1942-04-02'; 12 | } 13 | 14 | getRoadblocksNames() { 15 | return this.#data?.roadblocks_name ?? null; 16 | } 17 | 18 | getRoadblocksNameByType(type) { 19 | return this.#data?.roadblocks_name?.[type] ?? null; 20 | } 21 | 22 | getRoadblocksRequirements(schoolyear) { 23 | const yearKey = `pge${schoolyear}`; 24 | return this.#data?.promo_requirements?.[yearKey]?.roadblocks ?? null; 25 | } 26 | 27 | getRoadblocksRequirementsByType(schoolyear, type) { 28 | const yearKey = `pge${schoolyear}`; 29 | return this.#data?.promo_requirements?.[yearKey]?.roadblocks?.[type] ?? null; 30 | } 31 | 32 | getUnits(schoolyear) { 33 | const yearKey = `pge${schoolyear}`; 34 | return this.#data?.promo_requirements?.[yearKey]?.units ?? null; 35 | } 36 | 37 | getUnitsByType(schoolyear, type) { 38 | const yearKey = `pge${schoolyear}`; 39 | return this.#data?.promo_requirements?.[yearKey]?.units?.[type] ?? null; 40 | } 41 | 42 | getCreditsRequirements(schoolyear) { 43 | const yearKey = `pge${schoolyear}`; 44 | return this.#data?.promo_requirements?.[yearKey]?.roadblocks?.total_credits ?? 0; 45 | } 46 | 47 | getHubUnit() { 48 | return this.#data?.hub?.unit ?? null; 49 | } 50 | 51 | getHubMaxCredits(schoolyear) { 52 | const yearKey = `pge${schoolyear}`; 53 | return this.#data?.hub?.max_credits?.[yearKey] ?? 0; 54 | } 55 | 56 | getHubActivities() { 57 | return this.#data?.hub?.activities ?? null; 58 | } 59 | 60 | getHubActivitiesByType(type) { 61 | const activities = this.#data?.hub?.activities; 62 | 63 | if (!activities) return null; 64 | const matchingActivity = activities.find(activity => { 65 | return activity.name === type || (activity.alias && activity.alias.includes(type)); 66 | }); 67 | 68 | return matchingActivity || null; 69 | } 70 | } 71 | 72 | export { EpitechData }; 73 | -------------------------------------------------------------------------------- /public/scripts/classes/XPHubApi.js: -------------------------------------------------------------------------------- 1 | class XPHub { 2 | #xpAct; 3 | #me; 4 | 5 | constructor(activityData) { 6 | this.#me = { nbXps: 0, nbXpsSoon: 0, nbXpsLost: 0, activList: [] }; 7 | this.#xpAct = activityData.map(activity => { 8 | return { 9 | ...activity, 10 | nbPart: 0, 11 | nbOrg: 0, 12 | nbXPTotal: 0, 13 | }; 14 | }); 15 | } 16 | 17 | dateIsPassed = (targetDate) => { 18 | const actualDate = new Date(); 19 | return targetDate < actualDate; 20 | }; 21 | 22 | addProject = (everyNotes, codeacti, date_begin, date_end) => { 23 | const projects = everyNotes.filter((note) => note.codeacti === codeacti); 24 | 25 | projects.forEach((project) => { 26 | const beginDate = new Date(date_begin); 27 | const endDate = new Date(date_end); 28 | 29 | const dateDifference = ((endDate.getTime() - beginDate.getTime()) / (1000 * 60 * 60 * 24) + 1); 30 | 31 | const findAct = this.#xpAct.find((act) => act.name === 'Project' || act.alias.includes('Project')); 32 | findAct.nbPart += 1; 33 | 34 | if (this.dateIsPassed(endDate)) { 35 | const XP = (dateDifference * 2) * project.final_note / 100; 36 | this.#me.nbXps += XP; 37 | findAct.nbXPTotal += XP; 38 | this.#me.activList.push({ title: project.title, type: "Project", status: 'present', date: date_begin, XPEarn: XP }); 39 | } else { 40 | const XP = (dateDifference * 2); 41 | this.#me.nbXpsSoon += XP; 42 | this.#me.activList.push({ title: project.title, type: "Project", status: 'present', date: date_begin, XPEarn: 0 }); 43 | } 44 | }); 45 | }; 46 | 47 | addActivite = (title, type, status, date) => { 48 | const findAct = this.#xpAct.find((act) => act.name === type || act.alias.includes(type)); 49 | if (!findAct) return; 50 | const { limitPart, xpWinPart, xpWinOrg, nbPart, xpLostPart, nbOrg, limitOrg } = findAct; 51 | 52 | switch (status) { 53 | case 'present': 54 | if (limitPart === -1 || nbPart < limitPart) { 55 | this.#me.nbXps += xpWinPart; 56 | findAct.nbXPTotal += xpWinPart; 57 | findAct.nbPart += 1 58 | } 59 | this.#me.activList.push({ title, type, status: 'present', date, XPEarn: xpWinPart }); 60 | break; 61 | case 'absent': 62 | this.#me.nbXps -= xpLostPart; 63 | this.#me.nbXpsLost -= xpLostPart; 64 | findAct.nbXPTotal -= xpLostPart; 65 | this.#me.activList.push({ title, type, status: 'absent', date, XPEarn: xpLostPart * (-1) }); 66 | break; 67 | case 'organisateur': 68 | if (limitOrg === -1 || nbOrg < limitOrg) { 69 | this.#me.nbXps += xpWinOrg; 70 | findAct.nbXPTotal += xpWinOrg; 71 | findAct.nbOrg += 1; 72 | } 73 | this.#me.activList.push({ title, type, status: 'organisateur', date, XPEarn: xpWinOrg }); 74 | break; 75 | case 'soon': 76 | this.#me.activList.push({ title, type, status: 'inscrit', date, XPEarn: 0 }); 77 | break; 78 | default: 79 | break; 80 | } 81 | }; 82 | 83 | countXpSoon = () => { 84 | const registerActivity = (this.#me.activList).filter((act) => act.status === 'inscrit'); 85 | registerActivity.map((act) => { 86 | const findAct = this.#xpAct.find((elem) => elem.name === act.type || elem.alias.includes(act.type)); 87 | const { xpWinPart, limitPart, nbPart } = findAct; 88 | (limitPart === -1 || nbPart <= limitPart) && (this.#me.nbXpsSoon += xpWinPart) && findAct.nbPart++; 89 | }); 90 | }; 91 | 92 | getMeVariable = () => { 93 | return this.#me; 94 | } 95 | 96 | getnbXps = () => { 97 | return this.#me.nbXps; 98 | } 99 | 100 | getxpAct = () => { 101 | return this.#xpAct; 102 | } 103 | } 104 | 105 | export { XPHub }; 106 | -------------------------------------------------------------------------------- /public/scripts/data/epitech_data.js: -------------------------------------------------------------------------------- 1 | const epitechData = { 2 | "update_date": "2024-02-05", 3 | "roadblocks_name": { 4 | "professional_writings": "Professional Communication", 5 | "technical_foundation": "Technical Foundation", 6 | "technical_supplement": "Technical Supplement", 7 | "innovation": "Innovation & Professionalization", 8 | "softskills": "Soft Skills" 9 | }, 10 | "promo_requirements": { 11 | "pge1": { 12 | "units": { 13 | "professional_writings": ["B-PRO-100", "B-PRO-200"], 14 | "technical_foundation": ["B-CPE-100", "B-CPE-101", "B-CPE-110", "B-PSU-100", "B-CPE-200", "B-PSU-200"], 15 | "technical_supplement": ["B-MUL-100", "B-MAT-100", "B-MUL-200", "B-AIA-200", "B-NSA-100", "B-WEB-200", "B-MAT-200", "B-DOP-200", "B-SEC-200"], 16 | "innovation": ["G-JAM-001", "B-INN-200" ,"G-CUS-001" ,"G-CUS-002" ,"G-CUS-003" ,"G-CUS-004" ,"G-CUS-005" ,"G-CUS-006" ,"G-CUS-007" ,"G-CUS-008"], 17 | "softskills": ["B-PRO-100", "B-PRO-200", "B-PMP-100", "B-PMP-200", "B-PCP-000"], 18 | "solo_stumper": ["B-CPE-210"], 19 | "duo_stumper": ["B-CPE-210"] 20 | }, 21 | "roadblocks": { 22 | "technical_foundation": 20, 23 | "technical_supplement": 8, 24 | "innovation": 3, 25 | "softskills": 3, 26 | "tepitech": 600, 27 | "solo_stumper": 15, 28 | "duo_stumper": 15, 29 | "professional_writings": 0, 30 | "total_credits": 60 31 | } 32 | }, 33 | "pge2": { 34 | "units": { 35 | "professional_writings": ["B-PRO-100", "B-PRO-200", "B-PRO-400"], 36 | "technical_foundation": ["B-CCP-400", "B-NWP-400", "B-OOP-400", "B-PDG-300", "B-YEP-400"], 37 | "technical_supplement": ["B-CNA-410", "B-MAT-400", "B-ASM-400", "B-FUN-400", "B-DOP-400", "B-PSU-400", "B-SEC-400"], 38 | "innovation": ["G-JAM-001", "B-INN-400" ,"G-CUS-001" ,"G-CUS-002" ,"G-CUS-003" ,"G-CUS-004" ,"G-CUS-005" ,"G-CUS-006" ,"G-CUS-007" ,"G-CUS-008"], 39 | "softskills": ["B-PRO-400", "B-PCP-000", "B-PMP-400"] 40 | }, 41 | "roadblocks": { 42 | "technical_foundation": 13, 43 | "technical_supplement": 6, 44 | "innovation": 4, 45 | "softskills": 3, 46 | "tepitech": 700, 47 | "professional_writings": 1, 48 | "total_credits": 120 49 | } 50 | }, 51 | "pge3": { 52 | "units": { 53 | "professional_writings": ["B-PRO-100", "B-PRO-200", "B-PRO-400", "B-PRO-510"], 54 | "technical_foundation": ["B-CPP-500", "B-DEV-500", "B-FUN-500"], 55 | "technical_supplement": ["B-AIA-500", "B-DOP-500", "B-MAT-500", "B-SEC-500", "B-CNA-500"], 56 | "innovation": ["G-JAM-001", "B-INN-500" ,"G-CUS-001" ,"G-CUS-002" ,"G-CUS-003" ,"G-CUS-004" ,"G-CUS-005" ,"G-CUS-006" ,"G-CUS-007" ,"G-CUS-008", "B-PRO-500"], 57 | "softskills": ["B-DES-500", "B-SVR-500", "B-PRO-510", "B-PCP-000"] 58 | }, 59 | "roadblocks": { 60 | "technical_foundation": 8, 61 | "technical_supplement": 6, 62 | "innovation": 6, 63 | "softskills": 4, 64 | "tepitech": 750, 65 | "professional_writings": 2, 66 | "total_credits": 180 67 | } 68 | } 69 | }, 70 | "hub": { 71 | "unit": "B-INN-000", 72 | "max_credits": { 73 | "pge1": 5, 74 | "pge2": 8, 75 | "pge3": 8 76 | }, 77 | "activities": [ 78 | { 79 | "name": "Talk", 80 | "alias": [ 81 | "Meetup" 82 | ], 83 | "xpWinPart": 1, 84 | "xpWinOrg": 4, 85 | "xpLostPart": 1, 86 | "limitPart": 15, 87 | "limitOrg": 6 88 | }, 89 | { 90 | "name": "Workshop", 91 | "alias": [], 92 | "xpWinPart": 2, 93 | "xpWinOrg": 7, 94 | "xpLostPart": 2, 95 | "limitPart": 10, 96 | "limitOrg": 3 97 | }, 98 | { 99 | "name": "Hackathon", 100 | "alias": [], 101 | "xpWinPart": 6, 102 | "xpWinOrg": 15, 103 | "xpLostPart": 6, 104 | "limitPart": -1, 105 | "limitOrg": -1 106 | }, 107 | { 108 | "name": "Experience", 109 | "alias": [], 110 | "xpWinPart": 3, 111 | "xpWinOrg": 0, 112 | "xpLostPart": 0, 113 | "limitPart": 8, 114 | "limitOrg": 0 115 | }, 116 | { 117 | "name": "Project", 118 | "alias": [], 119 | "xpWinPart": -1, 120 | "xpLostPart": -1, 121 | "limitPart": -1, 122 | "limitOrg": -1 123 | } 124 | ] 125 | } 126 | } 127 | 128 | export { epitechData }; 129 | -------------------------------------------------------------------------------- /public/scripts/index.js: -------------------------------------------------------------------------------- 1 | import { ApiData } from "./classes/ApiData.js"; 2 | import { EpitechData } from "./classes/EpitechData.js"; 3 | import { XPHub } from "./classes/XPHubApi.js"; 4 | import { parseJwtToken } from "./utils/crypto.js"; 5 | import { retrieveData } from "./retrieveData/retrieveData.js"; 6 | import { getData } from "./utils/webStorage.js"; 7 | 8 | /* global chrome */ 9 | 10 | async function initApiData() { 11 | let refresh_token = getData('refresh_token'); 12 | 13 | if (!refresh_token) { 14 | const data = await new Promise(resolve => { 15 | chrome.runtime.sendMessage({ command: 'GET_TOKEN' }, resolve); 16 | }); 17 | if (!data || data['status'] !== true) { 18 | console.error(data['error']); 19 | return undefined; 20 | } 21 | refresh_token = data['refresh_token']; 22 | } 23 | 24 | const jwtToken = parseJwtToken(refresh_token); 25 | if (!jwtToken['login']) { 26 | console.error("Retrieve email failed."); 27 | return undefined; 28 | } 29 | 30 | const api = new ApiData(); 31 | await api.init(refresh_token, jwtToken['login']); 32 | return api; 33 | } 34 | 35 | window.addEventListener('load', async () => { 36 | const epitechData = new EpitechData(); 37 | const XPHubData = new XPHub(epitechData.getHubActivities()); 38 | const apiData = await initApiData(); 39 | 40 | if (!epitechData || !XPHubData || !apiData) return; 41 | apiData.sendTracking(); 42 | await retrieveData(epitechData, XPHubData, apiData); 43 | }); 44 | -------------------------------------------------------------------------------- /public/scripts/retrieveData/ModuleHandler.js: -------------------------------------------------------------------------------- 1 | async function getModuleInformation(apiData, codeInstance, codeSemester) { //check si on bien inscrit au module + roadblock + projet sinon mettre en gris dans le front 2 | try { 3 | let nodeCompleteData = await apiData.getNodeOnCourseCompleteData({ code: `${codeInstance}`, semester: Number(codeSemester) }); 4 | if (nodeCompleteData === null) { 5 | return null; 6 | } 7 | if (nodeCompleteData === undefined) { 8 | const nodeInfo = await apiData.getNodeOnCourseData({ code: `${codeInstance}`, semester: Number(codeSemester) }); 9 | let nodeData; 10 | nodeData = nodeInfo.find((item) => item.status !== "notregistered") || nodeInfo[0]; 11 | nodeCompleteData = await apiData.getCompleteDataFromApi(nodeData.code, nodeData.codeinstance); 12 | apiData.setNodeCourseCompleteData({ code: codeInstance, semester: codeSemester }, nodeCompleteData); 13 | } 14 | if (!nodeCompleteData.error) { 15 | let moduleInfo = { 16 | name: `${nodeCompleteData?.['title'] ?? null}`, 17 | codeInstance: codeInstance, 18 | user_credits: nodeCompleteData?.['user_credits'] ?? null, 19 | credits: nodeCompleteData?.['credits'] ?? null, 20 | student_registered: nodeCompleteData?.['student_registered'] ?? 0, 21 | student_grade: nodeCompleteData?.['student_grade'] ?? null, 22 | student_credits: nodeCompleteData?.['student_credits'] ?? 0, 23 | color: nodeCompleteData?.['color'] ?? null 24 | }; 25 | return moduleInfo; 26 | } 27 | } catch (error) { 28 | return false; 29 | } 30 | return false; 31 | } 32 | 33 | export { getModuleInformation }; 34 | -------------------------------------------------------------------------------- /public/scripts/retrieveData/TEpitech.js: -------------------------------------------------------------------------------- 1 | function getFilteredTEPitech(dataApi, regex, notes) { 2 | return notes.filter(element => { 3 | return element.codemodule === 'B-ANG-058' && regex.test(element.title) && (element.scolaryear).toString() === dataApi.getScolarYear() 4 | }); 5 | } 6 | 7 | function getHighestTEpitech(dataApi) { 8 | const generalNotesData = dataApi.getGeneralNotesData(); 9 | var highestTEpitech = 0; 10 | 11 | const regex = /TEPitech(?!.*Self-assessment)/; 12 | const filteredTEPitech = getFilteredTEPitech(dataApi, regex, generalNotesData['notes']); 13 | 14 | const bestTEPitechElement = filteredTEPitech.find(element => element.title === "Best TEPitech score of the year"); 15 | if (bestTEPitechElement) return bestTEPitechElement.final_note; 16 | 17 | filteredTEPitech.forEach(element => { 18 | highestTEpitech = (element.final_note > highestTEpitech ? element.final_note : highestTEpitech) 19 | }); 20 | return highestTEpitech; 21 | } 22 | 23 | function extractSectionsAndScores(comment) { 24 | const regex = /([\w&]+(?: \w+)*):(\d+)\/(\d+)/g; 25 | const sectionsAndScores = {}; 26 | let match; 27 | 28 | while ((match = regex.exec(comment)) !== null) { 29 | const section = match[1].trim().toLowerCase(); 30 | const score = match[2] ? parseInt(match[2], 10) : null; 31 | const total = match[3] ? parseInt(match[3], 10) : null; 32 | sectionsAndScores[section] = { score, total }; 33 | } 34 | return sectionsAndScores; 35 | } 36 | 37 | function getTEpitechStatistics(dataApi) { 38 | const generalNotesData = dataApi.getGeneralNotesData(); 39 | 40 | const regex = /TEPitech/; 41 | const filteredTEPitech = getFilteredTEPitech(dataApi, regex, generalNotesData['notes']); 42 | 43 | return filteredTEPitech.map(node => { 44 | return { 45 | date: node.date ?? null, 46 | final_note: node.final_note ?? 0, 47 | sections: (extractSectionsAndScores(node.comment) ?? null), 48 | }; 49 | }); 50 | } 51 | 52 | export { getHighestTEpitech, getTEpitechStatistics }; 53 | -------------------------------------------------------------------------------- /public/scripts/retrieveData/XPHub.js: -------------------------------------------------------------------------------- 1 | import { updateFrontend } from "../utils/updateFrontend.js"; 2 | 3 | export const getXPHubData = async (epitechData, apiData, XPHubData) => { 4 | const generalNotesData = await apiData.getGeneralNotesData(); 5 | const hubUnit = epitechData.getHubUnit(); 6 | 7 | const location = apiData.getUserLocation(); 8 | const pays = location[0]; 9 | const region = location[1]; 10 | 11 | let activitiesPublic = await apiData.getNodeOnCourseCompleteData({ code: hubUnit, codeinstance: `${pays}-0-1` }); 12 | let activitiesCampus = await apiData.getNodeOnCourseCompleteData({ code: hubUnit, codeinstance: `${region}-0-1` }); 13 | if (activitiesPublic === undefined) { 14 | activitiesPublic = null; 15 | activitiesPublic = (await apiData.getCompleteDataFromApi(hubUnit, `${pays}-0-1`)); 16 | apiData.setNodeCourseCompleteData({ code: hubUnit, codeinstance: `${pays}-0-1` }, activitiesPublic); 17 | } 18 | if (activitiesCampus === undefined) { 19 | activitiesCampus = null; 20 | activitiesCampus = (await apiData.getCompleteDataFromApi(hubUnit, `${region}-0-1`)); 21 | apiData.setNodeCourseCompleteData({ code: hubUnit, codeinstance: `${region}-0-1` }, activitiesCampus); 22 | } 23 | const everyActivities = [ 24 | ...(activitiesPublic?.activites || []), 25 | ...(activitiesCampus?.activites || []) 26 | ]; 27 | everyActivities.map((activite) => { 28 | if (activite.type_title && activite.type_title === "Project") { 29 | // for optimisation, we can store each project and do addProject with every project to avoid multiple loops of generalNotesData 30 | XPHubData.addProject(generalNotesData['notes'], activite.codeacti, activite.begin, activite.end); 31 | } else { 32 | activite.events.map((event) => { 33 | if (event.user_status) 34 | XPHubData.addActivite(activite.title, activite.type_title, event.user_status, event.begin); 35 | else if (event.assistants.find((assistant) => assistant.login === apiData.getUserEmail())) 36 | XPHubData.addActivite(activite.title, activite.type_title, 'organisateur', event.begin); 37 | else if (event.already_register) XPHubData.addActivite(activite.title, activite.type_title, 'soon', event.begin); 38 | }); 39 | } 40 | }); 41 | XPHubData.countXpSoon(); 42 | const XPHub_me = await XPHubData.getMeVariable(); 43 | let XPHub_xpAct = await XPHubData.getxpAct(); 44 | XPHub_xpAct.map((node) => { 45 | const types = node.alias; 46 | types.push(node.name); 47 | 48 | const activList = XPHub_me.activList; 49 | const activities = activList.filter((act) => types.includes(act.type)); 50 | node['activities'] = activities; 51 | }); 52 | const XPHub = { 53 | xp_completed: XPHub_me.nbXps, 54 | xp_in_progress: XPHub_me.nbXpsSoon, 55 | xp_lost: XPHub_me.nbXpsLost, 56 | activities_per_type: XPHub_xpAct, 57 | }; 58 | updateFrontend('hub', XPHub); 59 | } 60 | -------------------------------------------------------------------------------- /public/scripts/retrieveData/retrieveData.js: -------------------------------------------------------------------------------- 1 | import { getHighestTEpitech, getTEpitechStatistics } from "./TEpitech.js"; 2 | import { updateFrontend } from "../utils/updateFrontend.js"; 3 | import { updateRoadBlockInformation } from "./roadBlock.js"; 4 | import { getXPHubData } from "./XPHub.js"; 5 | import { updateTimelineChart } from "./timeline.js"; 6 | 7 | function updateSideBarInformation(apiData) { 8 | const generalUserData = apiData.getGeneralUserData(); 9 | if (!generalUserData) return; 10 | 11 | const data = { 12 | name: (generalUserData['title'] ?? ''), 13 | email: apiData.getUserEmail(), 14 | cursus: (generalUserData['course_code'] ?? ''), 15 | semester: (generalUserData['semester_code'] ?? ''), 16 | promo: 'Promotion ' + (generalUserData['promo'] ?? ''), 17 | profilPicture: ("https://intra.epitech.eu" + (generalUserData['picture'] ?? '')), 18 | city: (generalUserData['groups'][0]?.['title'] ?? ''), 19 | }; 20 | 21 | updateFrontend('sidebar', data); 22 | } 23 | 24 | async function updateDashboardInformation(epitechData, apiData) { 25 | const generalUserData = apiData.getGeneralUserData(); 26 | if (!generalUserData) return; 27 | 28 | const data = { 29 | credits: (generalUserData['credits'] ?? 0), 30 | GPA: (generalUserData['gpa'][0]?.['gpa'] ?? 0.00), 31 | highestTEpitech: (getHighestTEpitech(apiData)), 32 | goalTEpitech: epitechData.getRoadblocksRequirementsByType(apiData.getStudentYear(), 'tepitech'), 33 | }; 34 | updateFrontend('dashboard', data); 35 | } 36 | 37 | async function updateTepitechInformation(apiData) { 38 | const generalUserData = apiData.getGeneralUserData(); 39 | if (!generalUserData) return; 40 | 41 | updateFrontend('tepitechs', getTEpitechStatistics(apiData)); 42 | } 43 | 44 | async function updateCreditsInformation(epitechData, apiData) { 45 | const studentYear = apiData.getStudentYear(); 46 | 47 | const credits = apiData.getGeneralUserData()?.['credits'] ?? 0; 48 | const creditsRequirement = epitechData.getCreditsRequirements(studentYear) ?? 0; 49 | const current = await apiData.getCurrent(); 50 | 51 | if (!current || current.length < 1) return; 52 | const availableCredits = current.reduce((sum, module) => { 53 | if (module.grade === '-') 54 | return sum + (parseInt(module.credits) || 0); 55 | return sum; 56 | }, 0) + epitechData.getHubMaxCredits(studentYear); 57 | 58 | const data = { 59 | credits: credits, 60 | neededCredits: creditsRequirement, 61 | availableCredits: availableCredits, 62 | status: ((credits >= creditsRequirement) ? 'requirement_met' : ((credits + availableCredits >= creditsRequirement) ? 'requirement_attainable' : 'credits_below_requirement')), 63 | }; 64 | updateFrontend('credits_requirement', data); 65 | } 66 | 67 | async function fetchManifestJson() { 68 | const response = await fetch('/manifest.json'); 69 | if (!response.ok) return 'null'; 70 | return await response.json(); 71 | } 72 | 73 | async function updateDevelopperInformation(epitechData) { 74 | const manifestJson = await fetchManifestJson(); 75 | const data = { 76 | currentVersion: manifestJson.version, 77 | dataLastUpdate: epitechData.getUpdateDate(), 78 | }; 79 | updateFrontend('developper', data); 80 | } 81 | 82 | export async function retrieveData(epitechData, XPHubData, apiData) { 83 | updateDevelopperInformation(epitechData); 84 | updateSideBarInformation(apiData); 85 | updateDashboardInformation(epitechData, apiData); 86 | updateTepitechInformation(apiData); 87 | await getXPHubData(epitechData, apiData, XPHubData); 88 | updateTimelineChart(apiData); 89 | updateRoadBlockInformation(epitechData, apiData, XPHubData); 90 | updateCreditsInformation(epitechData, apiData); 91 | } 92 | -------------------------------------------------------------------------------- /public/scripts/retrieveData/roadBlock.js: -------------------------------------------------------------------------------- 1 | import { getModuleInformation } from "./ModuleHandler.js"; 2 | import { updateFrontend } from "../utils/updateFrontend.js"; 3 | 4 | function hubUnitInformation(epitechData, XPHubData, studentYear, moduleInfo) { 5 | let newModuleInfo = moduleInfo; 6 | 7 | newModuleInfo.credits = epitechData.getHubMaxCredits(studentYear); 8 | newModuleInfo.student_credits = Math.floor((XPHubData.getnbXps() / 10)); 9 | 10 | if (newModuleInfo.user_credits > newModuleInfo.credits) 11 | newModuleInfo.user_credits = newModuleInfo.credits; 12 | if (newModuleInfo.user_credits >= newModuleInfo.credits) 13 | newModuleInfo.color = 'green'; 14 | 15 | return newModuleInfo; 16 | } 17 | 18 | async function getUnitInformation(epitechData, apiData, XPHubData, studentYear, unitCode) { 19 | const semesterCodeMatch = unitCode.match(/-(\d)/); 20 | if (!semesterCodeMatch || !semesterCodeMatch[1]) return null; 21 | 22 | const moduleInfo = await getModuleInformation(apiData, unitCode, semesterCodeMatch[1]); 23 | if (!moduleInfo) return null; 24 | 25 | if (/B-INN-[0-9]00/.test(unitCode)) { // HUB UNIT 26 | return hubUnitInformation(epitechData, XPHubData, studentYear, moduleInfo); 27 | } else { 28 | return moduleInfo; 29 | } 30 | } 31 | 32 | function roadblockInformation(unitsData, rdKey, rdName, creditNeeded) { 33 | const roadblockData = { 34 | key: rdKey, 35 | name: rdName, 36 | modules: unitsData, 37 | total_roadblock_credits: unitsData.reduce((sum, module) => { 38 | return sum + (module.credits || 0); 39 | }, 0), 40 | available_credits: unitsData.reduce((sum, module) => { 41 | return sum + (parseInt(module.user_credits) || 0); 42 | }, 0), 43 | credit_needed: creditNeeded, 44 | actual_student_credits: unitsData.reduce((sum, module) => { 45 | return sum + (module.student_credits || 0); 46 | }, 0) 47 | }; 48 | return roadblockData; 49 | } 50 | 51 | export async function updateRoadBlockInformation(epitechData, apiData, XPHubData) { 52 | const roadblocksNames = epitechData.getRoadblocksNames(); 53 | const roadblocksKey = Object.keys(roadblocksNames); 54 | const studentYear = apiData.getStudentYear(); 55 | 56 | let roadBlocksData = []; 57 | 58 | for (const rdKey of roadblocksKey) { 59 | const unitsCode = epitechData.getUnitsByType(studentYear, rdKey); 60 | let unitsData = []; 61 | for (const unitCode of unitsCode) { 62 | const unitData = await getUnitInformation(epitechData, apiData, XPHubData, studentYear, unitCode); 63 | if (!unitData) continue; 64 | unitsData.push(unitData); 65 | } 66 | const creditNeeded = epitechData.getRoadblocksRequirementsByType(studentYear, rdKey); 67 | const roadblockData = roadblockInformation(unitsData, rdKey, roadblocksNames[rdKey], creditNeeded); 68 | if (roadblockData.key !== 'professional_writings') // do not show professional_writings 69 | roadBlocksData.push(roadblockData); 70 | } 71 | updateFrontend("roadblocks", roadBlocksData); 72 | } 73 | -------------------------------------------------------------------------------- /public/scripts/retrieveData/timeline.js: -------------------------------------------------------------------------------- 1 | import { updateFrontend } from "../utils/updateFrontend.js"; 2 | 3 | export async function updateTimelineChart(apiData) { 4 | let timeLineData = {}; 5 | const generalCourse = await apiData.getGeneralCourseData(); 6 | const regexSkip = /^(B0|[A-Z]0)|.*Hub.*|.*Roadblock.*|.*Administrative.*|.*Internship.*|.*Hackathon.*/; 7 | for (let node of generalCourse) { 8 | if (node.status === "notregistered") { 9 | continue; 10 | } 11 | if (regexSkip.test(node.title)) { 12 | continue; 13 | } 14 | let nodeCompleteData; 15 | if (node.complete_data === undefined) { 16 | nodeCompleteData = await apiData.getCompleteDataFromApi(node.code, node.codeinstance); 17 | node.complete_data = nodeCompleteData; 18 | } else { 19 | nodeCompleteData = node.complete_data; 20 | } 21 | for (let activite of nodeCompleteData.activites) { 22 | if (activite.is_projet === true && activite.type_code === "proj" && (activite.type_title === "Mini-project" || activite.type_title === "Project")) { 23 | let nodeTitle = node.title.replace(/^[A-Za-z]\d+\s*-\s*/, ''); 24 | if (!timeLineData[nodeTitle]) { 25 | timeLineData[nodeTitle] = []; 26 | } 27 | timeLineData[nodeTitle].push({ 28 | title: activite.title, 29 | begin: activite.begin, 30 | end: activite.end, 31 | end_register: activite.end_register 32 | }); 33 | } 34 | } 35 | } 36 | updateFrontend('timeline', timeLineData); 37 | }; -------------------------------------------------------------------------------- /public/scripts/utils/crypto.js: -------------------------------------------------------------------------------- 1 | async function sha256(str) { 2 | const encoder = new TextEncoder(); 3 | const data = encoder.encode(str); 4 | return await crypto.subtle.digest('SHA-256', data).then(hashBuffer => { 5 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 6 | const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); 7 | return hashHex; 8 | }); 9 | } 10 | 11 | async function generateIdFromStr(email) { 12 | return await sha256(email).then(hashedEmail => { 13 | const generatedId = hashedEmail.substring(0, 32); 14 | return generatedId; 15 | }); 16 | } 17 | 18 | function parseJwtToken(token) { 19 | if (token === undefined || token === null) return undefined; 20 | const base64Url = token.split('.')[1]; 21 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 22 | const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) { 23 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 24 | }).join('')); 25 | return JSON.parse(jsonPayload); 26 | } 27 | 28 | export { generateIdFromStr, parseJwtToken }; 29 | -------------------------------------------------------------------------------- /public/scripts/utils/updateFrontend.js: -------------------------------------------------------------------------------- 1 | export const updateFrontend = async (eventName, data) => { 2 | // console.warn(`[XPHub] Updating frontend with event ${eventName} and data`, data); 3 | const event = new CustomEvent(eventName, { detail: data }); 4 | window.dispatchEvent(event); 5 | } 6 | -------------------------------------------------------------------------------- /public/scripts/utils/webStorage.js: -------------------------------------------------------------------------------- 1 | function storeData(key, value) { 2 | try { 3 | localStorage.setItem(key, value); 4 | return true; 5 | } catch (error) { 6 | console.error('Error while storing data:', error); 7 | return false; 8 | } 9 | } 10 | 11 | function getData(key) { 12 | try { 13 | const storedData = localStorage.getItem(key); 14 | return storedData; 15 | } catch (error) { 16 | console.error('Error while retrieving data:', error); 17 | return null; 18 | } 19 | } 20 | 21 | export { getData, storeData }; 22 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | margin: 0; 3 | padding: 0; 4 | background-color: 'none'; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | } 10 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | import Layout from './Layout'; 4 | import { COLORS, BOX_SHADOW, BORDER_RADIUS } from './styles.js'; 5 | import Announcement from './components/AnnouncementHandler.js'; 6 | 7 | const App = () => { 8 | function handleOverlayClick(event) { 9 | if (event.target === event.currentTarget) { 10 | window.parent.postMessage({ type: 'outsideClick' }, '*'); 11 | } 12 | } 13 | 14 | const overlayStyle = { 15 | position: 'fixed', 16 | top: '0', 17 | left: '0', 18 | height: '100vh', 19 | width: '100vw', 20 | zIndex: '0', 21 | backgroundColor: 'rgba(0, 0, 0, 0.5)' 22 | }; 23 | 24 | const appStyle = { 25 | height: '66vh', 26 | width: '66vw', 27 | position: 'fixed', 28 | top: '50%', 29 | left: '50%', 30 | transform: 'translate(-50%, -50%)', 31 | zIndex: '1' 32 | }; 33 | 34 | return ( 35 |
36 |
37 | 54 | 55 | 56 | 57 | 66 | Il y a un an, nous avons lancé la première version de notre extension pour l'intranet d'Epitech.
67 | Aujourd'hui, nous sommes ravis de vous présenter une mise à jour majeure : notre extension s'ouvre désormais directement dans une popup, offrant un accès plus rapide à vos statistiques.
68 | L'interface a été repensée pour une meilleure expérience utilisateur, avec l'ajout de nouvelles données pour enrichir votre vue d'ensemble de l'année scolaire, et plus encore à venir. 69 |

, 70 |

71 | Cette version est encore en cours d'amélioration, donc des bugs pourraient subsister.
72 | Vos retours sont essentiels pour nous aider à les résoudre et à améliorer l'extension.
73 | Utilisez les issues GitHub pour partager vos suggestions et signaler les problèmes rencontrés. 74 |

, 75 |

76 | Vous pouvez aussi contribuer à maintenir les données de l'extension à jour.
77 | Certaines informations ne sont pas récupérables depuis l'intranet d'Epitech et doivent être actualisées dans le fichier 78 | epitech_data.js.
79 | Signalez toute erreur via les issues GitHub pour garantir la précision des données fournies. 80 |

81 | ]} 82 | /> 83 |
84 |
85 | 86 | ); 87 | }; 88 | 89 | export default App; 90 | // issues GitHub -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/Layout.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Box } from "@mui/material"; 3 | import Sidebar from "./components/Sidebar"; 4 | import Dashboard from "./Pages/Dashboard"; 5 | import Hub from "./Pages/Hub"; 6 | import Roadblock from "./Pages/Roadblock"; 7 | import IconButton from "./components/IconButton"; 8 | import { COLORS, BOX_SHADOW, BORDER_RADIUS } from './styles.js'; 9 | 10 | // icons 11 | import HiveRoundedIcon from '@mui/icons-material/HiveRounded'; 12 | import WidgetsRoundedIcon from '@mui/icons-material/WidgetsRounded'; 13 | import BatchPredictionRoundedIcon from '@mui/icons-material/BatchPredictionRounded'; 14 | import PublicRoundedIcon from '@mui/icons-material/PublicRounded'; 15 | import TEPitech from "./Pages/TEPitech.js"; 16 | import AboutModal from "./components/AboutModal.js"; 17 | 18 | const Layout = () => { 19 | const [selectedPage, setSelectedPage] = useState("dashboard"); 20 | const [isModalOpen, setIsModalOpen] = useState(false); 21 | 22 | const handlePageChange = (page) => { 23 | setSelectedPage(page); 24 | }; 25 | 26 | const openModal = () => { 27 | setIsModalOpen(true); 28 | }; 29 | 30 | const closeModal = () => { 31 | setIsModalOpen(false); 32 | }; 33 | 34 | const renderSelectedPage = () => { 35 | switch (selectedPage) { 36 | case "dashboard": 37 | return ; 38 | case "hub": 39 | return ; 40 | case "roadblock": 41 | return ; 42 | case "tepitech": 43 | default: 44 | return ; 45 | } 46 | }; 47 | 48 | return ( 49 | <> 50 | 59 | 60 | 71 | {renderSelectedPage()} 72 | 73 | 74 | 75 | 85 | handlePageChange("dashboard")} 86 | selected={selectedPage === "dashboard"} 87 | icon={} 88 | tooltipText="Dashboard" 89 | /> 90 | handlePageChange("roadblock")} 92 | selected={selectedPage === "roadblock"} 93 | icon={} 94 | tooltipText="Roadblock" 95 | /> 96 | handlePageChange("hub")} 98 | selected={selectedPage === "hub"} 99 | icon={} 100 | tooltipText="Hub" 101 | /> 102 | handlePageChange("tepitech")} 104 | selected={selectedPage === "tepitech"} 105 | icon={} 106 | tooltipText="TEPitech" 107 | /> 108 | 109 | About e.target.style.opacity = '0.8'} 124 | onMouseLeave={(e) => e.target.style.opacity = '1'} 125 | /> 126 | 127 | 128 | ); 129 | }; 130 | 131 | export default Layout; 132 | -------------------------------------------------------------------------------- /src/Pages/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { useData } from '../contexts/DataContext'; 4 | import SummaryCard from '../components/SummaryCard'; 5 | import TimelineBox from './TimelineBox'; 6 | 7 | const Dashboard = () => { 8 | const { dashboardData, timelineData } = useData(); 9 | 10 | return ( 11 | 21 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default Dashboard; 32 | -------------------------------------------------------------------------------- /src/Pages/Hub.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Box, Skeleton } from '@mui/material'; 3 | import { useData } from '../contexts/DataContext'; 4 | import SummaryCard from '../components/SummaryCard'; 5 | import RowGenerator from '../components/RowGenerator'; 6 | import HubCard from '../components/HubCard'; 7 | 8 | const Hub = () => { 9 | const { hubData } = useData(); 10 | 11 | const itemsPerRow = 2; 12 | 13 | const renderCell = (key, data) => ( 14 | 15 | ); 16 | 17 | const renderSkeletons = () => { 18 | return ( 19 | 20 | {[...Array(4)].map((_, index) => ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | ))} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | return ( 39 | 49 | 54 | 55 | {!hubData ? renderSkeletons() : ( 56 | 57 | )} 58 | 59 | ); 60 | }; 61 | 62 | export default Hub; 63 | -------------------------------------------------------------------------------- /src/Pages/Roadblock.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Skeleton } from '@mui/material'; 3 | import RowGenerator from '../components/RowGenerator'; 4 | import { useData } from '../contexts/DataContext'; 5 | import RoadBlockCard from '../components/RoadBlockCard'; 6 | 7 | const Roadblock = () => { 8 | const { roadblockData } = useData(); 9 | const itemsPerRow = 2; 10 | 11 | const renderCell = (key, data) => ( 12 | 13 | ); 14 | 15 | const renderSkeletons = () => { 16 | return ( 17 | 18 | {[...Array(4)].map((_, index) => ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | ))} 26 | 27 | ); 28 | }; 29 | 30 | return ( 31 | 42 | {!roadblockData ? renderSkeletons() : ( 43 | 44 | )} 45 | 46 | ); 47 | }; 48 | 49 | export default Roadblock; -------------------------------------------------------------------------------- /src/Pages/TEPitech.css: -------------------------------------------------------------------------------- 1 | .apexcharts-tooltip-container { 2 | background: #f9f9f9; 3 | } 4 | 5 | .apexcharts-tooltip-content { 6 | padding: 10px; 7 | padding-top: 4px; 8 | } 9 | 10 | .apexcharts-tooltip { 11 | transform: translateX(0px) translateY(-20px); 12 | overflow: visible !important; 13 | white-space: normal !important; 14 | } -------------------------------------------------------------------------------- /src/Pages/TEPitech.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Chart from 'react-apexcharts'; 3 | import { Box, Skeleton, Typography } from '@mui/material'; 4 | import { useData } from '../contexts/DataContext'; 5 | import './TEPitech.css'; 6 | import { COLORS } from '../styles'; 7 | 8 | const renderSkeletons = () => { 9 | return ( 10 | 11 | 12 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | const ShowChart = ({ tepitechs }) => { 25 | 26 | if (!tepitechs) return renderSkeletons(); 27 | const chartData = tepitechs.map((tepitech) => ({ 28 | x: new Date(tepitech.date), 29 | y: tepitech.final_note, 30 | })); 31 | 32 | const chartOptions = { 33 | chart: { 34 | type: 'line', 35 | zoom: { 36 | enabled: false, 37 | }, 38 | toolbar: { 39 | show: false, 40 | }, 41 | animations: { 42 | enabled: true, 43 | easing: 'easeinout', 44 | speed: 800, 45 | }, 46 | }, 47 | legend: { 48 | show: false 49 | }, 50 | stroke: { 51 | curve: 'smooth', 52 | }, 53 | xaxis: { 54 | type: 'datetime', 55 | categories: chartData.map((data) => data.x), 56 | labels: { 57 | format: 'dd/MM/yyyy', 58 | } 59 | }, 60 | yaxis: { 61 | title: { 62 | text: 'Notes', 63 | }, 64 | min: 0, 65 | max: 980, 66 | }, 67 | tooltip: { 68 | custom: ({ series, seriesIndex, dataPointIndex, w }) => { 69 | const data = tepitechs[dataPointIndex]; 70 | const note = data?.final_note || '-'; 71 | const sections = data?.sections ? Object.entries(data.sections) 72 | .map(([sectionName, { score, total }]) => `
${sectionName}: ${score}/${total}
`) 73 | .join('') : ''; 74 | 75 | return ` 76 |
77 |
${note}/980 points
78 |
79 | ${sections} 80 |
81 |
82 | `; 83 | }, 84 | }, 85 | }; 86 | 87 | return ( 88 | {/* 60px est la hauteur du titre */} 89 | data.y) }]} type="line" height='100%' /> 90 | 91 | ); 92 | }; 93 | 94 | const TEPitech = () => { 95 | const { tepitechs } = useData(); 96 | 97 | return ( 98 | 108 | 122 | 123 | 129 | TEPitech - Notes 130 | 131 | {tepitechs && tepitechs.length === 0 ? renderSkeletons() : ShowChart({ tepitechs })} 132 | 133 | 134 | 135 | ); 136 | }; 137 | 138 | export default TEPitech; 139 | -------------------------------------------------------------------------------- /src/Pages/TimelineBox.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Box, IconButton, Modal, Skeleton, Slide } from '@mui/material'; 3 | import ChartComponent from "../components/ChartComponent"; 4 | import FullscreenIcon from '@mui/icons-material/Fullscreen'; 5 | import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; 6 | import { COLORS, BOX_SHADOW } from '../styles.js'; 7 | 8 | const TimelineBox = ({ timelineData }) => { 9 | const [isModalOpen, setIsModalOpen] = useState(false); 10 | 11 | const openModal = () => { 12 | setIsModalOpen(true); 13 | }; 14 | 15 | const closeModal = () => { 16 | setIsModalOpen(false); 17 | }; 18 | 19 | const renderSkeletons = () => { 20 | return ( 21 | 22 | 23 | 30 | 31 | 32 | 33 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | return ( 49 | 65 | 66 | {/* {renderSkeletons()} */} 67 | {!timelineData ? renderSkeletons() : ( 68 | 78 | 79 | 80 | )} 81 | 82 | {timelineData && } 83 | 93 | 94 | 103 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | ); 120 | }; 121 | 122 | export default TimelineBox; -------------------------------------------------------------------------------- /src/components/AboutModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal, Box, Typography, Link, IconButton } from '@mui/material'; 3 | import CloseIcon from '@mui/icons-material/Close'; 4 | import { useData } from '../contexts/DataContext'; 5 | 6 | const SettingsModal = ({ isOpen, handleClose }) => { 7 | const { developperData } = useData(); 8 | 9 | return ( 10 | 15 | 20 | 27 | 28 | 33 | Settings 34 | 35 | 36 | 37 | 38 | Actual version: {developperData?.currentVersion ?? '-'} 39 | Last data file update: {developperData?.dataLastUpdate ?? '-'} 40 | 41 | 42 | To report a bug, suggest a feature or report an incorrect data, please consult {' '} 43 | issues on GitHub. 44 | 45 | 46 | 47 | 48 | Developed by RedBoardDev 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default SettingsModal; 58 | -------------------------------------------------------------------------------- /src/components/AnnouncementHandler.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Box, Button, Checkbox, Typography } from '@mui/material'; 3 | 4 | const Announcement = ({ id, titles, messages }) => { 5 | const [currentPage, setCurrentPage] = useState(0); 6 | const [showAnnouncement, setShowAnnouncement] = useState(false); 7 | const [showAgain, setShowAgain] = useState(false); 8 | 9 | useEffect(() => { 10 | const seenAnnouncement = localStorage.getItem(id); 11 | if (!seenAnnouncement) { 12 | setShowAnnouncement(true); 13 | } 14 | }, [currentPage, titles, id]); 15 | 16 | const handleClose = () => { 17 | if (currentPage < titles.length - 1) { 18 | setCurrentPage(currentPage + 1); 19 | } else { 20 | setShowAnnouncement(false); 21 | if (showAgain) { 22 | localStorage.setItem(id, 'seen'); 23 | } 24 | } 25 | }; 26 | 27 | if (!showAnnouncement) return null; 28 | 29 | return ( 30 | <> 31 | 42 | 56 | 57 | 67 | 68 | 69 | 70 | {titles[currentPage]} 71 | {messages[currentPage]} 72 | 73 | 74 | 75 | 76 | {currentPage >= titles.length - 1 && ( 77 | <> 78 | setShowAgain(e.target.checked)} 81 | /> 82 | Ne plus afficher 83 | 84 | )} 85 | 86 | 87 | 88 | 89 | 90 | ); 91 | }; 92 | 93 | export default Announcement; 94 | -------------------------------------------------------------------------------- /src/components/ChartComponent.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Chart from "react-apexcharts"; 3 | 4 | const defaultOptions = { 5 | colors: ['#FFA500', '#FF6347', '#228B22', '#9370DB', '#4B0082'], 6 | chart: { 7 | height: '100%', 8 | type: 'rangeBar', 9 | zoom: { 10 | enabled: false, 11 | }, 12 | toolbar: { 13 | show: false, 14 | }, 15 | animations: { 16 | enabled: true, 17 | easing: 'easeinout', 18 | speed: 600, 19 | }, 20 | }, 21 | plotOptions: { 22 | bar: { 23 | horizontal: true, 24 | borderRadius: 1, 25 | barHeight: '16%', 26 | rangeBarGroupRows: true, 27 | } 28 | }, 29 | yaxis: { 30 | type: 'category' 31 | }, 32 | xaxis: { 33 | type: 'datetime', 34 | labels: { 35 | format: 'dd/MM', 36 | } 37 | }, 38 | fill: { 39 | type: 'solid', 40 | opacity: 0.9 41 | }, 42 | legend: { 43 | show: false 44 | }, 45 | grid: { 46 | borderColor: '#C2C2C2', 47 | strokeDashArray: 2, 48 | xaxis: { 49 | lines: { 50 | show: true 51 | } 52 | }, 53 | yaxis: { 54 | lines: { 55 | show: true 56 | } 57 | } 58 | }, 59 | annotations: { 60 | xaxis: [ 61 | { 62 | x: new Date().getTime(), 63 | strokeDashArray: 0, 64 | borderColor: "#1c325c", 65 | opacity: 0.6, 66 | label: { 67 | borderColor: "#1c325c", 68 | style: { 69 | color: "#fff", 70 | background: "#1c325c" 71 | }, 72 | text: "Today" 73 | } 74 | }, 75 | ], 76 | }, 77 | }; 78 | 79 | const fullscreenOptions = { 80 | legend: { 81 | horizontalAlign: 'right', 82 | onItemClick: { 83 | toggleDataSeries: true 84 | }, 85 | onItemHover: { 86 | highlightDataSeries: true 87 | }, 88 | }, 89 | chart: { 90 | toolbar: { 91 | show: true, 92 | offsetX: 0, 93 | offsetY: 0, 94 | tools: { 95 | download: true, 96 | selection: false, 97 | zoom: false, 98 | zoomin: false, 99 | zoomout: false, 100 | pan: false, 101 | reset: false, 102 | }, 103 | autoSelected: 'download' 104 | }, 105 | } 106 | }; 107 | 108 | function ChartComponent({ data = {}, fullscreen = false }) { 109 | const options = fullscreen ? { ...defaultOptions, ...fullscreenOptions } : defaultOptions; 110 | 111 | return ( 112 |
116 | 123 |
124 | ); 125 | } 126 | 127 | export default ChartComponent; 128 | -------------------------------------------------------------------------------- /src/components/CustomToolTip.js: -------------------------------------------------------------------------------- 1 | import { Tooltip as ToolTipMUI } from '@mui/material'; 2 | import React from 'react'; 3 | 4 | const CustomToolTip = ({ title, placement, children }) => { 5 | return ( 6 |
7 | 23 | {children} 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default CustomToolTip; 30 | -------------------------------------------------------------------------------- /src/components/HubCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Typography } from '@mui/material'; 3 | import { useState, useEffect } from 'react'; 4 | import { COLORS, BOX_SHADOW } from '../styles.js'; 5 | import CustomToolTip from './CustomToolTip.js'; 6 | 7 | const CardInfo = ({ module }) => { 8 | const [moduleColor, setModuleColor] = useState('#bf6c1b'); 9 | 10 | // #bf6c1b orange 11 | // #2d962d green 12 | // #f25050 red 13 | // #a18716 grey yellow 14 | 15 | useEffect(() => { 16 | switch (module.status) { 17 | case 'soon': 18 | setModuleColor('#bf6c1b'); 19 | break; 20 | case 'present': 21 | setModuleColor('#2d962d'); 22 | break; 23 | case 'absent': 24 | setModuleColor('#f25050'); 25 | break; 26 | case 'organisateur': 27 | setModuleColor('#a18716'); 28 | break; 29 | default: 30 | setModuleColor('#bf6c1b'); 31 | break; 32 | } 33 | }, [module]); 34 | 35 | return ( 36 | 37 | 38 | {module.title.replace(/\[(.*?)\]/g, '')} 39 | 40 | 41 | ); 42 | }; 43 | 44 | const HubCard = ({ data }) => { 45 | const { 46 | name, 47 | activities, 48 | alias, 49 | limitOrg, 50 | limitPart, 51 | nbOrg, 52 | nbPart, 53 | nbXPTotal, 54 | xpLostPart, 55 | xpWinOrg, 56 | xpWinPart 57 | } = data; 58 | if (data === undefined) return null; 59 | return ( 60 | 75 | 84 | 89 | {name} - {nbXPTotal} XP 90 | 91 | 97 | {nbPart}/{(limitPart === -1 ? '∞' : limitPart) ?? '∞'} 98 | 99 | 100 | 106 | {activities && activities.length > 0 && activities.map((activity) => ( 107 | 108 | ))} 109 | 110 | 111 | ); 112 | }; 113 | 114 | export default HubCard; -------------------------------------------------------------------------------- /src/components/IconButton.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconButton as IconButtonMui, Tooltip } from "@mui/material"; 3 | 4 | const IconButton = ({ onClick, selected, icon, tooltipText }) => { 5 | return ( 6 | 7 | 11 | {React.cloneElement(icon, { style: { fontSize: 40 } })} 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default IconButton; 18 | -------------------------------------------------------------------------------- /src/components/RoadBlockCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Typography } from '@mui/material'; 3 | import { useState, useEffect } from 'react'; 4 | import { COLORS, BOX_SHADOW } from '../styles.js'; 5 | import CustomToolTip from './CustomToolTip.js'; 6 | 7 | const extractModuleInfo = (moduleName) => { 8 | const regex = /(\[[A-Z]-[A-Z]+-\d+\])\s*(\w+)\s*-\s*(.+)/; 9 | const matches = moduleName.match(regex); 10 | 11 | if (matches) { 12 | const code = matches[2]; 13 | const restOfString = matches[3]; 14 | const moduleCompleteName = `[${code}] - ${restOfString}`; 15 | 16 | return `${moduleCompleteName}`; 17 | } 18 | return moduleName; 19 | }; 20 | 21 | const extractStudentNotes = (user_credits = 0, credits = 0, grade = undefined) => { 22 | const creditsString = `${user_credits ?? 0}/${credits} credits`; 23 | 24 | if (!grade || grade === 'N/A') 25 | return `${creditsString}`; 26 | return `${grade.toString()} - ${creditsString}`; 27 | } 28 | 29 | const determineTooltip = (data) => { 30 | if (data === 'N/A') 31 | return 'Not rated'; 32 | if (!data) 33 | return "Not registered"; 34 | if (data === 'ECHEC') 35 | return 'Failed'; 36 | return 'Passed'; 37 | } 38 | 39 | const ModuleInfo = ({ module }) => { 40 | const [moduleColor, setModuleColor] = useState('#8d9396'); 41 | 42 | // #bf6c1b orange 43 | // #2d962d green 44 | // #f25050 red 45 | // #8d9396 grey 46 | 47 | useEffect(() => { 48 | switch (module.color) { 49 | case 'orange': 50 | setModuleColor('#bf6c1b'); 51 | break; 52 | case 'green': 53 | setModuleColor('#2d962d'); 54 | break; 55 | case 'red': 56 | setModuleColor('#f25050'); 57 | break; 58 | default: 59 | setModuleColor('#8d9396'); 60 | break; 61 | } 62 | }, [module]); 63 | 64 | return ( 65 | 66 | 67 | {extractModuleInfo(module.name)} 68 | 69 | {extractStudentNotes(module.student_credits, module.credits, module.student_grade)} 70 | 71 | ); 72 | }; 73 | 74 | const RoadBlockCard = ({ roadblockData }) => { 75 | const { 76 | name: type, 77 | actual_student_credits, 78 | credit_needed, 79 | available_credits, 80 | modules, 81 | } = roadblockData; 82 | 83 | return ( 84 | 96 | 105 | 110 | {type} 111 | 112 | 115 | Not enough credits
116 | You can have {available_credits} credits 117 | 118 | : 'Enough credits'} 119 | placement="left" 120 | > 121 | 128 | {actual_student_credits} / {credit_needed} 129 | 130 |
131 |
132 | 138 | {modules.map((module) => ( 139 | 140 | ))} 141 | 142 |
143 | ); 144 | }; 145 | 146 | export default RoadBlockCard; -------------------------------------------------------------------------------- /src/components/RowGenerator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | 4 | const RowGenerator = ({ data = [], itemsPerRow, renderCell }) => { 5 | let rows = []; 6 | const numRows = Math.ceil(data.length / itemsPerRow); 7 | 8 | for (let i = 0; i < numRows; i++) { 9 | const startIndex = i * itemsPerRow; 10 | const endIndex = startIndex + itemsPerRow; 11 | const rowData = data.slice(startIndex, endIndex); 12 | 13 | rows.push( 14 | 24 | {rowData.map((item, index) => 25 | renderCell(index + startIndex, item))} 26 | 27 | ); 28 | } 29 | 30 | return rows; 31 | }; 32 | 33 | export default RowGenerator; 34 | -------------------------------------------------------------------------------- /src/components/Sidebar.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Box, IconButton, Typography } from '@mui/material'; 3 | import { useData } from '../contexts/DataContext'; 4 | import { COLORS, BOX_SHADOW, BORDER_RADIUS } from '../styles.js'; 5 | 6 | const SidebarRender = ({ city, cursus, email, name, profilPicture, promo, semester, creditPhrase }) => { 7 | return ( 8 | 24 | 35 | 36 | Avatar { 47 | e.target.src = 'https://cdn.pixabay.com/photo/2017/02/23/13/05/avatar-2092113_960_720.png'; 48 | }} 49 | /> 50 | 51 | 52 | {name} 53 | {email} 54 | {city} 55 | {cursus} 56 | semester {semester} 57 | {promo} 58 | 59 | 60 | 61 | {creditPhrase} 62 | 63 | 64 | 65 | 66 | ); 67 | } 68 | 69 | 70 | 71 | const Sidebar = () => { 72 | const { sidebarData, creditsRequirement } = useData(); 73 | 74 | const [city, setCity] = useState('-'); 75 | const [cursus, setCursus] = useState('-'); 76 | const [email, setEmail] = useState('-'); 77 | const [name, setName] = useState('-'); 78 | const [profilPicture, setProfilPicture] = useState('-'); 79 | const [promo, setPromo] = useState('-'); 80 | const [semester, setSemester] = useState('-'); 81 | const [creditPhrase, setCreditPhrase] = useState('Credit information is currently unavailable.'); 82 | const [showSidebar, setShowSidebar] = useState(window.innerWidth >= 1540); 83 | const [sidebarOpen, setSidebarOpen] = useState(false) 84 | 85 | useEffect(() => { 86 | const handleResize = () => { 87 | setShowSidebar(window.innerWidth >= 1540); 88 | setSidebarOpen(window.innerWidth >= 1540); 89 | }; 90 | 91 | window.addEventListener("resize", handleResize); 92 | handleResize(); 93 | 94 | return () => { 95 | window.removeEventListener("resize", handleResize); 96 | }; 97 | }, []); 98 | 99 | useEffect(() => { 100 | setCity(sidebarData.city ?? '-'); 101 | setCursus(sidebarData.cursus ?? '-'); 102 | setEmail(sidebarData.email ?? '-'); 103 | setName(sidebarData.name ?? '-'); 104 | setProfilPicture(sidebarData.profilPicture ?? '-'); 105 | setPromo(sidebarData.promo ?? '-'); 106 | setSemester(sidebarData.semester ?? '-'); 107 | }, [sidebarData]); 108 | 109 | const toggleSidebar = () => { 110 | setSidebarOpen(!sidebarOpen); 111 | }; 112 | 113 | const generateCreditPhrase = (credits, neededCredits, availableCredits, status) => { 114 | 115 | switch (status) { 116 | case 'requirement_met': 117 | return `You have reached the required ${neededCredits} credits for this year. You currently have ${credits} credits and you can obtain ${availableCredits} credits.`; 118 | case 'requirement_attainable': 119 | return `You currently have ${credits} credits, ${neededCredits - credits} credits below the required ${neededCredits}. However, you can obtain ${availableCredits} more credits to meet the requirement.`; 120 | case 'credits_below_requirement': 121 | return `You currently have ${credits} credits, ${neededCredits - credits} credits below the required ${neededCredits}. Unfortunately, you can't meet the requirement with ${availableCredits} more available credits.`; 122 | default: 123 | return 'Credit information is currently unavailable.'; 124 | } 125 | }; 126 | 127 | useEffect(() => { 128 | if (creditsRequirement) { 129 | const { credits, neededCredits, availableCredits, status } = creditsRequirement; 130 | const phrase = generateCreditPhrase(credits, neededCredits, availableCredits, status); 131 | setCreditPhrase(phrase); 132 | } 133 | }, [creditsRequirement]); 134 | 135 | if (!showSidebar) { 136 | return ( 137 | <> 138 | 155 | {sidebarOpen ? "<" : ">"} 156 | 157 | {sidebarOpen && ( 158 | 168 | )} 169 | 170 | ); 171 | } 172 | 173 | return ( 174 | 184 | ) 185 | }; 186 | 187 | export default Sidebar; 188 | -------------------------------------------------------------------------------- /src/components/SummaryCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Tooltip, Typography } from '@mui/material'; 3 | import { COLORS, BOX_SHADOW } from '../styles.js'; 4 | 5 | const CustomCard = ({ title = '-', text = '-', tooltipTitle = null }) => { 6 | return ( 7 | 19 | 33 | 34 | {text} 35 | 36 | 37 | {title} 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | const SummaryCard = ({ cardsData, tooltipTitle = null }) => { 45 | if (!cardsData) return null; 46 | 47 | return ( 48 | 58 | {cardsData.map((card, index) => ( 59 | 60 | ))} 61 | 62 | ); 63 | }; 64 | 65 | export default SummaryCard; 66 | -------------------------------------------------------------------------------- /src/contexts/DataContext.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect } from 'react'; 2 | import { addListener, removeListener } from '../utils/listeners'; 3 | 4 | const DataContext = createContext(); 5 | 6 | function transformDataToSeries(data) { 7 | const courseNames = Object.keys(data); 8 | 9 | let series = []; 10 | 11 | courseNames.forEach(courseName => { 12 | let courseData = data[courseName]; 13 | 14 | courseData.forEach(project => { 15 | let projectIndex = series.findIndex(serie => serie.name === project.title); 16 | 17 | if (projectIndex === -1) { 18 | series.push({ 19 | name: project.title, 20 | data: [{ x: courseName, y: [new Date(project.begin).getTime(), new Date(project.end).getTime()] }] 21 | }); 22 | } else { 23 | series[projectIndex].data.push({ x: courseName, y: [new Date(project.begin).getTime(), new Date(project.end).getTime()] }); 24 | } 25 | }); 26 | }); 27 | return series; 28 | } 29 | 30 | export const DataProvider = ({ children }) => { 31 | const [dashboardData, setDashboardData] = useState({}); 32 | const [sidebarData, setSidebarData] = useState({}); 33 | const [roadblockData, setRoadblockData] = useState(undefined); 34 | const [hubData, setHubData] = useState(null); 35 | const [timelineData, setTimelineData] = useState(undefined); 36 | const [creditsRequirement, setCreditsRequirement] = useState(undefined); 37 | const [developperData, setDevelopperData] = useState({}); 38 | const [tepitechs, setTepitechs] = useState(undefined); 39 | 40 | useEffect(() => { 41 | const handleDashboardUpdate = (event) => { 42 | const { detail } = event; 43 | setDashboardData(detail); 44 | }; 45 | 46 | const handleSidebarUpdate = (event) => { 47 | const { detail } = event; 48 | setSidebarData(detail); 49 | }; 50 | 51 | const handleRoadblockUpdate = (event) => { 52 | const { detail } = event; 53 | setRoadblockData(detail); 54 | } 55 | 56 | const handleHubUpdate = (event) => { 57 | const { detail } = event; 58 | setHubData(detail); 59 | } 60 | 61 | const handleTimelineUpdate = (event) => { 62 | const { detail } = event; 63 | setTimelineData(transformDataToSeries(detail)); 64 | } 65 | 66 | const handleCreditsRequirementUpdate = (event) => { 67 | const { detail } = event; 68 | setCreditsRequirement(detail); 69 | } 70 | 71 | const handleDevelopperUpdate = (event) => { 72 | const { detail } = event; 73 | setDevelopperData(detail); 74 | } 75 | 76 | const handleTepitechsUpdate = (event) => { 77 | const { detail } = event; 78 | setTepitechs(detail); 79 | } 80 | 81 | addListener('sidebar', handleSidebarUpdate); 82 | addListener('dashboard', handleDashboardUpdate); 83 | addListener('roadblocks', handleRoadblockUpdate); 84 | addListener('hub', handleHubUpdate); 85 | addListener('timeline', handleTimelineUpdate); 86 | addListener('credits_requirement', handleCreditsRequirementUpdate); 87 | addListener('developper', handleDevelopperUpdate); 88 | addListener('tepitechs', handleTepitechsUpdate); 89 | 90 | return () => { 91 | removeListener('sidebar', handleSidebarUpdate); 92 | removeListener('dashboard', handleDashboardUpdate); 93 | removeListener('roadblocks', handleRoadblockUpdate); 94 | removeListener('hub', handleHubUpdate); 95 | removeListener('timeline', handleTimelineUpdate); 96 | removeListener('credits_requirement', handleCreditsRequirementUpdate); 97 | removeListener('developper', handleDevelopperUpdate); 98 | removeListener('tepitechs', handleTepitechsUpdate); 99 | }; 100 | }, []); 101 | 102 | return ( 103 | 113 | {children} 114 | 115 | ); 116 | }; 117 | 118 | export const useData = () => { 119 | const context = useContext(DataContext); 120 | if (!context) { 121 | throw new Error('useData must be used within a DataProvider'); 122 | } 123 | return context; 124 | }; 125 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import { DataProvider } from './contexts/DataContext'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | {/*
*/} 22 | 23 | 24 | 25 | {/*
*/} 26 |
27 | ); 28 | 29 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/styles.js: -------------------------------------------------------------------------------- 1 | export const COLORS = { 2 | box2: 'rgba(228, 230, 229, 0.8)', 3 | box22: 'rgba(228, 230, 229, 1.0)', 4 | backgroundBox: 'rgba(157, 163, 166, 0.2)', 5 | sidebar: 'rgba(157, 163, 166, 0.2)', 6 | mainBackground: '#D7D7D7', 7 | }; 8 | 9 | export const BOX_SHADOW = { 10 | box2: '0px 4px 9px rgba(51, 51, 51, 0.1)', 11 | box22: '0px 4px 9px rgba(51, 51, 51, 0.1)', 12 | backgroundBox: '2px 3px 8px rgba(51, 51, 51, 0.0), 0px 0px 12px rgba(51, 51, 51, 0.3)', 13 | sidebar: '2px 3px 8px rgba(51, 51, 51, 0.1), 0px 0px 12px rgba(51, 51, 51, 0.3)', 14 | mainBackground: '0px 0px 8px 0px #1F364D', 15 | }; 16 | 17 | export const BORDER_RADIUS = { 18 | box2: 3, 19 | main: 6 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/listeners.js: -------------------------------------------------------------------------------- 1 | export const addListener = (eventName, callback) => { 2 | window.addEventListener(eventName, callback); 3 | }; 4 | 5 | export const removeListener = (eventName, callback) => { 6 | window.removeEventListener(eventName, callback); 7 | }; 8 | --------------------------------------------------------------------------------