├── src ├── __mocks__ │ ├── styleMock.js │ └── ApiConnector.js ├── guarani │ ├── __snapshots__ │ │ └── PagesDataParser.test.js.snap │ ├── custompages │ │ ├── CustomPages.test.js │ │ ├── CoursesSearchCustomPage.test.js │ │ ├── PlanTrackingCustomPage.test.js │ │ ├── ProfessorsSearchCustomPage.test.js │ │ ├── CustomPages.css │ │ ├── PlanTrackingCustomPage.css │ │ ├── CoursesSearchCustomPage.js │ │ ├── CustomPages.js │ │ ├── PlanTrackingCustomPage.js │ │ └── ProfessorsSearchCustomPage.js │ ├── pages │ │ ├── HorariosPage.css │ │ ├── PreInscripcionPage.css │ │ ├── InscripcionAExamenesPage.js │ │ ├── HorariosPage.test.js │ │ ├── HorariosPage.js │ │ ├── PreInscripcionPage.js │ │ └── __fixtures__ │ │ │ ├── horariosPage.init_empty_agenda.html │ │ │ └── horariosPage.init_missing_cursada_elements.html │ ├── main.css │ ├── Store.js │ ├── PagesDataParser.test.js │ ├── foreground.js │ ├── Consts.js │ ├── main-kolla.js │ ├── Errors.js │ ├── main.js │ ├── Errors.test.js │ ├── DataCollector.js │ ├── Utils.js │ └── __fixtures__ │ │ ├── pagesDataParser.getStudentId_missing_div.html │ │ └── pagesDataParser.getStudentId_successful_parsing.html ├── Embrace.js ├── background.js └── ApiConnector.js ├── public ├── icons │ ├── icon16.png │ ├── icon48.png │ └── icon128.png └── manifest.json ├── screenshots ├── Horarios.png ├── BuscarCursos.png ├── BuscarDocentes.png ├── PreInscripcion.png └── SeguimientoPlan.png ├── .gitignore ├── jest.setup.js ├── .github └── workflows │ └── nodejs-ci.yml ├── pack.js ├── package.json ├── webpack.config.js ├── README.md └── jest.config.js /src/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // noinspection JSUnusedGlobalSymbols 2 | export default {}; 3 | -------------------------------------------------------------------------------- /public/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/public/icons/icon16.png -------------------------------------------------------------------------------- /public/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/public/icons/icon48.png -------------------------------------------------------------------------------- /public/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/public/icons/icon128.png -------------------------------------------------------------------------------- /screenshots/Horarios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/screenshots/Horarios.png -------------------------------------------------------------------------------- /screenshots/BuscarCursos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/screenshots/BuscarCursos.png -------------------------------------------------------------------------------- /screenshots/BuscarDocentes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/screenshots/BuscarDocentes.png -------------------------------------------------------------------------------- /screenshots/PreInscripcion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/screenshots/PreInscripcion.png -------------------------------------------------------------------------------- /screenshots/SeguimientoPlan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablomatiasgomez/utn.ba-helper/HEAD/screenshots/SeguimientoPlan.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | 4 | embrace-token 5 | 6 | # dependencies 7 | /node_modules 8 | npm-debug.log* 9 | 10 | # build dirs 11 | /build 12 | /release 13 | 14 | # tests 15 | /coverage -------------------------------------------------------------------------------- /src/guarani/__snapshots__/PagesDataParser.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`pagesDataParser.getStudentId successful parsing 1`] = `"149.388-7"`; 4 | -------------------------------------------------------------------------------- /src/guarani/custompages/CustomPages.test.js: -------------------------------------------------------------------------------- 1 | import {CustomPages} from './CustomPages.js'; 2 | 3 | describe('CustomPages', () => { 4 | new CustomPages(null, null, null, null) 5 | it.todo('should be tested'); 6 | }); 7 | -------------------------------------------------------------------------------- /src/guarani/custompages/CoursesSearchCustomPage.test.js: -------------------------------------------------------------------------------- 1 | import {CoursesSearchCustomPage} from './CoursesSearchCustomPage.js'; 2 | 3 | describe('CoursesSearchCustomPage', () => { 4 | new CoursesSearchCustomPage(null, null) 5 | it.todo('should be tested'); 6 | }); 7 | -------------------------------------------------------------------------------- /src/guarani/pages/HorariosPage.css: -------------------------------------------------------------------------------- 1 | /* HorariosPage */ 2 | .name-container { 3 | z-index: 5; 4 | white-space: nowrap; 5 | font-size: 11px; 6 | } 7 | 8 | #agenda-holder .top-left-border { 9 | border-top: 1px solid grey; 10 | border-left: 1px solid grey; 11 | } 12 | -------------------------------------------------------------------------------- /src/guarani/custompages/PlanTrackingCustomPage.test.js: -------------------------------------------------------------------------------- 1 | import {CustomPages} from './CustomPages.js'; 2 | import {PlanTrackingCustomPage} from './PlanTrackingCustomPage.js'; 3 | 4 | describe('PlanTrackingCustomPage', () => { 5 | new PlanTrackingCustomPage(null, null) 6 | it.todo('should be tested'); 7 | }); 8 | -------------------------------------------------------------------------------- /src/guarani/custompages/ProfessorsSearchCustomPage.test.js: -------------------------------------------------------------------------------- 1 | import {CustomPages} from './CustomPages.js'; 2 | import {ProfessorsSearchCustomPage} from './ProfessorsSearchCustomPage.js'; 3 | 4 | describe('ProfessorsSearchCustomPage', () => { 5 | new ProfessorsSearchCustomPage(null, null) 6 | it.todo('should be tested'); 7 | }); 8 | -------------------------------------------------------------------------------- /src/guarani/pages/PreInscripcionPage.css: -------------------------------------------------------------------------------- 1 | /** Some other css is breaking this so it's enforced here*/ 2 | .utnba-helper .filters label { 3 | display: initial; 4 | } 5 | 6 | .utnba-helper .filters a { 7 | margin-top: 8px; 8 | } 9 | 10 | .utnba-helper .filters input[type="checkbox"] { 11 | margin-top: 0px; 12 | } 13 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import {TextEncoder, TextDecoder} from 'util'; 2 | 3 | global.TextEncoder = TextEncoder; 4 | global.TextDecoder = TextDecoder; 5 | 6 | // Mock chrome APIs 7 | global.chrome = { 8 | runtime: { 9 | sendMessage: () => { 10 | }, 11 | getURL: (path) => `chrome-extension://test-extension/${path}`, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/guarani/main.css: -------------------------------------------------------------------------------- 1 | .powered-by-utnba-helper { 2 | float: right; 3 | clear: right; 4 | text-transform: uppercase; 5 | font-size: 10px; 6 | letter-spacing: 0.05em; 7 | cursor: pointer; 8 | } 9 | 10 | /* Allows space in the header to add the student id */ 11 | .brand-nav .user-navbar li > a { 12 | padding-top: 5px !important; 13 | } 14 | 15 | .brand-nav .row-fluid .span12 { 16 | min-height: initial !important; 17 | } 18 | -------------------------------------------------------------------------------- /src/guarani/Store.js: -------------------------------------------------------------------------------- 1 | const HASHED_STUDENT_ID_DATASTORE_KEY = "UtnBaHelper.HashedStudentId"; 2 | 3 | export class Store { 4 | readHashedStudentIdFromStore() { 5 | return chrome.storage.sync.get(HASHED_STUDENT_ID_DATASTORE_KEY).then(result => { 6 | return result[HASHED_STUDENT_ID_DATASTORE_KEY]; 7 | }); 8 | } 9 | 10 | saveHashedStudentIdToStore(hashedStudentId) { 11 | // Some old browsers return undefined instead of Promise... so we return an empty one if that happens. 12 | return chrome.storage.sync.set({[HASHED_STUDENT_ID_DATASTORE_KEY]: hashedStudentId}) || Promise.resolve(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Embrace.js: -------------------------------------------------------------------------------- 1 | import {getNavigationInstrumentation, initSDK, session} from '@embrace-io/web-sdk'; 2 | 3 | export function initializeEmbrace(contentScriptName) { 4 | initSDK({ 5 | appID: '08sxm', 6 | appVersion: chrome.runtime.getManifest().version, 7 | defaultInstrumentationConfig: { 8 | 'session-visibility': { 9 | limitedSessionMaxDurationMs: 5000, 10 | }, 11 | }, 12 | instrumentations: [ 13 | getNavigationInstrumentation(), 14 | ], 15 | }); 16 | session.addProperty("content-script", contentScriptName, {lifespan: "permanent"}); 17 | getNavigationInstrumentation().setCurrentRoute({ 18 | url: window.location.pathname, 19 | path: window.location.pathname 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/nodejs-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '22' 21 | cache: 'npm' 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Run tests with coverage 27 | uses: ArtiomTr/jest-coverage-report-action@v2 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | test-script: npm test -- --coverage 31 | -------------------------------------------------------------------------------- /src/guarani/pages/InscripcionAExamenesPage.js: -------------------------------------------------------------------------------- 1 | export class InscripcionAExamenesPage { 2 | #errorMessageSelector = document.querySelector('#lista_materias > div.alert.info.strong'); 3 | 4 | #replaceMessageIfExists() { 5 | if (this.#errorMessageSelector) { 6 | const textChild = document.createTextNode(" o haciendo click ") 7 | const redirectLink = document.createElement('a') 8 | redirectLink.href = "/autogestion/grado/datos_censales" 9 | redirectLink.textContent = "AQUÍ" 10 | 11 | this.#errorMessageSelector.appendChild(textChild) 12 | this.#errorMessageSelector.appendChild(redirectLink) 13 | } 14 | } 15 | 16 | init() { 17 | return Promise.resolve().then(() => { 18 | this.#replaceMessageIfExists(); 19 | }); 20 | } 21 | 22 | close() { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pack.js: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync, existsSync, mkdirSync} from "node:fs"; 2 | import {resolve} from "node:path"; 3 | import AdmZip from "adm-zip"; 4 | 5 | const __dirname = import.meta.dirname; 6 | 7 | const {name, version} = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8")); 8 | 9 | // Update version in build/manifest.json 10 | const manifestPath = resolve(__dirname, "build", "manifest.json"); 11 | const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); 12 | manifest.version = version; 13 | writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); 14 | 15 | const outDir = "release"; 16 | const filename = `${name}-v${version}.zip`; 17 | 18 | if (!existsSync(outDir)) mkdirSync(outDir); 19 | 20 | const zip = new AdmZip(); 21 | zip.addLocalFolder("build", "", (filename) => !filename.endsWith(".map")); 22 | zip.writeZip(`${outDir}/${filename}`); 23 | 24 | console.log(`Successfully created ${filename} file under ${outDir} directory.`); 25 | -------------------------------------------------------------------------------- /src/guarani/PagesDataParser.test.js: -------------------------------------------------------------------------------- 1 | import {ApiConnector} from '../__mocks__/ApiConnector.js'; 2 | import {Utils} from './Utils.js'; 3 | import {PagesDataParser} from './PagesDataParser.js'; 4 | 5 | import fs from "node:fs"; 6 | import path from "node:path"; 7 | 8 | const __dirname = import.meta.dirname; 9 | 10 | describe('pagesDataParser.getStudentId', () => { 11 | let apiConnector = new ApiConnector(); 12 | let utils = new Utils(apiConnector); 13 | let pagesDataParser = new PagesDataParser(utils); 14 | 15 | beforeEach(() => { 16 | const inputFile = expect.getState().currentTestName.replaceAll(" ", "_") + '.html'; 17 | document.body.innerHTML = fs.readFileSync(path.resolve(__dirname, './__fixtures__/', inputFile), 'utf8'); 18 | }); 19 | 20 | it('successful parsing', () => { 21 | let studentId = pagesDataParser.getStudentId(); 22 | expect(studentId).toMatchSnapshot(); 23 | }); 24 | 25 | it('missing div', () => { 26 | expect(() => { 27 | pagesDataParser.getStudentId(); 28 | }).toThrow(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/__mocks__/ApiConnector.js: -------------------------------------------------------------------------------- 1 | export class ApiConnector { 2 | // POSTs: 3 | logMessage(method, isError, message) { 4 | } 5 | 6 | logUserStat(hashedStudentId, pesoAcademico, passingGradesAverage, allGradesAverage, passingGradesCount, failingGradesCount) { 7 | } 8 | 9 | postClassSchedules(classSchedules) { 10 | } 11 | 12 | postProfessorSurveys(surveys) { 13 | } 14 | 15 | postCourses(courses) { 16 | } 17 | 18 | // GETs: 19 | getPreviousProfessors(previousProfessorsRequest) { 20 | return Promise.resolve([]); 21 | } 22 | 23 | searchProfessors(query) { 24 | return Promise.resolve([]); 25 | } 26 | 27 | getProfessorSurveysAggregate(professorName) { 28 | return Promise.resolve([]); 29 | } 30 | 31 | getClassesForProfessor(professorName, offset, limit) { 32 | return Promise.resolve([]); 33 | } 34 | 35 | searchCourses(query) { 36 | return Promise.resolve([]); 37 | } 38 | 39 | getPlanCourses(planCode) { 40 | return Promise.resolve([]); 41 | } 42 | 43 | getClassesForCourse(courseCode, offset, limit) { 44 | return Promise.resolve([]); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/guarani/pages/HorariosPage.test.js: -------------------------------------------------------------------------------- 1 | import {ApiConnector} from '../../__mocks__/ApiConnector.js'; 2 | import {Utils} from '../Utils.js'; 3 | import {HorariosPage} from './HorariosPage.js'; 4 | 5 | import fs from "node:fs"; 6 | import path from "node:path"; 7 | 8 | const __dirname = import.meta.dirname; 9 | 10 | describe('horariosPage.init', () => { 11 | let apiConnector = new ApiConnector(); 12 | let utils = new Utils(apiConnector); 13 | let horariosPage; 14 | 15 | beforeEach(async () => { 16 | const inputFile = expect.getState().currentTestName.replaceAll(" ", "_") + '.html'; 17 | document.body.innerHTML = fs.readFileSync(path.resolve(__dirname, './__fixtures__/', inputFile), 'utf8'); 18 | horariosPage = new HorariosPage(utils); 19 | await horariosPage.init(); 20 | }); 21 | 22 | afterEach(() => { 23 | horariosPage.close(); 24 | }); 25 | 26 | it('successful parsing', async () => { 27 | expect(document.body.innerHTML).toMatchSnapshot(); 28 | }); 29 | 30 | it('missing cursada elements', async () => { 31 | expect(document.body.innerHTML).toMatchSnapshot(); 32 | }); 33 | 34 | it('empty agenda', async () => { 35 | expect(document.body.innerHTML).toMatchSnapshot(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/guarani/custompages/CustomPages.css: -------------------------------------------------------------------------------- 1 | .utnba-helper table { 2 | width: initial; 3 | border: 1px solid #a0a0a0; 4 | border-collapse: collapse; 5 | padding: 0; 6 | margin: 0; 7 | vertical-align: middle; 8 | text-align: left; 9 | font-size: 14px; 10 | } 11 | 12 | .utnba-helper table th, .utnba-helper table td { 13 | padding: 4px 8px; 14 | border-left: 1px solid #c0c0c0; 15 | border-right: 1px solid #c0c0c0; 16 | border-bottom: 1px dotted #c0c0c0; 17 | } 18 | 19 | .utnba-helper table tr:hover > td { 20 | background-color: #f5f5f5; 21 | } 22 | 23 | .utnba-helper table tr:first-child > th { 24 | background-color: #707070; 25 | color: #f0f0f0; 26 | text-shadow: 0 1px 1px rgb(0 0 0); 27 | padding: 10px; 28 | text-align: center; 29 | } 30 | 31 | .utnba-helper ul.no-margin { 32 | padding-inline-start: 0; 33 | margin-block-end: 0; 34 | margin-block-start: 0; 35 | } 36 | 37 | .utnba-helper ul.no-margin li { 38 | margin: 1px 0; 39 | } 40 | 41 | .utnba-helper table tr.top-border td { 42 | border-top: 2px solid black; 43 | } 44 | 45 | .utnba-helper table tr.top-border-without-first-cell td:not(:first-child) { 46 | border-top: 2px solid black; 47 | } 48 | -------------------------------------------------------------------------------- /src/guarani/foreground.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | // Used in all pages to know when we navigate through the app 3 | window.history.pushState = (f => function pushState() { 4 | f.apply(this, arguments); // pushState returns void so no need to return value. 5 | window.dispatchEvent(new Event("locationchange")); 6 | })(window.history.pushState); 7 | 8 | window.history.replaceState = (f => function replaceState() { 9 | f.apply(this, arguments); // replaceState returns void so no need to return value. 10 | window.dispatchEvent(new Event("locationchange")); 11 | })(window.history.replaceState); 12 | 13 | window.addEventListener('popstate', () => { 14 | window.dispatchEvent(new Event("locationchange")); 15 | }); 16 | 17 | //------------ 18 | 19 | // Events used in PreInscriptionPage: 20 | kernel.evts.escuchar("comision_preinscripta", e => window.dispatchEvent(new CustomEvent("__utn_ba_event_comision_preinscripta", {detail: e})), true); 21 | kernel.evts.escuchar("comision_despreinscripta", e => window.dispatchEvent(new CustomEvent("__utn_ba_event_comision_despreinscripta", {detail: e})), true); 22 | kernel.evts.escuchar("setear_comisiones_insc_alternativa", e => window.dispatchEvent(new CustomEvent("__utn_ba_event_setear_comisiones_insc_alternativa", {detail: e})), true); 23 | 24 | })(); 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "utn.ba-helper", 3 | "version": "7.2.0", 4 | "description": "A Chrome extension that makes navigating and using the UTN.BA - FRBA website easier.", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "test": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' jest", 9 | "update-golden": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' jest -u", 10 | "watch": "webpack --watch --config webpack.config.js", 11 | "build": "webpack --config webpack.config.js", 12 | "pack": "npm run build && npm run process-sourcemaps && node pack.js && open ./release", 13 | "process-sourcemaps": "embrace-web-cli upload -a 08sxm -t \"$(cat embrace-token)\" -p ./build" 14 | }, 15 | "dependencies": { 16 | "@embrace-io/web-sdk": "^2.9.0", 17 | "chart.js": "^4.5.1", 18 | "jquery": "^3.7.1", 19 | "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" 20 | }, 21 | "devDependencies": { 22 | "@embrace-io/web-cli": "^2.9.0", 23 | "adm-zip": "^0.5.16", 24 | "copy-webpack-plugin": "^13.0.1", 25 | "css-loader": "^7.1.2", 26 | "jest": "^30.2.0", 27 | "jest-environment-jsdom": "^30.2.0", 28 | "mini-css-extract-plugin": "^2.9.4", 29 | "webpack": "^5.103.0", 30 | "webpack-cli": "^6.0.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 3 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 4 | 5 | const __dirname = import.meta.dirname; 6 | 7 | const PATHS = { 8 | src: path.resolve(__dirname, './src'), 9 | build: path.resolve(__dirname, './build'), 10 | }; 11 | 12 | // noinspection JSUnusedGlobalSymbols 13 | export default { 14 | entry: { 15 | "guarani/main": PATHS.src + '/guarani/main.js', 16 | "guarani/main-kolla": PATHS.src + '/guarani/main-kolla.js', 17 | "guarani/foreground": PATHS.src + '/guarani/foreground.js', 18 | "background": PATHS.src + '/background.js', 19 | }, 20 | output: { 21 | path: PATHS.build, 22 | filename: '[name].js', 23 | clean: true, 24 | }, 25 | devtool: 'source-map', 26 | stats: { 27 | all: false, 28 | errors: true, 29 | builtAt: true, 30 | assets: true, 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | // Help webpack in understanding CSS files imported in .js files 36 | test: /\.css$/, 37 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 38 | }, 39 | ], 40 | }, 41 | plugins: [ 42 | // Copy static assets from `public` folder to `build` folder 43 | new CopyWebpackPlugin({ 44 | patterns: [ 45 | { 46 | from: '**/*', 47 | context: 'public', 48 | }, 49 | ], 50 | }), 51 | // Extract CSS into separate files 52 | new MiniCssExtractPlugin({ 53 | filename: '[name].css', 54 | }), 55 | ], 56 | }; 57 | -------------------------------------------------------------------------------- /src/guarani/custompages/PlanTrackingCustomPage.css: -------------------------------------------------------------------------------- 1 | .utnba-helper .plan-tracking tbody td { 2 | vertical-align: top; 3 | } 4 | 5 | .utnba-helper .plan-tracking tbody .course { 6 | margin: 10px 0; 7 | border: 1px solid black; 8 | border-radius: 2px; 9 | padding: 4px; 10 | } 11 | 12 | .utnba-helper .plan-tracking tbody .course .text-small { 13 | font-size: 11px; 14 | text-align: center; 15 | } 16 | 17 | .utnba-helper .plan-tracking tbody .course .text-medium { 18 | font-size: 13px; 19 | text-align: center; 20 | } 21 | 22 | .utnba-helper .plan-tracking tbody hr { 23 | margin: 10px 0; 24 | } 25 | 26 | .utnba-helper .plan-tracking a > .dependency-tooltip { 27 | text-shadow: none; 28 | white-space: nowrap; 29 | position: relative; 30 | margin-right: 100px; 31 | margin-left: -3px; 32 | max-width: 300px; 33 | width: auto; 34 | border-radius: 12px; 35 | background-color: #FFFFB0; 36 | color: #000000; 37 | border: 1px solid #505050; 38 | font-family: arial, serif; 39 | font-size: 12px; 40 | padding: 10px; 41 | z-index: 1; 42 | text-decoration: none; 43 | display: none; 44 | box-shadow: 0 8px 8px -5px #a0a0a0; 45 | } 46 | 47 | .utnba-helper .plan-tracking a > .dependency-tooltip { 48 | text-decoration: none; 49 | position: absolute; 50 | margin-left: 0.6rem; 51 | } 52 | 53 | .utnba-helper .plan-tracking a:hover > .dependency-tooltip { 54 | display: inline; 55 | text-decoration: none; 56 | } 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/guarani/pages/HorariosPage.js: -------------------------------------------------------------------------------- 1 | import './HorariosPage.css'; 2 | 3 | export class HorariosPage { 4 | 5 | #trimCourseName(name) { 6 | name = name.trim(); 7 | if (name.length > 20) { 8 | return name.substring(0, 20) + "..."; 9 | } else { 10 | return name; 11 | } 12 | } 13 | 14 | #getColorFromClass(className) { 15 | const colorRegex = /materia-color-(\d*)/; 16 | let groups = colorRegex.exec(className); 17 | if (!groups) return null; 18 | return groups[1]; 19 | } 20 | 21 | #getClassesByColor() { 22 | let classesByColor = {}; 23 | document.querySelectorAll(".cursada .cursada-header").forEach(element => { 24 | let name = element.querySelector("h4").textContent.trim(); 25 | let color = this.#getColorFromClass(element.querySelector(".cuadrado").className); 26 | if (!name || !color) return; 27 | classesByColor[color] = name; 28 | }); 29 | return classesByColor; 30 | } 31 | 32 | #setCourseNamesInTable() { 33 | let classesByColor = this.#getClassesByColor(); 34 | let last = null; 35 | document.querySelectorAll(".agenda-hora").forEach(element => { 36 | let color = this.#getColorFromClass(element.className); 37 | if (color && last !== color && classesByColor[color]) { 38 | element.textContent = this.#trimCourseName(classesByColor[color]); 39 | element.classList.add("name-container"); 40 | } 41 | last = color; 42 | }); 43 | } 44 | 45 | init() { 46 | return Promise.resolve().then(() => { 47 | this.#setCourseNamesInTable(); 48 | }); 49 | } 50 | 51 | close() { 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "UTN.BA Helper (ex Siga Helper)", 4 | "short_name": "UTN.BA Helper", 5 | "version": "0.0.0", 6 | "description": "UTN.BA Helper facilita el uso de la web de la UTN - FRBA.", 7 | "author": "Pablo Matías Gomez", 8 | "icons": { 9 | "16": "icons/icon16.png", 10 | "48": "icons/icon48.png", 11 | "128": "icons/icon128.png" 12 | }, 13 | "content_scripts": [ 14 | { 15 | "matches": [ 16 | "*://*.guarani.frba.utn.edu.ar/*" 17 | ], 18 | "js": [ 19 | "guarani/main.js" 20 | ], 21 | "css": [ 22 | "guarani/main.css" 23 | ] 24 | }, 25 | { 26 | "matches": [ 27 | "*://*.kolla.frba.utn.edu.ar/*" 28 | ], 29 | "js": [ 30 | "guarani/main-kolla.js" 31 | ], 32 | "css": [ 33 | "guarani/main.css" 34 | ], 35 | "all_frames": true 36 | } 37 | ], 38 | "web_accessible_resources": [ 39 | { 40 | "resources": [ 41 | "guarani/foreground.js" 42 | ], 43 | "matches": [ 44 | "*://*.frba.utn.edu.ar/*" 45 | ], 46 | "use_dynamic_url": true 47 | }, 48 | { 49 | "resources": [ 50 | "guarani/*.js.map" 51 | ], 52 | "matches": [ 53 | "*://*.frba.utn.edu.ar/*" 54 | ] 55 | } 56 | ], 57 | "host_permissions": [ 58 | "*://*.kolla.frba.utn.edu.ar/*" 59 | ], 60 | "background": { 61 | "service_worker": "background.js" 62 | }, 63 | "permissions": [ 64 | "storage" 65 | ] 66 | } -------------------------------------------------------------------------------- /src/guarani/Consts.js: -------------------------------------------------------------------------------- 1 | export const Consts = { 2 | 3 | HOURS: { 4 | "MORNING": { 5 | 0: {start: "7:45", end: "8:30"}, 6 | 1: {start: "8:30", end: "9:15"}, 7 | 2: {start: "9:15", end: "10:00"}, 8 | 3: {start: "10:15", end: "11:00"}, 9 | 4: {start: "11:00", end: "11:45"}, 10 | 5: {start: "11:45", end: "12:30"}, 11 | 6: {start: "12:30", end: "13:15"} 12 | }, 13 | "AFTERNOON": { 14 | 0: {start: "13:30", end: "14:15"}, 15 | 1: {start: "14:15", end: "15:00"}, 16 | 2: {start: "15:00", end: "15:45"}, 17 | 3: {start: "16:00", end: "16:45"}, 18 | 4: {start: "16:45", end: "17:30"}, 19 | 5: {start: "17:30", end: "18:15"}, 20 | 6: {start: "18:15", end: "19:00"}, 21 | }, 22 | "NIGHT": { 23 | 0: {start: "18:15", end: "19:00"}, 24 | 1: {start: "19:00", end: "19:45"}, 25 | 2: {start: "19:45", end: "20:30"}, 26 | 3: {start: "20:45", end: "21:30"}, 27 | 4: {start: "21:30", end: "22:15"}, 28 | 5: {start: "22:15", end: "23:00"}, 29 | } 30 | }, 31 | 32 | DAYS: { 33 | "MONDAY": "Lunes", 34 | "TUESDAY": "Martes", 35 | "WEDNESDAY": "Miercoles", 36 | "THURSDAY": "Jueves", 37 | "FRIDAY": "Viernes", 38 | "SATURDAY": "Sabado", 39 | }, 40 | 41 | TIME_SHIFTS: { 42 | "MORNING": "Mañana", 43 | "AFTERNOON": "Tarde", 44 | "NIGHT": "Noche", 45 | }, 46 | 47 | // Doesn't have to be exact... just using March 10th. 48 | NEW_GRADES_REGULATION_DATE: new Date(2017, 2, 10), 49 | WEIGHTED_GRADES: { 50 | // "Ordenanza 1549" 51 | 1: 1, 52 | 2: 2.67, 53 | 3: 4.33, 54 | 4: 6, 55 | 5: 6.67, 56 | 6: 7.33, 57 | 7: 8, 58 | 8: 8.67, 59 | 9: 9.33, 60 | 10: 10 61 | }, 62 | 63 | }; 64 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | chrome.runtime.onMessage.addListener(function (requestInfo, sender, resolve) { 2 | requestFetch(requestInfo).then(response => { 3 | resolve(response); 4 | }).catch(e => { 5 | resolve({ 6 | // Need to do .toString() as Error is not "JSON-ifiable" and may get erased. 7 | errorStr: `Error executing ${requestInfo.method || "GET"} ${requestInfo.url} - ${e.toString()}` 8 | }); 9 | }); 10 | 11 | return true; 12 | }); 13 | 14 | const requestFetch = function (requestInfo) { 15 | return fetch(requestInfo.url, requestInfo).then(response => { 16 | if (response.ok) { 17 | let contentType = response.headers.get("content-type"); 18 | let isJson = contentType && contentType.indexOf("application/json") !== -1; 19 | let useCharsetDecoder = contentType && contentType.indexOf("charset=iso-8859-1") !== -1; 20 | if (isJson) { 21 | return response.text().then(r => JSON.parse(r)); 22 | } else if (useCharsetDecoder) { 23 | return response.arrayBuffer().then(buffer => new TextDecoder("iso-8859-1").decode(buffer)); 24 | } else { 25 | return response.text(); 26 | } 27 | } else { 28 | if (response.status === 429) { 29 | console.warn(`Got 429 for ${requestInfo.url}, retrying in 1 second...`); 30 | return Promise.resolve().then(delay(1000)).then(() => { 31 | return requestFetch(requestInfo); 32 | }); 33 | } 34 | 35 | return response.text().then(body => { 36 | throw new Error(`Got unexpected ResponseStatus: ${response.status} for url: ${requestInfo.url} - ResponseBody: ${body}`); 37 | }); 38 | } 39 | }); 40 | }; 41 | 42 | const delay = (delayMs) => { 43 | return result => new Promise(resolve => setTimeout(() => resolve(result), delayMs)); 44 | } -------------------------------------------------------------------------------- /src/guarani/main-kolla.js: -------------------------------------------------------------------------------- 1 | import './main.css'; 2 | 3 | import $ from 'jquery'; 4 | import {log} from "@embrace-io/web-sdk"; 5 | 6 | import {initializeEmbrace} from '../Embrace.js'; 7 | 8 | import {ApiConnector} from '../ApiConnector.js'; 9 | import {Utils} from './Utils.js'; 10 | import {Store} from './Store.js'; 11 | import {PagesDataParser} from './PagesDataParser.js'; 12 | 13 | (function () { 14 | initializeEmbrace("main-kolla"); 15 | 16 | let apiConnector = new ApiConnector(); 17 | let utils = new Utils(apiConnector); 18 | utils.runAsync("mainKolla", () => { 19 | let store = new Store(); 20 | let pagesDataParser = new PagesDataParser(utils); 21 | 22 | if (pagesDataParser.kollaSurveyFromCompleted($(document))) { 23 | log.message("Exiting completed kolla survey", 'info', {attributes: {location_href: location.href}}); 24 | return; 25 | } 26 | log.message("Entering kolla survey", 'info', {attributes: {location_href: location.href}}); 27 | 28 | let $btn = $("#formulario .btn-primary[type=submit][onclick]:visible:enabled"); 29 | if (!$btn.length) return utils.logHTML("kollaMissingBtn", 100); 30 | 31 | $btn.on("mousedown", function () { 32 | utils.runAsync("surveyFinished", () => { 33 | return store.readHashedStudentIdFromStore().then(hashedStudentId => { 34 | if (!hashedStudentId) throw new Error(`Couldn't find hashedStudentId within form url ${location.href}.`); 35 | 36 | let surveys = pagesDataParser.parseKollaSurveyForm($(document), document.documentElement.outerHTML); 37 | if (surveys.length) { 38 | surveys.forEach(survey => survey.surveyTaker = hashedStudentId); 39 | return apiConnector.postProfessorSurveys(surveys); 40 | } 41 | }); 42 | }); 43 | }); 44 | }); 45 | })(); 46 | -------------------------------------------------------------------------------- /src/guarani/Errors.js: -------------------------------------------------------------------------------- 1 | // RedirectedToHomeError is thrown when a request to Guarani's backend returns that the user's session is expired. 2 | export class LoggedOutError extends Error { 3 | constructor(message = "User has been logged out!", options) { 4 | super(message, options); 5 | this.name = "LoggedOutError"; 6 | } 7 | } 8 | 9 | // RedirectedToHomeError is thrown when a request to Guarani's backend returns that the user was redirected to the home. 10 | export class RedirectedToHomeError extends Error { 11 | constructor(message = "Request has been redirected to home", options) { 12 | super(message, options); 13 | this.name = "RedirectedToHomeError"; 14 | } 15 | } 16 | 17 | // GuaraniBackendError is thrown when the Guarani's server is not working correctly. 18 | export class GuaraniBackendError extends Error { 19 | constructor(message, options) { 20 | super(message, options); 21 | this.name = "GuaraniBackendError"; 22 | } 23 | } 24 | 25 | // MissingStudentIdError is thrown when the studentId is not present. 26 | // This seems to happen in a few cases where the student was not yet assigned an ID? 27 | export class MissingStudentIdError extends Error { 28 | constructor(message, options) { 29 | super(message, options); 30 | this.name = "MissingStudentIdError"; 31 | } 32 | } 33 | 34 | // Errors that we don't want to log to our backend 35 | const IGNORED_ERROR_TYPES = [LoggedOutError, GuaraniBackendError, MissingStudentIdError]; 36 | 37 | /** 38 | * Checks if the given error or any error in its cause chain is of a type that should be ignored. 39 | * @param {Error} error - The error to check 40 | * @returns {boolean} - True if the error should be ignored 41 | */ 42 | export function isIgnoredError(error) { 43 | if (!error) return false; 44 | if (IGNORED_ERROR_TYPES.some(ErrorType => error instanceof ErrorType)) return true; 45 | return isIgnoredError(error.cause); 46 | } 47 | 48 | /** 49 | * Converts an error to a string representation, including the full cause chain. 50 | * @param {Error|Object|*} error - The error to stringify 51 | * @returns {string} - String representation of the error 52 | */ 53 | export function stringifyError(error) { 54 | if (error instanceof Error) { 55 | let message = error.toString(); 56 | let result = error.stack || message; 57 | 58 | // `stack` usually includes the message in the first line, but not in all cases. 59 | if (error.stack && !error.stack.startsWith(message)) { 60 | result = message + "\n" + error.stack; 61 | } 62 | 63 | // Include the cause chain if present 64 | if (error.cause) { 65 | result += "\nCaused by: " + stringifyError(error.cause); 66 | } 67 | 68 | return result; 69 | } 70 | if (typeof error === "object") { 71 | return JSON.stringify(error); 72 | } 73 | return error + ""; 74 | } 75 | -------------------------------------------------------------------------------- /src/ApiConnector.js: -------------------------------------------------------------------------------- 1 | const CLIENT = `CHROME@${chrome.runtime.getManifest().version}`; 2 | const BASE_API_URL = "https://www.pablomatiasgomez.com.ar/utnba-helper/v2"; 3 | 4 | export class ApiConnector { 5 | // POSTs: 6 | logMessage(method, isError, message) { 7 | return this.#postData(BASE_API_URL + "/log", { 8 | method: method, 9 | error: isError, 10 | message: message 11 | }); 12 | } 13 | 14 | logUserStat(hashedStudentId, pesoAcademico, passingGradesAverage, allGradesAverage, passingGradesCount, failingGradesCount) { 15 | return this.#postData(BASE_API_URL + "/user-stats", { 16 | hashedStudentId: hashedStudentId, 17 | pesoAcademico: pesoAcademico, 18 | passingGradesAverage: passingGradesAverage, 19 | allGradesAverage: allGradesAverage, 20 | passingGradesCount: passingGradesCount, 21 | failingGradesCount: failingGradesCount 22 | }); 23 | } 24 | 25 | postClassSchedules(classSchedules) { 26 | return this.#postData(BASE_API_URL + "/class-schedules", classSchedules); 27 | } 28 | 29 | postProfessorSurveys(surveys) { 30 | return this.#postData(BASE_API_URL + "/professor-surveys", surveys); 31 | } 32 | 33 | postCourses(courses) { 34 | return this.#postData(BASE_API_URL + "/courses", courses); 35 | } 36 | 37 | // GETs: 38 | getPreviousProfessors(previousProfessorsRequest) { 39 | return this.#postData(BASE_API_URL + "/previous-professors", previousProfessorsRequest); 40 | } 41 | 42 | searchProfessors(query) { 43 | return this.#getData(BASE_API_URL + "/professors?q=" + encodeURIComponent(query)); 44 | } 45 | 46 | getProfessorSurveysAggregate(professorName) { 47 | return this.#getData(BASE_API_URL + "/aggregated-professor-surveys?professorName=" + encodeURIComponent(professorName)); 48 | } 49 | 50 | getClassesForProfessor(professorName, offset, limit) { 51 | return this.#getClassesSchedules(null, professorName, offset, limit); 52 | } 53 | 54 | searchCourses(query) { 55 | return this.#getData(BASE_API_URL + "/courses?q=" + encodeURIComponent(query)); 56 | } 57 | 58 | getPlanCourses(planCode) { 59 | return this.#getData(BASE_API_URL + "/courses?planCode=" + encodeURIComponent(planCode)); 60 | } 61 | 62 | getClassesForCourse(courseCode, offset, limit) { 63 | return this.#getClassesSchedules(courseCode, null, offset, limit); 64 | } 65 | 66 | // Private: 67 | #getClassesSchedules(courseCode, professorName, offset, limit) { 68 | let params = { 69 | offset: offset, 70 | limit: limit 71 | }; 72 | if (courseCode) params.courseCode = courseCode; 73 | if (professorName) params.professorName = professorName; 74 | return this.#getData(BASE_API_URL + "/class-schedules?" + this.#buildQueryParams(params)); 75 | } 76 | 77 | #postData(url, data) { 78 | return this.#makeRequest({ 79 | url: url, 80 | method: 'POST', 81 | headers: { 82 | "X-Client": CLIENT, 83 | "Content-type": "application/json; charset=utf-8" 84 | }, 85 | body: JSON.stringify(data) 86 | }); 87 | } 88 | 89 | #getData(url) { 90 | return this.#makeRequest({ 91 | url: url, 92 | method: 'GET', 93 | headers: { 94 | "X-Client": CLIENT 95 | } 96 | }); 97 | } 98 | 99 | #makeRequest(options) { 100 | // TODO this is duplicated in Utils.backgroundFetch. 101 | return new Promise((resolve, reject) => { 102 | chrome.runtime.sendMessage(options, response => (response && response.errorStr) ? reject(new Error(response.errorStr)) : resolve(response)); 103 | }); 104 | } 105 | 106 | #buildQueryParams(params) { 107 | return Object.entries(params) 108 | .map(entry => `${encodeURIComponent(entry[0])}=${encodeURIComponent(entry[1])}`) 109 | .join("&"); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/guarani/main.js: -------------------------------------------------------------------------------- 1 | import './main.css'; 2 | 3 | import {initializeEmbrace} from '../Embrace.js'; 4 | 5 | import {ApiConnector} from '../ApiConnector.js'; 6 | import {Utils} from './Utils.js'; 7 | import {Store} from './Store.js'; 8 | import {PagesDataParser} from './PagesDataParser.js'; 9 | import {DataCollector} from './DataCollector.js'; 10 | import {CustomPages} from './custompages/CustomPages.js'; 11 | import {HorariosPage} from './pages/HorariosPage.js'; 12 | import {PreInscripcionPage} from './pages/PreInscripcionPage.js'; 13 | import {InscripcionAExamenesPage} from './pages/InscripcionAExamenesPage.js'; 14 | 15 | (function () { 16 | initializeEmbrace("main"); 17 | 18 | let apiConnector = new ApiConnector(); 19 | let utils = new Utils(apiConnector); 20 | utils.runAsync("main", () => { 21 | let store = new Store(); 22 | let pagesDataParser = new PagesDataParser(utils); 23 | let dataCollector = new DataCollector(store, pagesDataParser, apiConnector); 24 | let customPages = new CustomPages(pagesDataParser, dataCollector, utils, apiConnector); 25 | 26 | // Custom pages & handlers 27 | const PAGE_HANDLERS = [ 28 | // match is performed using regex and first one is used. 29 | { 30 | pathRegex: /^\/autogestion\/grado\/calendario$/, 31 | handler: () => new HorariosPage() 32 | }, 33 | { 34 | pathRegex: /^\/autogestion\/grado\/cursada\/elegir_materia\/.*/, 35 | handler: () => new PreInscripcionPage(pagesDataParser, utils, apiConnector) 36 | }, 37 | { 38 | pathRegex: /^\/autogestion\/grado\/examen$/, 39 | handler: () => new InscripcionAExamenesPage() 40 | }, 41 | ]; 42 | 43 | let currentHandler = null; 44 | let handleCurrentPage = () => utils.runAsync("HandlePage " + window.location.pathname + window.location.search, () => { 45 | if (currentHandler) currentHandler.close(); 46 | 47 | // We only will handle pages if: 48 | // - the user is in the /autogestion/grado pages 49 | // - the user is logged in (they have the name in the navbar) 50 | // - there is a profile selector with "Alumno" selected. 51 | // - they are not in any Survey page. 52 | let isInGradoPage = window.location.pathname.startsWith("/autogestion/grado"); 53 | let isLoggedIn = !!document.querySelector(".user-navbar"); 54 | let currentProfile = document.querySelector("#js-selector-perfiles .js-texto-perfil")?.textContent.trim(); 55 | let isStudentProfile = currentProfile === "Perfil: Alumno"; 56 | let isInSurveyPage = location.pathname.startsWith("/autogestion/grado/encuestas_kolla"); 57 | if (!isInGradoPage || !isLoggedIn || !isStudentProfile || isInSurveyPage) { 58 | // Remove all the added things when the page is not handled. 59 | customPages.removeMenu(); 60 | utils.removePoweredByUTNBAHelper(); 61 | 62 | return apiConnector.logMessage("pageNotHandled", false, `[Path:${window.location.pathname}][IsLoggedIn:${isLoggedIn}][CurrentProfile:${currentProfile}]`); 63 | } 64 | 65 | // Init everything: 66 | customPages.appendMenu(); 67 | utils.addPoweredByUTNBAHelper(); 68 | 69 | // Collect background data 70 | utils.runAsync("collectBackgroundDataIfNeeded", () => dataCollector.collectBackgroundDataIfNeeded()); 71 | 72 | // Wait for the loading div to hide... applies for both loading from document or ajax. 73 | return utils.waitForElementToHide("#loading_top").then(() => { 74 | currentHandler = customPages.getSelectedPageHandler() || PAGE_HANDLERS.find(entry => entry.pathRegex.test(window.location.pathname))?.handler; 75 | if (!currentHandler) return; 76 | currentHandler = currentHandler(); 77 | return currentHandler.init(); 78 | }); 79 | }); 80 | 81 | handleCurrentPage(); 82 | 83 | // Subscribe to ajax page changes (some of these events are created in the foreground script) 84 | window.addEventListener("locationchange", handleCurrentPage); 85 | 86 | // Append the foreground script that will subscribe to all the needed events. 87 | utils.runAsync('injectForeground', () => utils.injectScript("guarani/foreground.js", true)); 88 | }); 89 | })(); 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

UTN.BA Helper (ex Siga Helper) - Chrome extension

2 | 3 |

4 | downloads 5 | rating 6 | stars 7 |

8 |

Install from the Chrome Web Store

9 |

install

10 |

logo

11 | 12 | UTN.BA Helper is a Chrome extension that makes navigating and using the UTN.BA - FRBA website easier. 13 | 14 | Features description of this extension are written in Spanish as the target users are from Argentina. 15 | 16 | ## Features 17 | 18 | UTN.BA Helper facilita el uso de la web de la UTN - FRBA. 19 | 20 | - Colecta anónimamente distintos datos, para ser utilizados en las distintas secciones, como: 21 | - Las encuestas docentes para poder publicar esta información en la sección de "Buscar Docentes" e incluso mostrarla al momento de inscribirse a un curso. 22 | - Los horarios de las cursadas para mostrar esta información al momento de inscribirse a un nuevo curso, y poder intentar predecir cuál va a ser el profesor que va a estar en cada cursada. 23 | 24 | - Al momento de inscribirse a materias, muestra los profesores que estuvieron en cada cursada, basándose en data colectada, para así poder saber qué profesor va a estar en cada curso. 25 | 26 | - Agrega nuevas secciones bajo el menu "UTN.BA Helper": 27 | - "Buscar Docentes", donde se puede ver información colectada, entre ello, la encuesta docente. 28 | - "Buscar Cursos", donde se puede ver información de cursos pasados, como horarios, profesores que estuvieron en cada uno, etc. 29 | - "Seguimiento de Plan": 30 | - Se visualiza el estado actual del plan, viendo materias aprobadas, habilitadas para rendir final, por cursar, etc. 31 | - Peso académico, cantidad de finales aprobados y desaprobados. 32 | - Promedio de notas ponderadas (según Ordenanza Nº 1549) y no ponderadas, contando y sin contar desaprobados. 33 | 34 | - Agrega el nombre de la materia en la grilla de horarios en la sección de Agenda. 35 | 36 | ## Screenshots: 37 | 38 |

screenshot

39 | 40 | --- 41 | 42 |

screenshot

43 | 44 | --- 45 | 46 |

screenshot

47 | 48 | --- 49 | 50 |

screenshot

51 | 52 | --- 53 | 54 |

screenshot

55 | 56 | ## Glossary 57 | 58 | | English | Spanish | 59 | |---------:|:------------| 60 | | Course | Materia | 61 | | Class | Cursada | 62 | | Elective | Optativa | 63 | | Grade | Nota | 64 | | Signed | Firmada | 65 | | Passed | Aprobada | 66 | | Failed | Desaprobada | 67 | 68 | 69 | ## Stargazers over time 70 | [![Stargazers over time](https://starchart.cc/pablomatiasgomez/utn.ba-helper.svg?variant=adaptive)](https://starchart.cc/pablomatiasgomez/utn.ba-helper) 71 | -------------------------------------------------------------------------------- /src/guarani/Errors.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | LoggedOutError, 3 | RedirectedToHomeError, 4 | GuaraniBackendError, 5 | MissingStudentIdError, 6 | isIgnoredError, 7 | stringifyError 8 | } from './Errors.js'; 9 | 10 | describe('isIgnoredError', () => { 11 | it('should return true for LoggedOutError', () => { 12 | const error = new LoggedOutError(); 13 | expect(isIgnoredError(error)).toBe(true); 14 | }); 15 | 16 | it('should return true for GuaraniBackendError', () => { 17 | const error = new GuaraniBackendError('Backend error'); 18 | expect(isIgnoredError(error)).toBe(true); 19 | }); 20 | 21 | it('should return true for MissingStudentIdError', () => { 22 | const error = new MissingStudentIdError('No student ID'); 23 | expect(isIgnoredError(error)).toBe(true); 24 | }); 25 | 26 | it('should return false for RedirectedToHomeError', () => { 27 | const error = new RedirectedToHomeError(); 28 | expect(isIgnoredError(error)).toBe(false); 29 | }); 30 | 31 | it('should return false for generic Error', () => { 32 | const error = new Error('Generic error'); 33 | expect(isIgnoredError(error)).toBe(false); 34 | }); 35 | 36 | it('should return false for null', () => { 37 | expect(isIgnoredError(null)).toBe(false); 38 | }); 39 | 40 | it('should return false for undefined', () => { 41 | expect(isIgnoredError(undefined)).toBe(false); 42 | }); 43 | 44 | it('should return true if cause is an ignored error', () => { 45 | const cause = new LoggedOutError(); 46 | const error = new Error('Wrapper error', {cause}); 47 | expect(isIgnoredError(error)).toBe(true); 48 | }); 49 | 50 | it('should return true if cause of cause is an ignored error', () => { 51 | const rootCause = new GuaraniBackendError('Root cause'); 52 | const cause = new Error('Middle error', {cause: rootCause}); 53 | const error = new Error('Top error', {cause}); 54 | expect(isIgnoredError(error)).toBe(true); 55 | }); 56 | 57 | it('should return false if no error in cause chain is ignored', () => { 58 | const rootCause = new RedirectedToHomeError(); 59 | const cause = new Error('Middle error', {cause: rootCause}); 60 | const error = new Error('Top error', {cause}); 61 | expect(isIgnoredError(error)).toBe(false); 62 | }); 63 | 64 | it('should handle deeply nested cause chains', () => { 65 | const level4 = new MissingStudentIdError('Deep error'); 66 | const level3 = new Error('Level 3', {cause: level4}); 67 | const level2 = new Error('Level 2', {cause: level3}); 68 | const level1 = new Error('Level 1', {cause: level2}); 69 | const level0 = new Error('Level 0', {cause: level1}); 70 | expect(isIgnoredError(level0)).toBe(true); 71 | }); 72 | }); 73 | 74 | describe('stringifyError', () => { 75 | it('should stringify Error with stack', () => { 76 | const error = new Error('Test error'); 77 | const result = stringifyError(error); 78 | expect(result).toContain('Test error'); 79 | expect(result).toContain('Error: Test error'); 80 | }); 81 | 82 | it('should stringify custom error classes', () => { 83 | const error = new LoggedOutError('User logged out'); 84 | const result = stringifyError(error); 85 | expect(result).toContain('LoggedOutError'); 86 | expect(result).toContain('User logged out'); 87 | }); 88 | 89 | it('should include cause in stringification', () => { 90 | const cause = new Error('Original error'); 91 | const error = new Error('Wrapper error', {cause}); 92 | const result = stringifyError(error); 93 | expect(result).toContain('Wrapper error'); 94 | expect(result).toContain('Caused by:'); 95 | expect(result).toContain('Original error'); 96 | }); 97 | 98 | it('should include nested causes in stringification', () => { 99 | const rootCause = new Error('Root cause'); 100 | const middleCause = new Error('Middle error', {cause: rootCause}); 101 | const error = new Error('Top error', {cause: middleCause}); 102 | const result = stringifyError(error); 103 | expect(result).toContain('Top error'); 104 | expect(result).toContain('Caused by:'); 105 | expect(result).toContain('Middle error'); 106 | expect(result).toContain('Root cause'); 107 | }); 108 | 109 | it('should stringify plain objects', () => { 110 | const obj = {foo: 'bar', baz: 123}; 111 | const result = stringifyError(obj); 112 | expect(result).toBe(JSON.stringify(obj)); 113 | }); 114 | 115 | it('should stringify primitive values', () => { 116 | expect(stringifyError('string error')).toBe('string error'); 117 | expect(stringifyError(123)).toBe('123'); 118 | expect(stringifyError(true)).toBe('true'); 119 | }); 120 | 121 | it('should handle null and undefined', () => { 122 | expect(stringifyError(null)).toBe('null'); 123 | expect(stringifyError(undefined)).toBe('undefined'); 124 | }); 125 | 126 | it('should handle errors without stack property', () => { 127 | const error = new Error('Test'); 128 | delete error.stack; 129 | const result = stringifyError(error); 130 | expect(result).toContain('Test'); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/guarani/DataCollector.js: -------------------------------------------------------------------------------- 1 | import {trace} from "@embrace-io/web-sdk"; 2 | 3 | const LOCAL_STORAGE_DATA_COLLECTOR_KEY = "UtnBaHelper.DataCollector"; 4 | const ONE_DAY_MS = 24 * 60 * 60 * 1000; 5 | 6 | export class DataCollector { 7 | #store; 8 | #pagesDataParser; 9 | #apiConnector; 10 | #hashedStudentId; 11 | 12 | constructor(store, pagesDataParser, apiConnector) { 13 | this.#store = store; 14 | this.#pagesDataParser = pagesDataParser; 15 | this.#apiConnector = apiConnector; 16 | } 17 | 18 | #getHashedStudentId() { 19 | if (this.#hashedStudentId) return this.#hashedStudentId; 20 | 21 | let studentId = this.#pagesDataParser.getStudentId(); 22 | this.#hashedStudentId = this.#hashCode(studentId); 23 | return this.#hashedStudentId; 24 | } 25 | 26 | /** 27 | * Sends the user stat with the hashed student it to keep data anonymous. 28 | */ 29 | logUserStat(pesoAcademico, passingGradesAverage, allGradesAverage, passingGradesCount, failingGradesCount) { 30 | return this.#apiConnector.logUserStat(this.#getHashedStudentId(), pesoAcademico, passingGradesAverage, allGradesAverage, passingGradesCount, failingGradesCount); 31 | } 32 | 33 | /** 34 | * Collects, every one day or more, background data such as: 35 | * - class schedules and professors 36 | * @returns {Promise} 37 | */ 38 | collectBackgroundDataIfNeeded() { 39 | let hashedStudentId = this.#getHashedStudentId(); 40 | // Save hashedStudentId to local storage, so that it can be used for surveys collection. 41 | return this.#store.saveHashedStudentIdToStore(hashedStudentId).then(() => { 42 | let lastTimeCollected = this.#getLastTimeCollectedForStudentId(hashedStudentId); 43 | 44 | let collectMethods = [ 45 | { 46 | key: "schedules", 47 | minTime: ONE_DAY_MS, 48 | method: () => this.#collectClassSchedulesWithProfessors(), 49 | }, 50 | { 51 | key: "planCourses", 52 | minTime: ONE_DAY_MS * 180, 53 | method: () => this.#collectStudentPlanCourses(), 54 | } 55 | ]; 56 | 57 | let shouldSaveLastTimeCollected = false; 58 | let promise = Promise.resolve(); 59 | collectMethods.filter(collectMethod => { 60 | // Never collected or min time has passed. 61 | return !lastTimeCollected[collectMethod.key] || Date.now() - lastTimeCollected[collectMethod.key] > collectMethod.minTime; 62 | }).forEach(collectMethod => { 63 | promise = promise.then(() => { 64 | const span = trace.startSpan("Collect-" + collectMethod.key); 65 | return collectMethod.method() 66 | .then(() => span.end()) 67 | .catch((e) => { 68 | span.fail(); 69 | throw e; 70 | }); 71 | }).then(() => { 72 | // If at least one collect method is executed, we need to save the last time collected info to local storage. 73 | shouldSaveLastTimeCollected = true; 74 | lastTimeCollected[collectMethod.key] = Date.now(); 75 | }); 76 | }); 77 | 78 | return promise.then(() => { 79 | if (shouldSaveLastTimeCollected) { 80 | this.#saveLastTimeCollected(hashedStudentId, lastTimeCollected); 81 | } 82 | }); 83 | }); 84 | } 85 | 86 | #collectClassSchedulesWithProfessors() { 87 | return Promise.all([ 88 | this.#pagesDataParser.getClassSchedules(), 89 | this.#pagesDataParser.getProfessorClassesFromSurveys(), 90 | ]).then(results => { 91 | let classSchedules = results[0].concat(results[1]); 92 | if (classSchedules.length) { 93 | return this.#apiConnector.postClassSchedules(classSchedules); 94 | } 95 | }); 96 | } 97 | 98 | #collectStudentPlanCourses() { 99 | return this.#pagesDataParser.getStudentPlanCourses().then(planCourses => { 100 | return this.#apiConnector.postCourses(planCourses); 101 | }); 102 | } 103 | 104 | // ----- 105 | 106 | #getLastTimeCollectedByHashedStudentId() { 107 | let lastTimeCollectedByHashedStudentId; 108 | try { 109 | // Don't know why, but some cases were failing with json parsing errors... We simply consider those as not present. 110 | lastTimeCollectedByHashedStudentId = JSON.parse(localStorage.getItem(LOCAL_STORAGE_DATA_COLLECTOR_KEY)); 111 | } catch (e) { 112 | console.error(`Error parsing localStorage item...`, e); 113 | } 114 | if (!lastTimeCollectedByHashedStudentId) { 115 | lastTimeCollectedByHashedStudentId = {}; 116 | localStorage.setItem(LOCAL_STORAGE_DATA_COLLECTOR_KEY, JSON.stringify(lastTimeCollectedByHashedStudentId)); 117 | } 118 | return lastTimeCollectedByHashedStudentId; 119 | } 120 | 121 | #getLastTimeCollectedForStudentId(hashedStudentId) { 122 | return this.#getLastTimeCollectedByHashedStudentId()[hashedStudentId] || {}; 123 | } 124 | 125 | #saveLastTimeCollected(hashedStudentId, lastTimeCollected) { 126 | let lastTimeCollectedByHashedStudentId = this.#getLastTimeCollectedByHashedStudentId(); 127 | lastTimeCollectedByHashedStudentId[hashedStudentId] = lastTimeCollected; 128 | localStorage.setItem(LOCAL_STORAGE_DATA_COLLECTOR_KEY, JSON.stringify(lastTimeCollectedByHashedStudentId)); 129 | } 130 | 131 | // Used to make the studentId anonymous. 132 | #hashCode(str) { 133 | let hash = 0; 134 | for (let i = 0; i < str.length; i++) { 135 | hash = ((hash << 5) - hash) + str.charCodeAt(i); 136 | hash = hash & hash; // Convert to 32bit integer 137 | } 138 | return hash; 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /src/guarani/Utils.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {Consts} from './Consts.js'; 3 | import {isIgnoredError, stringifyError} from './Errors.js'; 4 | import {log} from "@embrace-io/web-sdk"; 5 | import {CustomPages} from './custompages/CustomPages.js'; 6 | 7 | export class Utils { 8 | // TODO utils eventually shouldn't be instantiated and should be a set of functions. 9 | // But we need to get rid of using apiConnector here. 10 | 11 | #apiConnector; 12 | #failedToFetchErrors = 0; 13 | 14 | constructor(apiConnector) { 15 | this.#apiConnector = apiConnector; 16 | } 17 | 18 | // Related to the extension: 19 | delay(delayMs) { 20 | return result => new Promise(resolve => setTimeout(() => resolve(result), delayMs)); 21 | } 22 | 23 | // TODO this is duplicated in the ApiConnector. 24 | backgroundFetch(options) { 25 | return new Promise((resolve, reject) => { 26 | chrome.runtime.sendMessage(options, response => (response && response.errorStr) ? reject(new Error(response.errorStr)) : resolve(response)); 27 | }); 28 | } 29 | 30 | injectScript(filePath, removeAfterLoad = false) { 31 | return new Promise((resolve, reject) => { 32 | let script = document.createElement('script'); 33 | script.type = 'text/javascript'; 34 | script.src = chrome.runtime.getURL(filePath); 35 | script.onload = () => { 36 | if (removeAfterLoad) script.remove(); 37 | resolve(); 38 | } 39 | script.onerror = () => reject(new Error(`Failed to load script: ${filePath}`)); 40 | document.head.appendChild(script); 41 | }); 42 | } 43 | 44 | 45 | /** 46 | * Wraps a function that is triggered from an async event, and handles errors by logging them to the api. 47 | */ 48 | runAsync(name, fn) { 49 | // Start with Promise.resolve() as we don't know if fn returns promise or not. 50 | Promise.resolve().then(() => { 51 | return fn(); 52 | }).catch(e => { 53 | console.error(`Error while executing ${name}`, e); 54 | // Not logging errors that we can't do anything. 55 | if (isIgnoredError(e)) return; 56 | 57 | let errStr = stringifyError(e); 58 | // Skip first 2 Failed to fetch errors. We only want to know about these if it's failing for every request. 59 | // These are usually related to the user closing the tab, dns not resolving, etc., but we cannot get the details. 60 | if (errStr.includes("Failed to fetch") && ++this.#failedToFetchErrors <= 2) return; 61 | 62 | // Log to Embrace 63 | log.logException(e, {handled: true, attributes: {name: name}}); 64 | 65 | // Log to our backend 66 | return this.#apiConnector.logMessage(name, true, errStr); 67 | }); 68 | } 69 | 70 | /** 71 | * logHTML is used to report the entire HTML or debug purposes 72 | * Can be called with a given pct of users to sample. 73 | * E.g. 74 | * this.#utils.logHTML("HorariosPage", 10); 75 | */ 76 | logHTML(name, pct) { 77 | // Only log for a percentage of users 78 | if (Math.random() * 100 >= pct) return; 79 | 80 | let message = `HTML log for ${name}`; 81 | let html = document.documentElement.outerHTML; 82 | log.message(message, 'warning', { 83 | attributes: { 84 | name: name, 85 | html: html 86 | } 87 | }); 88 | return this.#apiConnector.logMessage(message, false, `HTML log for ${name}. HTML: ${html}`); 89 | } 90 | 91 | waitForElementToHide(selector) { 92 | return new Promise((resolve) => { 93 | let check = () => { 94 | if (!$(selector).is(":visible")) { 95 | resolve(); 96 | } else { 97 | setTimeout(check, 100); 98 | } 99 | }; 100 | check(); 101 | }); 102 | } 103 | 104 | // ---- 105 | 106 | /** 107 | * Removes the existent "powered by banner", if any. 108 | */ 109 | removePoweredByUTNBAHelper() { 110 | document.querySelector(".powered-by-utnba-helper")?.remove(); 111 | } 112 | 113 | /** 114 | * Appends the "powered by banner", unless it already exists. 115 | */ 116 | addPoweredByUTNBAHelper() { 117 | if (!!document.querySelector(".powered-by-utnba-helper")) return; 118 | document.querySelector(".user-navbar").closest(".row-fluid") 119 | .insertAdjacentHTML('afterbegin', `POWERED BY UTN.BA HELPER`); 120 | } 121 | 122 | 123 | getSchedulesAsString(schedules) { 124 | if (!schedules) return "-"; 125 | return schedules 126 | .map(schedule => 127 | Consts.DAYS[schedule.day] + " (" + Consts.TIME_SHIFTS[schedule.shift] + ") " + 128 | Consts.HOURS[schedule.shift][schedule.firstHour].start + "hs a " + Consts.HOURS[schedule.shift][schedule.lastHour].end + "hs") 129 | .join(" y "); 130 | } 131 | 132 | getColorForAvg(avg, alpha = 1) { 133 | if (avg < 60) { 134 | return `rgba(213, 28, 38, ${alpha})`; // "#D51C26"; 135 | } else if (avg >= 80) { 136 | return `rgba(25, 177, 53, ${alpha})`; // "#19B135"; 137 | } else { 138 | return `rgba(244, 210, 36, ${alpha})`; // "#F4D224"; 139 | } 140 | } 141 | 142 | getOverallScoreSpan(overallScore) { 143 | return `${overallScore}`; 144 | } 145 | 146 | getProfessorLi(professor) { 147 | let fontSize = professor.kind === "DOCENTE" ? "13px" : "11px"; 148 | if (typeof professor.overallScore === "undefined") { 149 | // If we do not have surveys we do not show the score nor the link. 150 | return `
  • ${professor.name} (${professor.role})
  • `; 151 | } 152 | return `
  • 153 | ${this.getOverallScoreSpan(professor.overallScore)} 154 | ${professor.name} (${professor.role}) 155 |
  • `; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/guarani/custompages/CoursesSearchCustomPage.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {log} from "@embrace-io/web-sdk"; 3 | 4 | export class CoursesSearchCustomPage { 5 | static menuName = "Buscar cursos"; 6 | static customParamKey = "courseCode"; 7 | 8 | #$container; 9 | #services; 10 | #$searchDiv; 11 | #$searchResultsDiv; 12 | #$courseDataDiv; 13 | // Used to add a separator between rows that change year and quarter 14 | #lastYear; 15 | #lastQuarter; 16 | 17 | constructor(container, services) { 18 | this.#$container = $(container); 19 | this.#services = services; 20 | } 21 | 22 | #createPage() { 23 | this.#$searchDiv = $("
    "); 24 | this.#$searchDiv.append(`Buscar por nombre o código de materia: `); 25 | let $searchTxt = $(``); 26 | $searchTxt.on("keydown", (e) => { 27 | if (e.key === "Enter") { 28 | this.#services.utils.runAsync("CoursesSearch", () => this.#search($searchTxt.val().trim())); 29 | return false; 30 | } 31 | }); 32 | this.#$searchDiv.append($searchTxt); 33 | let $searchBtn = $(`Buscar`); 34 | $searchBtn.on("click", () => { 35 | this.#services.utils.runAsync("CoursesSearch", () => this.#search($searchTxt.val().trim())); 36 | return false; 37 | }); 38 | this.#$searchDiv.append($searchBtn); 39 | this.#$searchDiv.append("
    "); 40 | this.#$container.append(this.#$searchDiv); 41 | 42 | this.#$searchResultsDiv = $(`
    `); 43 | this.#$searchResultsDiv.hide(); 44 | this.#$searchResultsDiv.append("

    Resultados de busqueda

    "); 45 | let $searchResultsTable = $(`
    `).append(""); 46 | $searchResultsTable.on("click", "a", (e) => { 47 | let courseCode = $(e.currentTarget).text(); 48 | this.#services.utils.runAsync("retrieveClassesForCourse", () => this.#retrieveClassesForCourse(courseCode, 0, 15)); 49 | return false; 50 | }); 51 | this.#$searchResultsDiv.append($searchResultsTable); 52 | this.#$searchResultsDiv.append("
    "); 53 | this.#$container.append(this.#$searchResultsDiv); 54 | 55 | this.#$courseDataDiv = $(`
    `); 56 | this.#$courseDataDiv.hide(); 57 | this.#$courseDataDiv.append("

    Resultados para

    "); 58 | let $classesTable = $(`
    `).append(""); 59 | this.#$courseDataDiv.append($classesTable); 60 | this.#$courseDataDiv.append("
    "); 61 | this.#$container.append(this.#$courseDataDiv); 62 | } 63 | 64 | #search(query) { 65 | if (query.length < 3) return; 66 | this.#$searchResultsDiv.show().get(0).scrollIntoView({behavior: "smooth"}); 67 | this.#$searchResultsDiv.hide(); 68 | this.#$courseDataDiv.hide(); 69 | log.message("Searching courses", 'info', {attributes: {query: query}}); 70 | return this.#services.apiConnector.searchCourses(query).then(results => { 71 | let trs = results.map(item => { 72 | return `${item.value}${item.data}`; 73 | }).join(""); 74 | this.#$searchResultsDiv.show(); 75 | this.#$searchResultsDiv.find("table tbody") 76 | .html(trs) 77 | .prepend("NombreCodigo"); 78 | }); 79 | } 80 | 81 | #retrieveClassesForCourse(courseCode, offset, limit) { 82 | if (offset === 0) { 83 | this.#$courseDataDiv.show().get(0).scrollIntoView({behavior: "smooth"}); 84 | this.#$courseDataDiv.hide(); 85 | } 86 | return this.#services.apiConnector.getClassesForCourse(courseCode, offset, limit).then(classSchedules => { 87 | if (offset === 0) { 88 | this.#lastYear = this.#lastQuarter = null; 89 | this.#$courseDataDiv.find("h2").text(`Resultados para ${courseCode}`); 90 | this.#$courseDataDiv.show(); 91 | this.#$courseDataDiv.find("table tbody") 92 | .html(` 93 | Cuatr.CursoAnexoHorarioProfesores 94 | Ver mas resultados...`); 95 | this.#$courseDataDiv.find("table tbody tr:last a").on("click", () => { 96 | this.#services.utils.runAsync("retrieveClassesForCoursePage", () => this.#retrieveClassesForCourse(courseCode, offset += limit, limit)); 97 | return false; 98 | }); 99 | } 100 | if (classSchedules.length < limit) { 101 | this.#$courseDataDiv.find("table tbody tr:last").hide(); 102 | } 103 | this.#appendClassesToTable(classSchedules); 104 | }); 105 | } 106 | 107 | #appendClassesToTable(classSchedules) { 108 | let trs = classSchedules.map(classSchedule => { 109 | let professorLis = (classSchedule.professors || []).map(professor => { 110 | return this.#services.utils.getProfessorLi(professor); 111 | }).join(""); 112 | let trClass = (this.#lastYear && this.#lastYear !== classSchedule.year) ? "top-border" : (this.#lastQuarter && this.#lastQuarter !== classSchedule.quarter) ? "top-border-without-first-cell" : ""; 113 | this.#lastYear = classSchedule.year; 114 | this.#lastQuarter = classSchedule.quarter; 115 | return ` 116 | ${classSchedule.year} 117 | ${classSchedule.quarter} 118 | ${classSchedule.classCode} 119 | ${classSchedule.branch || "-"} 120 | ${this.#services.utils.getSchedulesAsString(classSchedule.schedules)} 121 | 122 | `; 123 | }).join(""); 124 | this.#$courseDataDiv.find("table tbody tr:last").before(trs); 125 | } 126 | 127 | init() { 128 | return Promise.resolve().then(() => { 129 | this.#createPage(); 130 | let courseCode = new URLSearchParams(window.location.search).get(CoursesSearchCustomPage.customParamKey); 131 | if (courseCode) { 132 | return this.#retrieveClassesForCourse(courseCode, 0, 15); 133 | } 134 | }); 135 | } 136 | 137 | close() { 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | // All imported modules in your tests should be mocked automatically 9 | // automock: false, 10 | 11 | // Stop running tests after `n` failures 12 | // bail: 0, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/75/d_z9ynpj6b927l05jfl_gs300000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls, instances, contexts and results before every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | collectCoverageFrom: [ 25 | "src/**/*.js", 26 | ], 27 | 28 | // The directory where Jest should output its coverage files 29 | coverageDirectory: "coverage", 30 | 31 | // An array of regexp pattern strings used to skip coverage collection 32 | // coveragePathIgnorePatterns: [ 33 | // "/node_modules/" 34 | // ], 35 | 36 | // Indicates which provider should be used to instrument code for coverage 37 | coverageProvider: "v8", 38 | 39 | // A list of reporter names that Jest uses when writing coverage reports 40 | // coverageReporters: [ 41 | // "json", 42 | // "text", 43 | // "lcov", 44 | // "clover" 45 | // ], 46 | 47 | // An object that configures minimum threshold enforcement for coverage results 48 | // coverageThreshold: undefined, 49 | 50 | // A path to a custom dependency extractor 51 | // dependencyExtractor: undefined, 52 | 53 | // Make calling deprecated APIs throw helpful error messages 54 | // errorOnDeprecated: false, 55 | 56 | // The default configuration for fake timers 57 | // fakeTimers: { 58 | // "enableGlobally": false 59 | // }, 60 | 61 | // Force coverage collection from ignored files using an array of glob patterns 62 | // forceCoverageMatch: [], 63 | 64 | // A path to a module which exports an async function that is triggered once before all test suites 65 | // globalSetup: undefined, 66 | 67 | // A path to a module which exports an async function that is triggered once after all test suites 68 | // globalTeardown: undefined, 69 | 70 | // A set of global variables that need to be available in all test environments 71 | // globals: {}, 72 | 73 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 74 | // maxWorkers: "50%", 75 | 76 | // An array of directory names to be searched recursively up from the requiring module's location 77 | // moduleDirectories: [ 78 | // "node_modules" 79 | // ], 80 | 81 | // An array of file extensions your modules use 82 | // moduleFileExtensions: [ 83 | // "js", 84 | // "mjs", 85 | // "cjs", 86 | // "jsx", 87 | // "ts", 88 | // "mts", 89 | // "cts", 90 | // "tsx", 91 | // "json", 92 | // "node" 93 | // ], 94 | 95 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 96 | moduleNameMapper: { 97 | '\\.(css|less)$': '/src/__mocks__/styleMock.js', 98 | }, 99 | 100 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 101 | // modulePathIgnorePatterns: [], 102 | 103 | // Activates notifications for test results 104 | // notify: false, 105 | 106 | // An enum that specifies notification mode. Requires { notify: true } 107 | // notifyMode: "failure-change", 108 | 109 | // A preset that is used as a base for Jest's configuration 110 | // preset: undefined, 111 | 112 | // Run tests from one or more projects 113 | // projects: undefined, 114 | 115 | // Use this configuration option to add custom reporters to Jest 116 | // reporters: undefined, 117 | 118 | // Automatically reset mock state before every test 119 | // resetMocks: false, 120 | 121 | // Reset the module registry before running each individual test 122 | // resetModules: false, 123 | 124 | // A path to a custom resolver 125 | // resolver: undefined, 126 | 127 | // Automatically restore mock state and implementation before every test 128 | // restoreMocks: false, 129 | 130 | // The root directory that Jest should scan for tests and modules within 131 | // rootDir: undefined, 132 | 133 | // A list of paths to directories that Jest should use to search for files in 134 | // roots: [ 135 | // "" 136 | // ], 137 | 138 | // Allows you to use a custom runner instead of Jest's default test runner 139 | // runner: "jest-runner", 140 | 141 | // The paths to modules that run some code to configure or set up the testing environment before each test 142 | // setupFiles: [], 143 | 144 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 145 | setupFilesAfterEnv: ['/jest.setup.js'], 146 | 147 | // The number of seconds after which a test is considered as slow and reported as such in the results. 148 | // slowTestThreshold: 5, 149 | 150 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 151 | // snapshotSerializers: [], 152 | 153 | // The test environment that will be used for testing 154 | testEnvironment: "jsdom", 155 | 156 | // Options that will be passed to the testEnvironment 157 | // testEnvironmentOptions: {}, 158 | 159 | // Adds a location field to test results 160 | // testLocationInResults: false, 161 | 162 | // The glob patterns Jest uses to detect test files 163 | // testMatch: [ 164 | // "**/__tests__/**/*.?([mc])[jt]s?(x)", 165 | // "**/?(*.)+(spec|test).?([mc])[jt]s?(x)" 166 | // ], 167 | 168 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 169 | // testPathIgnorePatterns: [ 170 | // "/node_modules/" 171 | // ], 172 | 173 | // The regexp pattern or array of patterns that Jest uses to detect test files 174 | // testRegex: [], 175 | 176 | // This option allows the use of a custom results processor 177 | // testResultsProcessor: undefined, 178 | 179 | // This option allows use of a custom test runner 180 | // testRunner: "jest-circus/runner", 181 | 182 | // A map from regular expressions to paths to transformers 183 | // transform: undefined, 184 | 185 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 186 | // transformIgnorePatterns: [ 187 | // "/node_modules/", 188 | // "\\.pnp\\.[^\\/]+$" 189 | // ], 190 | 191 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 192 | // unmockedModulePathPatterns: undefined, 193 | 194 | // Indicates whether each individual test should be reported during the run 195 | // verbose: undefined, 196 | 197 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 198 | // watchPathIgnorePatterns: [], 199 | 200 | // Whether to use watchman for file crawling 201 | // watchman: true, 202 | }; 203 | 204 | export default config; 205 | -------------------------------------------------------------------------------- /src/guarani/custompages/CustomPages.js: -------------------------------------------------------------------------------- 1 | import './CustomPages.css'; 2 | 3 | import {CoursesSearchCustomPage} from './CoursesSearchCustomPage.js'; 4 | import {ProfessorsSearchCustomPage} from './ProfessorsSearchCustomPage.js'; 5 | import {PlanTrackingCustomPage} from './PlanTrackingCustomPage.js'; 6 | 7 | const CUSTOM_PAGES = [ 8 | CoursesSearchCustomPage, 9 | ProfessorsSearchCustomPage, 10 | PlanTrackingCustomPage, 11 | ]; 12 | 13 | export class CustomPages { 14 | static CUSTOM_PAGE_QUERY_PARAM = "customPage"; 15 | 16 | #pagesDataParser; 17 | #dataCollector; 18 | #utils; 19 | #apiConnector; 20 | 21 | constructor(pagesDataParser, dataCollector, utils, apiConnector) { 22 | this.#pagesDataParser = pagesDataParser; 23 | this.#dataCollector = dataCollector; 24 | this.#utils = utils; 25 | this.#apiConnector = apiConnector; 26 | } 27 | 28 | /** 29 | * Removes the existent menu, if any. 30 | */ 31 | removeMenu() { 32 | document.querySelector(".main-nav #js-nav .utnba-helper-menu")?.remove(); 33 | } 34 | 35 | /** 36 | * Appends the custom menu, unless it already exists. 37 | */ 38 | appendMenu() { 39 | if (!!document.querySelector(".main-nav #js-nav .utnba-helper-menu")) return; 40 | 41 | const customMenusContainer = document.createElement('ul'); 42 | customMenusContainer.className = 'dropdown-menu'; 43 | 44 | const li = document.createElement('li'); 45 | li.className = 'dropdown js-menuitem-root utnba-helper-menu'; 46 | li.innerHTML = `UTN.BA Helper `; 47 | 48 | li.appendChild(customMenusContainer); 49 | document.querySelector(".main-nav #js-nav").appendChild(li); 50 | 51 | CUSTOM_PAGES.forEach(customPage => { 52 | const menuItem = document.createElement('li'); 53 | menuItem.innerHTML = `${customPage.menuName}`; 54 | customMenusContainer.appendChild(menuItem); 55 | }); 56 | } 57 | 58 | #initCustomPage(customPage) { 59 | document.querySelector("#kernel_contenido").innerHTML = ` 60 |
    61 |
    62 |
    63 |

    UTN.BA HELPER - Información importante

    64 | 65 |

    Sobre el bloqueo de la extensión

    66 | 67 |

    68 | En el último tiempo, la facultad decidió bloquear activamente el uso de la extensión, advirtiendo a los estudiantes que la desactiven. 69 |

    70 |

    71 | Lamentablemente, esta decisión no protege a los estudiantes, los perjudica. 72 |

    73 |

    74 | Lo único que logra es quitarle poder a los alumnos, impedirles acceder a información que ellos mismos generan, 75 | y reforzar un esquema donde la facultad concentra el control total de los datos. 76 |

    77 |

    78 | La información recolectada en las encuestas docentes existe gracias a los estudiantes. 79 | Sin embargo, se decide retenerla y ocultarla, incluso frente a iniciativas voluntarias y 80 | transparentes que buscan devolver esa información a la comunidad. 81 |

    82 | 83 |

    Consecuencias reales de esta decisión

    84 |
      85 |
    • 86 | Se desincentiva la mejora docente, ya que el mal desempeño o el maltrato a estudiantes no tiene consecuencias visibles. 87 |
    • 88 |
    • 89 | Se reduce la capacidad de los alumnos de tomar decisiones informadas sobre su propia formación. 90 |
    • 91 |
    92 | 93 |

    Un ataque al aprendizaje y al desarrollo tecnológico

    94 |

    95 | Esta extensión es un proyecto abierto y comunitario. 96 | Estudiantes y docentes participaron activamente, abriendo pull 98 | requests, reportando issues, proponiendo ideas y sugerencias, etc. 99 |

    100 |

    101 | Esto es exactamente lo que una facultad (y más aún una facultad tecnológica) debería fomentar: aprender 102 | haciendo, colaborar, construir herramientas útiles para la comunidad. 103 |

    104 |

    105 | Resulta particularmente inexplicable en una institución como la UTN, cuyo objetivo debería ser formar 106 | profesionales críticos, autónomos y técnicamente capaces, no usuarios pasivos de sistemas cerrados. 107 |

    108 | 109 |

    Prohibir no es el camino

    110 |

    111 | La prohibición y el ocultamiento de los datos de las encuestas no es una solución educativa, ya que lo unico que 112 | logra es afectar a los estudiantes. 113 |

    114 |

    115 | El camino podría haber sido abrir al público toda la información de manera oficial, 116 | para proveer una herramienta aún mejor que la que hoy ofrece esta extensión, 117 | y así entonces eliminar su uso de forma orgánica y no mediante un bloqueo. 118 |

    119 |

    120 | La información generada por los alumnos debe volver a los alumnos, y la transparencia es el mejor camino para 121 | mejorar la educación. 122 |

    123 | 124 |

    Futuro de la extensión

    125 |

    126 | Como se mencionó más arriba, los datos recolectados siempre fueron y serán de los estudiantes, si en algún 127 | momento la extensión deja de funcionar por bloqueos totales, se intentará exponer la información en alguna 128 | sección separada del SIU Guarani. 129 |

    130 |
    131 |
    132 |
    133 |

    ${customPage.menuName}

    134 |
    135 | 138 |
    139 |

    UTN.BA HELPER - Información importante

    140 |

    Esta sección es provista por el "UTN.BA Helper" y no es parte del sistema de la UTN.

    141 |

    La información presentada en esta sección proviene de datos colectados de los usuarios que poseen la extensión, y de información generada por la misma extensión, por lo cual puede estar incompleta y/o errónea.
    142 | Ninguno de los datos presentados en esta sección proviene del sistema de la UTN, por lo que debe ser usada bajo su propia interpretación.

    143 |

    Tener en cuenta que en los casos de encuestas, la información colectada es una muestra parcial del total real, y por ende en casos donde la muestra es muy baja, puede implicar que los resultados estén alejados de la realidad.

    144 |
    145 |
    146 | `; 147 | } 148 | 149 | getSelectedPageHandler() { 150 | let selectedCustomPageName = new URLSearchParams(window.location.search).get(CustomPages.CUSTOM_PAGE_QUERY_PARAM); 151 | let selectedCustomPage = CUSTOM_PAGES.filter(customPage => selectedCustomPageName === customPage.menuName)[0]; 152 | if (!selectedCustomPage) return null; 153 | 154 | return () => { 155 | this.#initCustomPage(selectedCustomPage); 156 | return new selectedCustomPage(document.querySelector("#kernel_contenido .main"), { 157 | pagesDataParser: this.#pagesDataParser, 158 | dataCollector: this.#dataCollector, 159 | utils: this.#utils, 160 | apiConnector: this.#apiConnector 161 | }); 162 | }; 163 | } 164 | 165 | // Static methods 166 | static getCustomPageUrl(customPage, customParamValue) { 167 | let params = { 168 | [CustomPages.CUSTOM_PAGE_QUERY_PARAM]: customPage.menuName, 169 | [customPage.customParamKey]: customParamValue, 170 | }; 171 | return "/autogestion/grado/?" + Object.entries(params).filter(entry => !!entry[1]).map(entry => entry.map(encodeURIComponent).join("=")).join("&"); 172 | } 173 | 174 | static getCourseResultsUrl(courseCode) { 175 | return CustomPages.getCustomPageUrl(CoursesSearchCustomPage, courseCode); 176 | } 177 | 178 | static getProfessorSurveyResultsUrl(professorName) { 179 | return CustomPages.getCustomPageUrl(ProfessorsSearchCustomPage, professorName); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/guarani/pages/PreInscripcionPage.js: -------------------------------------------------------------------------------- 1 | import './PreInscripcionPage.css'; 2 | 3 | export class PreInscripcionPage { 4 | #pagesDataParser; 5 | #utils; 6 | #apiConnector; 7 | #addPreviousProfessorsTableEventFn; 8 | #addPreviousProfessorsTableEventWithDelayFn; 9 | 10 | constructor(pagesDataParser, utils, apiConnector) { 11 | this.#pagesDataParser = pagesDataParser; 12 | this.#utils = utils; 13 | this.#apiConnector = apiConnector; 14 | } 15 | 16 | #addClassSchedulesFilter(alternativesCombo, filtersDiv, previousProfessorsTableBody) { 17 | let branches = new Set(); 18 | let schedules = new Set(); 19 | let yearQuarters = new Set(); 20 | 21 | // Collect all combinations for each of the filters: 22 | Array.from(alternativesCombo.options).forEach(option => { 23 | let values = option.text.split("|"); 24 | if (values.length !== 5) return; 25 | branches.add(values[1].trim()); 26 | values[2].trim().split(",").map(day => day.trim().split(" ")[0]).forEach(schedule => schedules.add(schedule.trim())); 27 | yearQuarters.add(values[3].trim()); 28 | }); 29 | 30 | let createFilterOptions = opts => { 31 | return Array.from(opts).map(opt => ` 32 | 33 | 34 | `).join(" | "); 35 | } 36 | 37 | // Adds the checkboxes html 38 | filtersDiv.insertAdjacentHTML("beforeend", ` 39 |
    40 | Sede: 41 | ${createFilterOptions(branches)} 42 |
    43 |
    44 | Cuatrimestre: 45 | ${createFilterOptions(yearQuarters)} 46 |
    47 |
    48 | Turno: 49 | ${createFilterOptions(schedules)} 50 |
    51 | Filtrar 52 | `); 53 | 54 | let filterClassSchedules = function () { 55 | // For all the filter types (branches, schedules, yearQuarter) at least one option of each has to match. 56 | Array.from(alternativesCombo.options).forEach((option, i) => { 57 | if (i === 0) { 58 | // Force select first option and keep it visible 59 | alternativesCombo.value = option.value; 60 | return; 61 | } 62 | let visible = Array.from(filtersDiv.querySelectorAll(".filter")).every(filter => { 63 | return Array.from(filter.querySelectorAll("input:checked")).some(filterOpt => option.text.includes(filterOpt.value)); 64 | }); 65 | 66 | let optionId = option.getAttribute("data-option-id"); 67 | // hide or show both the option and the row in the previous professors table: 68 | if (visible) { 69 | option.removeAttribute("hidden"); 70 | previousProfessorsTableBody.querySelector(`tr[data-option-id='${optionId}']`).removeAttribute("hidden"); 71 | } else { 72 | option.setAttribute("hidden", ""); 73 | previousProfessorsTableBody.querySelector(`tr[data-option-id='${optionId}']`).setAttribute("hidden", ""); 74 | } 75 | }); 76 | } 77 | filtersDiv.querySelector("a").addEventListener('click', (event) => { 78 | event.preventDefault(); 79 | this.#utils.runAsync("filterClassSchedules", filterClassSchedules); 80 | }); 81 | } 82 | 83 | #addPreviousProfessorsTable(alternativesCombo, filtersDiv, previousProfessorsTableBody) { 84 | return Promise.resolve().then(() => { 85 | return this.#fetchCourseAlternatives(); 86 | }).then(courseOptionsData => { 87 | return this.#renderPreviousProfessorsTable(alternativesCombo, previousProfessorsTableBody, courseOptionsData); 88 | }).then(() => { 89 | // Once everything is rendered, display filter box: 90 | return this.#addClassSchedulesFilter(alternativesCombo, filtersDiv, previousProfessorsTableBody); 91 | }); 92 | } 93 | 94 | #fetchCourseAlternatives() { 95 | return Promise.resolve().then(() => { 96 | // Avoid using cache as the endpoint is always the same but the student may register or unregister from the course. 97 | return this.#pagesDataParser.fetchAjaxGETContents(location.href, false); 98 | }).then(response => { 99 | if (!response.agenda || !response.agenda.comisiones) throw new Error(`Missing course alternatives for ${location.href}. response: ${JSON.stringify(response)}`); 100 | 101 | // `comisiones` may include the current class schedules. This is not a problem because we access the array by id. 102 | // But we could eventually filter them out by using the `cursadas` array if we confirm it 103 | return response.agenda.comisiones; 104 | }); 105 | } 106 | 107 | #renderPreviousProfessorsTable(alternativesCombo, previousProfessorsTableBody, courseOptionsData) { 108 | let optionId = 0; 109 | let optionDetails = []; 110 | let previousProfessorsRequest = Array.from(alternativesCombo.options) 111 | .map(option => { 112 | let classData = courseOptionsData[option.value]; 113 | if (!classData) return null; 114 | 115 | let classSchedule = this.#pagesDataParser.mapClassDataToClassSchedule(classData); 116 | 117 | // Set an optionId in the option text to identify in the table that is added later. 118 | optionId++; 119 | let textParts = option.textContent.split("|").map(t => t.trim()); 120 | optionDetails.push(textParts.join("
    ") + "
    " + classSchedule.classCode); 121 | 122 | option.textContent = `(${optionId}) | ${option.textContent} | ${classSchedule.classCode}`; 123 | option.setAttribute("data-option-id", optionId); 124 | 125 | return classSchedule; 126 | }) 127 | .filter(req => !!req); 128 | 129 | // Returns a List that corresponds one to one with the request list, with maps that represent: year -> classCode (the new one) -> List of professors 130 | return this.#apiConnector.getPreviousProfessors(previousProfessorsRequest).then(response => { 131 | for (let i = 0; i < response.length; i++) { 132 | let previousProfessors = response[i]; 133 | optionId = i + 1; 134 | 135 | let content = `
      `; 136 | Object.entries(previousProfessors) 137 | .sort((a, b) => (a[0] < b[0] ? 1 : a[0] > b[0] ? -1 : 0)) 138 | .forEach(([year, classes]) => { 139 | content += `
    • ${year}
        `; 140 | Object.entries(classes) 141 | .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) 142 | .forEach(([newClassCode, professors]) => { 143 | content += `
      • ${newClassCode}
          `; 144 | professors.forEach(professor => { 145 | content += this.#utils.getProfessorLi(professor); 146 | }); 147 | content += `
      • `; 148 | }); 149 | content += `
    • `; 150 | }); 151 | content += `
    `; 152 | 153 | const row = document.createElement("tr"); 154 | row.setAttribute("data-option-id", optionId); 155 | row.innerHTML = `(${optionId})${optionDetails[i]}${content}`; 156 | previousProfessorsTableBody.appendChild(row); 157 | } 158 | }); 159 | } 160 | 161 | init() { 162 | return Promise.resolve().then(() => { 163 | // Need to listen to course register changes, as the combo is reloaded, and we need to add the table again. 164 | // We need to un register them on close, as changing a course will trigger a new PreInscripcionPage. 165 | // Events triggered from foreground script: 166 | this.#addPreviousProfessorsTableEventFn = () => { 167 | let alternativesDiv = document.querySelector("#insc_alternativas .inscripcion-alternativa"); 168 | if (!alternativesDiv) return; // There may be no div if for example the alternative already has a selected option. 169 | alternativesDiv.insertAdjacentHTML("beforebegin", ` 170 |
    171 |
    172 |

    UTN.BA HELPER - Información importante

    173 |

    La información sobre profesores anteriores es provista por el "UTN.BA Helper" y no es parte del sistema de la UTN.

    174 |

    La intención de esta tabla es, en base a datos colectados por el "UTN.BA Helper", intentar predecir que profesor va a estar en cada cursada, basándonos en los profesores que estuvieron en cursadas anteriores. 175 |

    Para cada horario presentado en el combo de abajo, se muestra un item en la tabla, que puede ser identificado por el ID que es agregado al texto de cada opción, y que es mostrado en cada fila de la tabla.

    176 |
    177 |
    178 |

    Filtros

    179 |
    180 |
    181 |

    Profesores en años anteriores

    182 |
    IDDetalleProfesores en años anteriores
    183 |
    184 |
    185 |
    186 | `); 187 | let alternativesCombo = document.querySelector('#comision'); 188 | let filtersDiv = document.querySelector("#insc_alternativas .utnba-helper .filters"); 189 | let previousProfessorsTableBody = document.querySelector("#insc_alternativas .utnba-helper .previous-professors tbody"); 190 | this.#utils.runAsync("addPreviousProfessorsTable", () => this.#addPreviousProfessorsTable(alternativesCombo, filtersDiv, previousProfessorsTableBody)); 191 | }; 192 | this.#addPreviousProfessorsTableEventWithDelayFn = () => setTimeout(this.#addPreviousProfessorsTableEventFn, 500); 193 | 194 | window.addEventListener("__utn_ba_event_comision_preinscripta", this.#addPreviousProfessorsTableEventFn); 195 | window.addEventListener("__utn_ba_event_comision_despreinscripta", this.#addPreviousProfessorsTableEventFn); 196 | window.addEventListener("__utn_ba_event_setear_comisiones_insc_alternativa", this.#addPreviousProfessorsTableEventWithDelayFn); 197 | return this.#addPreviousProfessorsTableEventFn(); 198 | }); 199 | } 200 | 201 | close() { 202 | window.removeEventListener("__utn_ba_event_comision_preinscripta", this.#addPreviousProfessorsTableEventFn); 203 | window.removeEventListener("__utn_ba_event_comision_despreinscripta", this.#addPreviousProfessorsTableEventFn); 204 | window.removeEventListener("__utn_ba_event_setear_comisiones_insc_alternativa", this.#addPreviousProfessorsTableEventWithDelayFn); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/guarani/custompages/PlanTrackingCustomPage.js: -------------------------------------------------------------------------------- 1 | import './PlanTrackingCustomPage.css'; 2 | 3 | import $ from 'jquery'; 4 | import {CustomPages} from './CustomPages.js'; 5 | 6 | const TRANSLATIONS = { 7 | "SIGNED": "Firmada", 8 | "PASSED": "Aprobada", 9 | "REGISTER": "Cursar", 10 | "TAKE_FINAL_EXAM": "Rendir final", 11 | }; 12 | 13 | export class PlanTrackingCustomPage { 14 | static menuName = "Seguimiento de Plan"; 15 | static customParamKey = ""; 16 | 17 | #$container; 18 | #services; 19 | #$gradesSummary; 20 | #planDiv; 21 | 22 | constructor(container, services) { 23 | this.#$container = $(container); 24 | this.#services = services; 25 | } 26 | 27 | #createPage(planCode, coursesHistory) { 28 | let promises = []; 29 | 30 | this.#$container.append(`

    Plan ${planCode}

    `); 31 | 32 | this.#$gradesSummary = $("
    "); 33 | this.#$container.append(this.#$gradesSummary); 34 | promises.push(this.#buildGradesSummary(coursesHistory)); 35 | 36 | this.#$container.append("
    "); 37 | 38 | this.#planDiv = document.createElement('div'); 39 | this.#$container.append(this.#planDiv); 40 | promises.push(this.#loadPlan(planCode, coursesHistory)); 41 | 42 | return Promise.all(promises); 43 | } 44 | 45 | #buildGradesSummary(coursesHistory) { 46 | let startYear = coursesHistory.courses.concat(coursesHistory.finalExams) 47 | .map(course => course.date) 48 | .sort((a, b) => a - b) 49 | .map(date => date.getFullYear()) 50 | [0]; 51 | if (!startYear) return; // If no courses nor finalExams, there is nothing we can show. 52 | let yearsCount = (new Date().getFullYear() - startYear + 1); 53 | 54 | const arrayAverage = arr => Math.round(arr.reduce((a, b) => a + b, 0) / arr.length * 100) / 100; 55 | 56 | let passedFinalExams = coursesHistory.finalExams.filter(course => course.isPassed); 57 | let failedFinalExams = coursesHistory.finalExams.filter(course => !course.isPassed); 58 | let pesoAcademico = 11 * passedFinalExams.length - 5 * yearsCount - 3 * failedFinalExams.length; 59 | 60 | // Some final exams do not have grade (e.g. "Equivalencia Total") so we ignore them for the average. 61 | let passedWeightedGrades = passedFinalExams.filter(course => typeof course.weightedGrade === "number").map(course => course.weightedGrade); 62 | let failedWeightedGrades = failedFinalExams.filter(course => typeof course.weightedGrade === "number").map(course => course.weightedGrade); 63 | let passedNonWeightedGrades = passedFinalExams.filter(course => typeof course.grade === "number").map(course => course.grade); 64 | let failedNonWeightedGrades = failedFinalExams.filter(course => typeof course.grade === "number").map(course => course.grade); 65 | 66 | let allWeightedGradesAverage = arrayAverage(passedWeightedGrades.concat(failedWeightedGrades)); 67 | let passedWeightedGradesAverage = arrayAverage(passedWeightedGrades); 68 | let allNonWeightedGradesAverage = arrayAverage(passedNonWeightedGrades.concat(failedNonWeightedGrades)); 69 | let passedNonWeightedGradesAverage = arrayAverage(passedNonWeightedGrades); 70 | 71 | this.#$gradesSummary.html(`
    72 | a Peso académico: Materias Aprobadas * 11 - años de carrera * 5 - finales desaprobados * 3
    73 | b La nota ponderada es calculada por el "UTN.BA Helper" según Ordenanza Nº 1549
    `); 74 | const appendTableRow = (description, value) => this.#$gradesSummary.find("tbody").append("" + description + "" + (value || value === 0 ? value : "n/a") + ""); 75 | 76 | appendTableRow("Peso academico", `${pesoAcademico} (11*${passedFinalExams.length} - 5*${yearsCount} - 3*${failedFinalExams.length}) a`); 77 | appendTableRow("Cantidad de finales aprobados", passedFinalExams.length); 78 | appendTableRow("Cantidad de finales desaprobados", failedFinalExams.length); 79 | appendTableRow("Promedio de notas ponderadasb con desaprobados", allWeightedGradesAverage); 80 | appendTableRow("Promedio de notas ponderadasb sin desaprobados", passedWeightedGradesAverage); 81 | appendTableRow("Promedio de notas originalesb con desaprobados", allNonWeightedGradesAverage); 82 | appendTableRow("Promedio de notas originalesb sin desaprobados", passedNonWeightedGradesAverage); 83 | 84 | return this.#services.dataCollector.logUserStat(pesoAcademico, passedWeightedGradesAverage, allWeightedGradesAverage, passedFinalExams.length, failedFinalExams.length); 85 | } 86 | 87 | //... 88 | 89 | #loadPlan(planCode, coursesHistory) { 90 | if (!planCode) return; 91 | return this.#services.apiConnector.getPlanCourses(planCode).then(planCourses => { 92 | return this.#loadPlanCourses(planCourses, coursesHistory); 93 | }); 94 | } 95 | 96 | #loadPlanCourses(planCourses, coursesHistory) { 97 | let courseNamesByCode = planCourses.reduce(function (courseNamesByCode, course) { 98 | courseNamesByCode[course.courseCode] = course.courseName; 99 | return courseNamesByCode; 100 | }, {}); 101 | 102 | // For signed courses we consider both passed and signed, and remove duplicates. 103 | let passedCourses = coursesHistory.finalExams.filter(course => course.isPassed).map(course => course.courseCode); 104 | let signedCourses = [...new Set([...passedCourses, ...coursesHistory.courses.filter(course => course.isPassed).map(course => course.courseCode)])]; 105 | let courseRequirementToArray = { 106 | "SIGNED": signedCourses, 107 | "PASSED": passedCourses, 108 | }; 109 | let hasCourse = (requirement, courseCode) => courseRequirementToArray[requirement].includes(courseCode); 110 | 111 | let getCoursesHtml = level => { 112 | let lastWasElective = false; 113 | let showExtraElectivesButtonAdded = false; 114 | return planCourses.filter(course => { 115 | return course.level === level; 116 | }).map(course => { 117 | let meetsDependencies = kind => course.dependencies 118 | .filter(dependency => dependency.kind === kind) 119 | .every(dependency => hasCourse(dependency.requirement, dependency.courseCode)); 120 | course.isSigned = hasCourse("SIGNED", course.courseCode); 121 | course.isPassed = hasCourse("PASSED", course.courseCode); 122 | course.canRegister = meetsDependencies("REGISTER"); 123 | course.canTakeFinalExam = meetsDependencies("TAKE_FINAL_EXAM"); 124 | return course; 125 | }).sort((c1, c2) => { 126 | let courseWeight = course => { 127 | let w; 128 | if (course.isPassed) w = 10; 129 | else if (course.canTakeFinalExam) w = 9; 130 | else if (course.isSigned) w = 8; 131 | else if (course.canRegister) w = 7; 132 | else w = 6; 133 | if (course.elective) { 134 | w -= 5; 135 | } 136 | return w; 137 | }; 138 | return courseWeight(c2) - courseWeight(c1); 139 | }).map(course => { 140 | let status; 141 | let backgroundColor = "#7e7e7e"; 142 | let color = "#000000"; 143 | if (course.isPassed) { 144 | status = TRANSLATIONS["PASSED"]; 145 | backgroundColor = "#55bb55"; 146 | } else if (course.canTakeFinalExam) { 147 | status = "Puede " + TRANSLATIONS["TAKE_FINAL_EXAM"].toLowerCase(); 148 | backgroundColor = "#ffcc00"; 149 | } else if (course.isSigned) { 150 | status = TRANSLATIONS["SIGNED"]; 151 | backgroundColor = "#ffcc00"; 152 | } else if (course.canRegister) { 153 | status = "Puede " + TRANSLATIONS["REGISTER"].toLowerCase(); 154 | backgroundColor = "#5555bb"; 155 | color = "#f1f1f1"; 156 | } 157 | 158 | let hr = ""; 159 | if (!lastWasElective && course.elective) { 160 | lastWasElective = true; 161 | hr = `
    Electivas:
    `; 162 | } 163 | 164 | let divClass = ""; 165 | let showExtraElectivesButton = ""; 166 | if (course.elective && !course.isPassed && !course.canTakeFinalExam && !course.isSigned) { 167 | divClass = "hidden"; 168 | if (!showExtraElectivesButtonAdded) { 169 | showExtraElectivesButtonAdded = true; 170 | showExtraElectivesButton = `> Mostrar todas`; 171 | } 172 | } 173 | 174 | let getDependenciesLines = dependencyKind => course.dependencies 175 | .filter(dependency => dependency.kind === dependencyKind) 176 | .map(dependency => { 177 | let line = `${TRANSLATIONS[dependency.requirement]} [${dependency.courseCode}] ${courseNamesByCode[dependency.courseCode] || ""}`; 178 | if (hasCourse(dependency.requirement, dependency.courseCode)) line = `${line}`; 179 | return line; 180 | }) 181 | .join("
    "); 182 | 183 | return ` 184 | ${hr} 185 | ${showExtraElectivesButton} 186 | `; 200 | }).join(""); 201 | }; 202 | 203 | let levels = [...new Set(planCourses 204 | .filter(course => course.level !== 0) // TODO this should be removed if we update courses... 205 | .map(course => course.level))] 206 | .sort(); 207 | 208 | let ths = levels.map(level => `Nivel ${level}`).join(""); 209 | let tds = levels.map(level => `${getCoursesHtml(level)}`).join(""); 210 | this.#planDiv.innerHTML = ` 211 | 212 | 213 | ${ths} 214 | ${tds} 215 | 216 |
    217 | `; 218 | 219 | this.#planDiv.querySelectorAll("table .show-electives").forEach(element => { 220 | element.addEventListener("click", e => { 221 | let level = element.getAttribute("data-level"); 222 | this.#planDiv.querySelectorAll(`table .course.level-${level}`).forEach(el => el.classList.remove("hidden")); 223 | element.remove(); // TODO eventually we could allow hiding again the electives. 224 | e.preventDefault(); 225 | }); 226 | }); 227 | } 228 | 229 | init() { 230 | return Promise.resolve().then(() => { 231 | return Promise.all([ 232 | this.#services.pagesDataParser.getStudentPlanCode(), 233 | this.#services.pagesDataParser.getCoursesHistory(), 234 | ]); 235 | }).then(result => { 236 | let planCode = result[0]; 237 | let coursesHistory = result[1]; 238 | return this.#createPage(planCode, coursesHistory); 239 | }); 240 | } 241 | 242 | close() { 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/guarani/custompages/ProfessorsSearchCustomPage.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {Chart} from 'chart.js/auto'; 3 | import {log} from "@embrace-io/web-sdk"; 4 | import {CustomPages} from './CustomPages.js'; 5 | 6 | const SENTIMENT_COLORS = { 7 | "POSITIVE": "#19B135", 8 | "NEUTRAL": "#000000", 9 | "NEGATIVE": "#D51C26", 10 | } 11 | 12 | export class ProfessorsSearchCustomPage { 13 | static menuName = "Buscar docentes"; 14 | static customParamKey = "professorName"; 15 | 16 | #$container; 17 | #services; 18 | #$searchDiv; 19 | #$searchResultsDiv; 20 | #$professorResultsTitleDiv; // Just the title with the professor name. 21 | #$coursesResultDiv; // Shows the last courses in which the professor was present 22 | #$surveyResultDiv; // Shows the survey results of the given professor 23 | 24 | constructor(container, services) { 25 | this.#$container = $(container); 26 | this.#services = services; 27 | } 28 | 29 | #createPage() { 30 | this.#$searchDiv = $("
    "); 31 | this.#$searchDiv.append(`Buscar docente: `); 32 | let $searchTxt = $(``); 33 | $searchTxt.on("keydown", (e) => { 34 | if (e.key === "Enter") { 35 | this.#services.utils.runAsync("ProfessorsSearch", () => this.#search($searchTxt.val().trim())); 36 | return false; 37 | } 38 | }); 39 | this.#$searchDiv.append($searchTxt); 40 | let $searchBtn = $(`Buscar`); 41 | $searchBtn.on("click", () => { 42 | this.#services.utils.runAsync("ProfessorsSearch", () => this.#search($searchTxt.val().trim())); 43 | return false; 44 | }); 45 | this.#$searchDiv.append($searchBtn); 46 | this.#$searchDiv.append("
    "); 47 | this.#$container.append(this.#$searchDiv); 48 | 49 | this.#$searchResultsDiv = $(`
    `); 50 | this.#$searchResultsDiv.hide(); 51 | this.#$searchResultsDiv.append("

    Resultados de busqueda

    "); 52 | let $searchResultsTable = $(`
    `).append(""); 53 | $searchResultsTable.on("click", "a", (e) => { 54 | let professorName = $(e.currentTarget).text(); 55 | this.#services.utils.runAsync("retrieveProfessorData", () => this.#retrieveProfessorData(professorName)); 56 | return false; 57 | }); 58 | this.#$searchResultsDiv.append($searchResultsTable); 59 | this.#$searchResultsDiv.append("
    "); 60 | this.#$container.append(this.#$searchResultsDiv); 61 | 62 | this.#$professorResultsTitleDiv = $(`
    `); 63 | this.#$container.append(this.#$professorResultsTitleDiv); 64 | this.#$coursesResultDiv = $(`
    `); 65 | this.#$container.append(this.#$coursesResultDiv); 66 | this.#$surveyResultDiv = $(`
    `); 67 | this.#$container.append(this.#$surveyResultDiv); 68 | } 69 | 70 | #search(query) { 71 | if (query.length < 3) return; 72 | this.#hideProfessorData(); 73 | this.#$searchResultsDiv.show().get(0).scrollIntoView({behavior: "smooth"}); 74 | this.#$searchResultsDiv.hide(); 75 | log.message("Searching professors", 'info', {attributes: {query: query}}); 76 | return this.#services.apiConnector.searchProfessors(query).then(results => { 77 | let trs = results.map(item => { 78 | return `${item.value}${item.data.surveysCount}${item.data.classScheduleOccurrences}`; 79 | }).join(""); 80 | this.#$searchResultsDiv.show(); 81 | this.#$searchResultsDiv.find("table tbody") 82 | .html(trs) 83 | .prepend("ProfesorCantidad de encuestas (total historico)Cantidad de cursos (total historico)"); 84 | }); 85 | } 86 | 87 | #hideProfessorData() { 88 | this.#$professorResultsTitleDiv.hide(); 89 | this.#$coursesResultDiv.hide(); 90 | this.#$surveyResultDiv.hide(); 91 | } 92 | 93 | #retrieveProfessorData(professorName) { 94 | this.#$professorResultsTitleDiv.show().get(0).scrollIntoView({behavior: "smooth"}); 95 | this.#$professorResultsTitleDiv.html(`

    Resultados para ${professorName}


    `); 96 | return Promise.all([ 97 | this.#retrieveProfessorCourses(professorName), 98 | this.#retrieveSurveyResults(professorName), 99 | ]); 100 | } 101 | 102 | #retrieveProfessorCourses(professorName) { 103 | this.#$coursesResultDiv.hide(); 104 | // For now, we are showing just the latest 20 classes. 105 | return this.#services.apiConnector.getClassesForProfessor(professorName, 0, 20).then(classSchedules => { 106 | this.#$coursesResultDiv.html(""); 107 | let trs = classSchedules.map(classSchedule => { 108 | let professorLis = (classSchedule.professors || []).map(professor => { 109 | return this.#services.utils.getProfessorLi(professor); 110 | }).join(""); 111 | return ` 112 | ${classSchedule.year} 113 | ${classSchedule.quarter} 114 | ${classSchedule.courseName} 115 | ${classSchedule.classCode} 116 | ${classSchedule.branch || "-"} 117 | ${this.#services.utils.getSchedulesAsString(classSchedule.schedules)} 118 |
      ${professorLis}
    119 | `; 120 | }).join(""); 121 | this.#$coursesResultDiv.append(` 122 |

    Cursos en los que estuvo presente en los últimos 3 años (incluyendo el actual)

    123 | 124 | 125 | 126 | ${trs} 127 | 128 |
    Cuatr.MateriaCursoAnexoHorarioProfesores
    129 | `); 130 | this.#$coursesResultDiv.append(`
    `); 131 | this.#$coursesResultDiv.show(); 132 | }); 133 | } 134 | 135 | #retrieveSurveyResults(professorName) { 136 | this.#$surveyResultDiv.hide(); 137 | return this.#services.apiConnector.getProfessorSurveysAggregate(professorName).then(response => { 138 | this.#$surveyResultDiv.html(""); 139 | Object.entries(response) 140 | // Put DOCENTE before AUXILIAR 141 | .sort((a, b) => (a[0] > b[0] ? -1 : 1)) 142 | .forEach(entry => this.#appendSurveyResults(entry[0], entry[1])); 143 | this.#$surveyResultDiv.show(); 144 | }); 145 | } 146 | 147 | #appendSurveyResults(surveyKind, results) { 148 | this.#$surveyResultDiv.append(`

    Encuesta de tipo ${surveyKind}

    `); 149 | 150 | if (results.historicalScores && Object.keys(results.historicalScores).length) { 151 | let canvasId = `historical-score-${surveyKind}`; 152 | 153 | this.#$surveyResultDiv.append(` 154 |
    155 | `); 156 | 157 | let minYear, maxYear; 158 | Object.values(results.historicalScores).forEach(historicalScores => { 159 | historicalScores.forEach(historicalScore => { 160 | let year = parseInt(historicalScore.year); 161 | if (!minYear || year < minYear) minYear = year; 162 | if (!maxYear || year > maxYear) maxYear = year; 163 | }); 164 | }); 165 | 166 | let canvas = document.getElementById(canvasId); 167 | new Chart(canvas, { 168 | type: 'line', 169 | data: { 170 | // All years in between as labels 171 | labels: Array.from({length: maxYear - minYear + 1}, (_, i) => minYear + i), 172 | datasets: Object.entries(results.historicalScores).map(historicalScore => { 173 | let courseName = historicalScore[0]; 174 | let historicalScores = historicalScore[1]; 175 | 176 | let data = Array.from({length: maxYear - minYear + 1}, (_, i) => { 177 | let year = minYear + i; 178 | // Not ideal to use `find` but these are usually a few datapoints so it's fine... 179 | let score = historicalScores.find(score => parseInt(score.year) === year); 180 | return score ? score.overallScore : undefined; 181 | }); 182 | return { 183 | label: courseName, 184 | data: data, 185 | }; 186 | }) 187 | }, 188 | plugins: [ 189 | { 190 | id: 'backgroundColor', 191 | beforeDraw: (chart) => { 192 | let backgroundRules = [ 193 | {from: 0, to: 60}, 194 | {from: 60, to: 80}, 195 | {from: 80, to: 100}, 196 | ]; 197 | 198 | let xAxis = chart.scales.x; 199 | let yAxis = chart.scales.y; 200 | backgroundRules.forEach(rule => { 201 | let yTop = yAxis.top + (yAxis.height / 100 * (100 - rule.to)); 202 | let yHeight = yAxis.height / 100 * (rule.to - rule.from); 203 | chart.ctx.fillStyle = this.#services.utils.getColorForAvg(rule.from, 0.1); 204 | chart.ctx.fillRect(xAxis.left, yTop, xAxis.width, yHeight); 205 | }); 206 | } 207 | }, 208 | ], 209 | options: { 210 | maintainAspectRatio: false, 211 | plugins: { 212 | title: { 213 | display: true, 214 | text: 'Puntajes históricos', 215 | }, 216 | }, 217 | scales: { 218 | y: { 219 | suggestedMin: 0, 220 | suggestedMax: 100, 221 | }, 222 | }, 223 | }, 224 | } 225 | ); 226 | } 227 | 228 | 229 | if (results.percentageFields.length) { 230 | let percentageRows = results.percentageFields.map(item => { 231 | return `${item.question}${item.average}${item.count}`; 232 | }).join(""); 233 | this.#$surveyResultDiv.append(` 234 |

    Puntajes

    235 |
    General: ${this.#services.utils.getOverallScoreSpan(results.overallScore)}
    236 | 237 | 238 | 239 | ${percentageRows} 240 | 241 |
    PreguntaPromedioMuestra
    242 | `); 243 | } 244 | 245 | if (results.textFieldGroups.length) { 246 | // First collect all question sentiments to create a header 247 | let questionsBySentiment = {}; 248 | results.textFieldGroups.forEach(group => { 249 | questionsBySentiment[group.sentiment] = group.question; 250 | }); 251 | let sentimentsSorted = Object.keys(questionsBySentiment).sort().reverse(); 252 | let textHeaderColumns = sentimentsSorted.map(sentiment => { 253 | return `${questionsBySentiment[sentiment]}`; 254 | }).join(""); 255 | 256 | // Now for each group create rows: 257 | let answersByGroup = {}; 258 | results.textFieldGroups.forEach(group => { 259 | let key = `${group.year} ${group.quarter} - ${group.courseName} (${group.courseCode})`; 260 | if (!answersByGroup[key]) answersByGroup[key] = {}; 261 | answersByGroup[key][group.sentiment] = group.values; 262 | }); 263 | 264 | let textValueRows = Object.keys(answersByGroup).sort().reverse().map(groupKey => { 265 | let textValueColumns = sentimentsSorted.map(sentiment => { 266 | let values = answersByGroup[groupKey][sentiment] || []; 267 | let answers = values.map(answer => `"${answer}"`).join(`
    `); 268 | return `${answers}`; 269 | }).join(""); 270 | 271 | return ` 272 | ${groupKey} 273 | ${textValueColumns} 274 | `; 275 | }).join(""); 276 | 277 | this.#$surveyResultDiv.append(` 278 |

    Comentarios

    279 | 280 | 281 | ${textHeaderColumns} 282 | ${textValueRows} 283 | 284 |
    285 | `); 286 | } 287 | this.#$surveyResultDiv.append(`
    `); 288 | } 289 | 290 | init() { 291 | return Promise.resolve().then(() => { 292 | this.#createPage(); 293 | let professorName = new URLSearchParams(window.location.search).get(ProfessorsSearchCustomPage.customParamKey); 294 | if (professorName) { 295 | return this.#retrieveProfessorData(professorName); 296 | } 297 | }); 298 | } 299 | 300 | close() { 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/guarani/__fixtures__/pagesDataParser.getStudentId_missing_div.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |
    4 |
    5 |
    6 | 7 |
    8 | 9 |
    10 |
    11 |
    12 | 32 |
    33 |
    34 |
    35 |
    36 |
    37 |
    38 | 169 | 170 | 171 | 172 | 173 | 174 | 175 |
    176 | 177 |
    178 |
    179 |

    Bienvenido/a PABLO MATIAS GOMEZ

    180 |
    181 | 182 |
    183 |
    184 |
    185 |
    186 |

      Períodos lectivos

    • Grado SyS 2025

      • Tipo: anual
      • Fechas del período lectivo:
        • Inicio: 12/08/2024
        • Finalización: 12/07/2025
      • Período de inscripción #1:
        • Inicio: 12/07/2024
        • Finalización: 15/08/2024
    • Grado Anual 2025

      • Tipo: anual
      • Fechas del período lectivo:
        • Inicio: 25/03/2025
        • Finalización: 29/11/2025
      • Período de inscripción #1:
        • Inicio: 26/02/2025
        • Finalización: 01/04/2025
    • Grado Primer Cuatrimestre 2025

      • Tipo: cuatrimestre
      • Fechas del período lectivo:
        • Inicio: 25/03/2025
        • Finalización: 12/07/2025
      • Período de inscripción #1:
        • Inicio: 26/02/2025
        • Finalización: 01/04/2025

      Turnos de examen

    187 |
    188 |
    189 |

      Encuestas pendientes

      No hay encuestas pendientes para completar
    190 |
    191 | 192 |
    193 | 194 |
    195 |

    Servicios

    196 | 203 |
    204 | 205 |
    206 |
    207 | 208 |
    209 | 210 |
    211 |
    212 |
    213 |
    214 |
    215 | 216 |
    217 |
    218 |
    219 | 222 |
    223 |
    224 |
    225 |
    226 | 227 | 228 | 229 | 230 | 231 | -------------------------------------------------------------------------------- /src/guarani/__fixtures__/pagesDataParser.getStudentId_successful_parsing.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |
    4 |
    5 |
    6 | 7 |
    8 | 9 |
    10 |
    11 |
    12 | 32 |
    33 |
    34 | 35 |
    36 |
    37 |
    38 |
    39 | Propuesta: 40 | Ingeniería en Sistemas de Información 41 |
    42 | Legajo: 43 | 149.388-7 44 |
    45 |
    46 |
    47 |
    48 |
    49 |
    50 |
    51 |
    52 |
    53 | 184 | 185 | 186 | 187 | 188 | 189 | 190 |
    191 | 192 |
    193 |
    194 |

    Bienvenido/a PABLO MATIAS GOMEZ

    195 |
    196 | 197 |
    198 |
    199 |
    200 |
    201 |

      Períodos lectivos

    • Grado SyS 2025

      • Tipo: anual
      • Fechas del período lectivo:
        • Inicio: 12/08/2024
        • Finalización: 12/07/2025
      • Período de inscripción #1:
        • Inicio: 12/07/2024
        • Finalización: 15/08/2024
    • Grado Anual 2025

      • Tipo: anual
      • Fechas del período lectivo:
        • Inicio: 25/03/2025
        • Finalización: 29/11/2025
      • Período de inscripción #1:
        • Inicio: 26/02/2025
        • Finalización: 01/04/2025
    • Grado Primer Cuatrimestre 2025

      • Tipo: cuatrimestre
      • Fechas del período lectivo:
        • Inicio: 25/03/2025
        • Finalización: 12/07/2025
      • Período de inscripción #1:
        • Inicio: 26/02/2025
        • Finalización: 01/04/2025

      Turnos de examen

    202 |
    203 |
    204 |

      Encuestas pendientes

      No hay encuestas pendientes para completar
    205 |
    206 | 207 |
    208 | 209 |
    210 |

    Servicios

    211 | 218 |
    219 | 220 |
    221 |
    222 | 223 |
    224 | 225 |
    226 |
    227 |
    228 |
    229 |
    230 | 231 |
    232 |
    233 |
    234 | 237 |
    238 |
    239 |
    240 |
    241 | 242 | 243 | 244 | 245 | 246 | -------------------------------------------------------------------------------- /src/guarani/pages/__fixtures__/horariosPage.init_empty_agenda.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 | 6 |
    7 | 8 |
    9 |
    10 |
    11 | 29 |
    30 |
    31 | 32 |
    33 |
    34 |
    35 |
    36 | Propuesta: 37 | Ingeniería en Sistemas de Información 38 |
    39 | Legajo: 40 | 149.388-7 41 |
    42 |
    43 |
    44 |
    45 |
    46 |
    47 |
    48 |
    49 |
    50 | 183 | 184 | 185 | 186 | 187 | 188 | 189 |
    190 |
    191 | 192 | 193 |

    Horarios de cursadas

    194 | 195 | 196 | 197 |
    198 |
    199 | 200 |
    201 | 202 |
    203 | 204 |

    Física I

    Horario: Sab 08:30 a 12:30
    Comision: I1022
    Ubicacion: Sede Campus

    Ingeniería y Sociedad

    Horario: Mar 14:15 a 17:30
    Comision: K1545
    Ubicacion: Sede Campus

    Algoritmos y Estructura de Datos

    Horario: Jue 08:30 a 12:30
    Comision: K1030
    Ubicacion: Sede Campus

    Lógica y Estructuras Discretas

    Horario: Lun 10:15 a 12:30
    Comision: K1030
    Ubicacion: Sede Medrano

    Arquitectura de Computadores

    Horario: Mie 11:00 a 13:15 - Mie 13:30 a 14:15
    Comision: K1030
    Ubicacion: Sede Medrano

    Sistemas y Procesos de Negocio

    Horario: Lun 07:45 a 10:00
    Comision: K1030
    Ubicacion: Sede Medrano

    Análisis Matemático I

    Horario: Mar 08:30 a 12:30
    Comision: O1021
    Ubicacion: Sede Campus

    Álgebra y Geometría Analítica

    Horario: Vie 08:30 a 12:30
    Comision: O1021
    Ubicacion: Sede Campus
    205 | 206 | 212 | 213 |
    214 |
    215 | 216 |
    217 | 218 |
    219 |
    220 |
    221 |
    222 |
    223 | 224 |
    225 |
    226 |
    227 | 230 |
    231 |
    232 |
    233 |
    234 | 237 | 238 | 239 |
    -------------------------------------------------------------------------------- /src/guarani/pages/__fixtures__/horariosPage.init_missing_cursada_elements.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 | 6 |
    7 | 8 |
    9 |
    10 |
    11 | 29 |
    30 |
    31 | 32 |
    33 |
    34 |
    35 |
    36 | Propuesta: 37 | Ingeniería en Sistemas de Información 38 |
    39 | Legajo: 40 | 149.388-7 41 |
    42 |
    43 |
    44 |
    45 |
    46 |
    47 |
    48 |
    49 |
    50 | 183 | 184 | 185 | 186 | 187 | 188 | 189 |
    190 |
    191 | 192 | 193 |

    Horarios de cursadas

    194 | 195 | 196 | 197 |
    198 |
    Lunes
    Martes
    Miércoles
    Jueves
    Viernes
    Sábado
    Mañana
    0
    1
    2
    3
    4
    5
    6
    Tarde
    0
    1
    2
    3
    4
    5
    6
    Noche
    0
    1
    2
    3
    4
    5
    199 | 200 |
    201 | 202 |
    203 | 204 |
    205 | 206 | 212 | 213 |
    214 |
    215 | 216 |
    217 | 218 |
    219 |
    220 |
    221 |
    222 |
    223 | 224 |
    225 |
    226 |
    227 | 230 |
    231 |
    232 |
    233 |
    234 | 237 | 238 | 239 |
    --------------------------------------------------------------------------------