├── .prettierrc ├── .gitignore ├── public ├── fplan.png ├── favicon.png └── index.html ├── babel.config.js ├── .github └── workflows │ └── node.js.yml ├── src ├── useWindowSize.js ├── components │ ├── CalendarWeek.js │ ├── Body.js │ ├── Calendar.js │ ├── CalendarAgenda.js │ ├── ManualUploadModal.js │ ├── Sugerencias.js │ ├── SelectExtra.js │ ├── SelectMateria.js │ ├── SelectCurso.js │ ├── TabSystem.js │ └── MateriasDrawer.js ├── theme.js ├── index.js ├── utils.js ├── siuparser.js ├── Style.css └── DataContext.js ├── LICENSE ├── README.md ├── package.json └── tests ├── siuparser.test.js ├── siu-json ├── siu-exactas-computacion-primer-cuatri.json └── siu-axel.json └── siu-raw └── siu-exactas-computacion-primer-cuatri.js /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | .ipynb_checkpoints 4 | .venv 5 | -------------------------------------------------------------------------------- /public/fplan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FdelMazo/FIUBA-Plan/HEAD/public/fplan.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FdelMazo/FIUBA-Plan/HEAD/public/favicon.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | ['@babel/preset-react', {runtime: 'automatic'}], 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy to Github Pages 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | node-to-gh: 9 | runs-on: ubuntu-latest 10 | name: Build & Deploy to Github Pages 11 | steps: 12 | - id: node-to-gh 13 | uses: fdelmazo/node-to-gh-action@v2 14 | - name: Run tests 15 | run: npm test 16 | shell: bash 17 | -------------------------------------------------------------------------------- /src/useWindowSize.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function useWindowSize() { 4 | const [windowSize, setWindowSize] = React.useState({ 5 | width: undefined, 6 | height: undefined, 7 | }); 8 | 9 | React.useEffect(() => { 10 | function handleResize() { 11 | setWindowSize({ 12 | width: window.innerWidth, 13 | height: window.innerHeight, 14 | }); 15 | } 16 | 17 | window.addEventListener("resize", handleResize); 18 | handleResize(); 19 | 20 | return () => window.removeEventListener("resize", handleResize); 21 | }, []); 22 | 23 | return windowSize; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/CalendarWeek.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TimeGrid from "react-big-calendar/lib/TimeGrid"; 3 | import Week from "react-big-calendar/lib/Week"; 4 | import WorkWeek from "react-big-calendar/lib/WorkWeek"; 5 | 6 | class CalendarWeek extends WorkWeek { 7 | range(date, events, options) { 8 | const showSabado = !!events.find((e) => e.end.getDay() === 6); 9 | const DAYS = showSabado ? [1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5]; 10 | return Week.range(date, options).filter((d) => DAYS.includes(d.getDay())); 11 | } 12 | 13 | render() { 14 | let { date, ...props } = this.props; 15 | let events = this.props.events || []; 16 | let range = this.range(date, events, this.props); 17 | 18 | return ; 19 | } 20 | } 21 | 22 | export default CalendarWeek; 23 | -------------------------------------------------------------------------------- /src/theme.js: -------------------------------------------------------------------------------- 1 | import { extendTheme } from "@chakra-ui/react"; 2 | import "react-big-calendar/lib/css/react-big-calendar.css"; 3 | import "./Style.css"; 4 | 5 | const config = { 6 | initialColorMode: "system", 7 | }; 8 | 9 | const customTheme = extendTheme({ 10 | fonts: { 11 | body: "Source Sans Pro", 12 | heading: "Georgia, serif", 13 | mono: "Menlo, monospace", 14 | }, 15 | colors: { 16 | primary: { 17 | 50: "#e5cff8", 18 | 100: "#dec4f7", 19 | 200: "#d8b8f6", 20 | 300: "#d2adf4", 21 | 400: "#cba2f3", 22 | 500: "#bf8cf0", 23 | 600: "#b881ee", 24 | 700: "#b275ed", 25 | 800: "#ac6aeb", 26 | 900: "#a55fea", 27 | }, 28 | drawerbg: "#222d38", 29 | drawerbgdark: "#3c4042", 30 | drawerbgalpha: "#222d38CC", 31 | drawerbgdarkalpha: "#3c4042CC", 32 | agendabgdark: "#323f56", 33 | calendarbg: "#f7f9fa", 34 | calendarbgdark: "#222d38", 35 | calendarbggrey: "#ededed", 36 | hovercolor: "#e2e8f033", 37 | }, 38 | config, 39 | }); 40 | 41 | export default customTheme; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Federico del Mazo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | FIUBA Plan · fede.dm 29 | 30 | 31 | 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [FIUBA-Plan](https://fede.dm/FIUBA-Plan/) 2 | 3 | Organizador de horarios de la Facultad de Ingenieria 4 | 5 | --- 6 | 7 | ![](public/fplan.png) 8 | 9 | Este proyecto apunta a una manera más facil de visualizar los horarios de cursada de la FIUBA. La idea es que no dependa de ningun servicio externo para conseguir los horarios cada cuatrimestre (porque no existe ninguno confiable que sea actualizado año a año), y los horarios sean cargados manualmente por el usuario que los puede obtener de su SIU (incluso, en una de esas funciona para SIUs de otras facultades de la UBA). 10 | 11 | ## Desarrollo 12 | 13 | Para agregar un feature o fixear un issue hay que clonar el repositorio, instalar las dependencias con `npm install` y después correr la aplicación con `npm start`. En `localhost:3000/` va a estar corriendo la aplicación constantemente, y toda modificación que se haga al código se va a ver reflejada en la página. 14 | 15 | Con `npm test` se pueden correr los tests del parser del SIU, para agregar tests de distintos SIUs podés agregar en `siu-raw` el texto de tu SIU, y en `siu-json` el objeto que da el parser luego de procesar el texto del SIU que pegas en el cuadro de texto (en la consola de desarrollo se imprime). 16 | 17 | Una vez terminados los cambios, con solo hacer un PR basta (porque la aplicación se compila automáticamente con cada push a master). 18 | 19 | Si tenés algún problema con el parser podés armar un issue con lo que intentaste pegar en el cuadro de texto. 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fiuba-plan", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@chakra-ui/icons": "^2.0.19", 7 | "@chakra-ui/react": "^2.7.1", 8 | "@emotion/react": "^11.11.1", 9 | "@emotion/styled": "^11.11.0", 10 | "@fontsource/source-sans-pro": "^4.5.1", 11 | "buffer": "^6.0.3", 12 | "color-hash": "^2.0.2", 13 | "downshift": "^8.5.0", 14 | "framer-motion": "^6.5.1", 15 | "immer": "^10.0.2", 16 | "moment": "^2.29.4", 17 | "pako": "^2.1.0", 18 | "react": "^18.2.0", 19 | "react-big-calendar": "^1.13.1", 20 | "react-dom": "^18.2.0", 21 | "react-error-boundary": "^4.0.13", 22 | "react-hotkeys-hook": "^4.4.0", 23 | "react-scripts": "^5.0.1", 24 | "use-immer": "^0.9.0" 25 | }, 26 | "homepage": "https://fede.dm/FIUBA-Plan", 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "eject": "react-scripts eject", 31 | "test": "jest" 32 | }, 33 | "eslintConfig": { 34 | "extends": "react-app" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@babel/plugin-proposal-private-property-in-object": "7.21.11", 50 | "jest": "^27.5.1", 51 | "prettier": "3.2.4" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { ChakraProvider, Flex } from "@chakra-ui/react"; 2 | import "@fontsource/source-sans-pro/400.css"; 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | import { ErrorBoundary } from "react-error-boundary"; 6 | import Body from "./components/Body"; 7 | import { DataProvider } from "./DataContext"; 8 | import customTheme from "./theme"; 9 | 10 | import { useToast } from '@chakra-ui/react' 11 | 12 | const App = () => { 13 | const toast = useToast(); 14 | 15 | return ( 16 | { 18 | console.warn( 19 | "Hubo un error inesperado. Se limpian los datos guardados", 20 | error 21 | ); 22 | 23 | toast({ 24 | title: "Ocurrió un error", 25 | description: "Se limpiaron los datos guardados.", 26 | status: "error", 27 | duration: 9000, 28 | isClosable: true, 29 | }) 30 | 31 | // Llamamos resetErrorBoundary() para resetear el error boundary y volver 32 | // a intentar el render 33 | resetErrorBoundary(); 34 | }} 35 | onReset={() => { 36 | localStorage.setItem("fiubaplan", JSON.stringify({})); 37 | }} 38 | > 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | const container = document.getElementById("root"); 49 | const root = ReactDOM.createRoot(container); 50 | 51 | root.render( 52 | 53 | 54 | 55 | ); 56 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer"; 2 | import ColorHash from "color-hash"; 3 | import { useCombobox, useSelect } from "downshift"; 4 | import pako from "pako"; 5 | 6 | const arr = (min, max, int) => { 7 | const arr = []; 8 | for (let i = min; i <= max; i += int) { 9 | arr.push(i); 10 | } 11 | return arr; 12 | }; 13 | 14 | const colorHash = new ColorHash({ 15 | lightness: arr(0.6, 0.85, 0.1), 16 | saturation: arr(0.6, 0.85, 0.1), 17 | hash: "bkdr", 18 | }); 19 | 20 | export const getColor = (event) => { 21 | if (!event) return null; 22 | return colorHash.hex(event.id.toString()); 23 | }; 24 | 25 | // Downshift util to not close the menu on an item selection (with click, space or enter) 26 | export function stateReducer(state, actionAndChanges) { 27 | const { changes, type } = actionAndChanges; 28 | switch (type) { 29 | case useCombobox.stateChangeTypes.InputKeyDownEnter: 30 | case useCombobox.stateChangeTypes.ItemClick: 31 | case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter: 32 | case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton: 33 | case useSelect.stateChangeTypes.ItemClick: 34 | return { 35 | ...changes, 36 | isOpen: true, 37 | highlightedIndex: state.highlightedIndex, 38 | }; 39 | default: 40 | return changes; 41 | } 42 | } 43 | 44 | export function base64tojson(data) { 45 | // b64 => pako => json 46 | const savedataPako = Buffer.from(data, "base64"); 47 | return JSON.parse(pako.ungzip(savedataPako, { to: "string" })); 48 | } 49 | 50 | export function jsontobase64(jsondata) { 51 | // json => pako => b64 52 | const savedataPako = pako.gzip(JSON.stringify(jsondata), { to: "string" }); 53 | return Buffer.from(savedataPako).toString("base64"); 54 | } 55 | -------------------------------------------------------------------------------- /src/siuparser.js: -------------------------------------------------------------------------------- 1 | const SEMANA = [ 2 | "Domingo", 3 | "Lunes", 4 | "Martes", 5 | "Miércoles", 6 | "Jueves", 7 | "Viernes", 8 | "Sábado", 9 | ]; 10 | 11 | export function parseSIU(rawdata) { 12 | // Asegurarse: 13 | // - no agregar ningun curso sin clases 14 | // - no agregar ninguna materia sin cursos asociados 15 | // - no agregar periodos sin materias 16 | 17 | const result = []; 18 | 19 | const periodoPattern = 20 | /Período lectivo: ([^\n]+)\n[\s\S]*?(?=Período lectivo:|$)/g; 21 | const materiaPattern = 22 | /Actividad: ([^\n]+) \((.+?)\)\n[\s\S]*?(?=Actividad:|$)/g; 23 | const cursosPattern = 24 | /Comisión: ([^\n]+)[\s\S]*?Docentes: ([^\n]+)[\s\S]*?Tipo de clase\s+Día\s+Horario(?:\s+Aula)([\s\S]*?)(?=Comisión:|$)/g; 25 | 26 | const periodos = []; 27 | for (const periodoMatch of rawdata.matchAll(periodoPattern)) { 28 | const periodoFullText = periodoMatch[0]; 29 | const periodoNombre = periodoMatch[1]; 30 | 31 | console.debug(`Found periodo: ${periodoNombre}`) 32 | periodos.push({ 33 | periodo: periodoNombre, 34 | raw: periodoFullText, 35 | materias: [], 36 | cursos: [], 37 | }); 38 | } 39 | 40 | for (let periodo of periodos) { 41 | for (const materiaMatch of periodo.raw.matchAll(materiaPattern)) { 42 | const materiaFullText = materiaMatch[0]; 43 | const materia = { 44 | nombre: materiaMatch[1], 45 | codigo: materiaMatch[2], 46 | cursos: [], 47 | }; 48 | console.debug(`- Found materia: ${materia.nombre} (${materia.codigo})`) 49 | 50 | for (const cursoMatch of materiaFullText.matchAll(cursosPattern)) { 51 | const cursoCodigo = `${materia.codigo}-${cursoMatch[1]}`; 52 | const cursoDocentes = cursoMatch[2].trim().replace(/\(.*?\)/g, ""); 53 | console.debug(`-- Found curso: ${cursoCodigo}`) 54 | 55 | const clases = []; 56 | for (let claseLine of cursoMatch[3].trim().split("\n")) { 57 | console.debug(`--- Found clase: ${claseLine}`) 58 | if (!SEMANA.some(day => claseLine.includes(day))) { 59 | continue; 60 | } 61 | 62 | // eslint-disable-next-line no-unused-vars 63 | const [_tipo, dia, horario, _aula = null] = claseLine.split("\t"); 64 | const [inicio, fin] = horario.split(" a "); 65 | const clase = { 66 | dia: SEMANA.indexOf(dia), 67 | inicio, 68 | fin, 69 | }; 70 | clases.push(clase); 71 | } 72 | 73 | if (clases.length === 0) { 74 | continue; 75 | } 76 | 77 | periodo.cursos.push({ 78 | materia: materia.codigo, 79 | codigo: cursoCodigo, 80 | docentes: cursoDocentes, 81 | clases, 82 | }); 83 | materia.cursos.push(cursoCodigo); 84 | } 85 | 86 | if (materia.cursos.length === 0) { 87 | continue; 88 | } 89 | periodo.materias.push(materia); 90 | } 91 | 92 | if (periodo.materias.length === 0) { 93 | continue; 94 | } 95 | result.push({ 96 | periodo: periodo.periodo, 97 | materias: periodo.materias, 98 | cursos: periodo.cursos, 99 | timestamp: Date.now(), 100 | }); 101 | } 102 | console.debug(result); 103 | return result; 104 | } 105 | -------------------------------------------------------------------------------- /src/Style.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Source+Serif+Pro&display=swap"); 2 | 3 | .rbc-calendar { 4 | background: var(--chakra-colors-calendarbg); 5 | } 6 | 7 | #dark .rbc-calendar { 8 | background: var(--chakra-colors-calendarbgdark); 9 | } 10 | 11 | .rbc-allday-cell { 12 | display: none; 13 | } 14 | 15 | .rbc-header, 16 | .rbc-label, 17 | .rbc-agenda-date-cell, 18 | .rbc-agenda-time-cell { 19 | font-family: "Source Serif Pro", serif; 20 | } 21 | 22 | .rbc-time-view { 23 | border: none; 24 | } 25 | 26 | .rbc-time-gutter { 27 | padding: 0 0.5em; 28 | font-weight: bold; 29 | font-size: large; 30 | color: var(--chakra-colors-drawerbg); 31 | margin-right: 5px; 32 | } 33 | #dark .rbc-time-gutter { 34 | color: var(--chakra-colors-primary-600); 35 | } 36 | 37 | .rbc-time-content > * + * > * { 38 | border: 3px solid; 39 | border-top: none; 40 | border-left: none; 41 | border-bottom: none; 42 | border-color: var(--chakra-colors-drawerbg); 43 | } 44 | #dark .rbc-time-content > * + * > * { 45 | border-color: var(--chakra-colors-primary-300); 46 | } 47 | 48 | .rbc-day-slot .rbc-event-label { 49 | width: 100%; 50 | text-align: left; 51 | } 52 | 53 | .rbc-day-slot .rbc-events-container { 54 | margin: 0; 55 | } 56 | 57 | .rbc-time-column:last-child * { 58 | border-right: none; 59 | } 60 | 61 | .rbc-timeslot-group { 62 | display: flex; 63 | flex-direction: row; 64 | align-items: center; 65 | border: none; 66 | } 67 | #dark .rbc-time-gutter .rbc-timeslot-group { 68 | border-bottom: 1px solid var(--chakra-colors-primary-300); 69 | } 70 | #dark .rbc-time-gutter .rbc-timeslot-group:last-child { 71 | border-bottom: none; 72 | } 73 | 74 | .rbc-row { 75 | height: 3em; 76 | line-height: 3em; 77 | } 78 | 79 | .rbc-header * { 80 | cursor: text; 81 | color: var(--chakra-colors-drawerbg); 82 | font-size: larger; 83 | } 84 | #dark .rbc-header * { 85 | color: var(--chakra-colors-primary-600); 86 | } 87 | 88 | .rbc-day-slot .rbc-timeslot-group:nth-child(even) { 89 | background: var(--chakra-colors-calendarbggrey); 90 | } 91 | #dark .rbc-day-slot .rbc-timeslot-group:nth-child(even) { 92 | background: var(--chakra-colors-agendabgdark); 93 | } 94 | 95 | .rbc-header { 96 | border: none; 97 | } 98 | 99 | .rbc-day-slot { 100 | border: none; 101 | } 102 | 103 | .rbc-day-slot .rbc-time-slot { 104 | border-top: none; 105 | } 106 | 107 | .rbc-time-content { 108 | border-top: none; 109 | } 110 | 111 | .rbc-header + .rbc-header { 112 | border-left: none; 113 | } 114 | 115 | .rbc-time-header-content { 116 | border: none; 117 | } 118 | 119 | .rbc-agenda-event-cell, 120 | .rbc-agenda-time-cell { 121 | font-size: small; 122 | line-height: 120%; 123 | } 124 | 125 | .rbc-agenda-time-cell { 126 | border: 1px solid var(--chakra-colors-primary-300); 127 | } 128 | 129 | .rbc-agenda-date-cell { 130 | background: var(--chakra-colors-calendarbggrey); 131 | border: none; 132 | } 133 | 134 | #dark .rbc-agenda-table .rbc-agenda-date-cell { 135 | background: var(--chakra-colors-calendarbgdark); 136 | } 137 | 138 | .rbc-agenda-event-cell-sub { 139 | font-size: x-small; 140 | color: var(--chakra-colors-drawerbg); 141 | } 142 | 143 | #dark .rbc-agenda-table * { 144 | background: var(--chakra-colors-agendabgdark); 145 | color: var(--chakra-colors-calendarbg); 146 | } 147 | 148 | #dark .rbc-agenda-table { 149 | border: none; 150 | } 151 | -------------------------------------------------------------------------------- /src/components/Body.js: -------------------------------------------------------------------------------- 1 | import { AddIcon, Icon } from "@chakra-ui/icons"; 2 | import { 3 | Box, 4 | IconButton, 5 | Tooltip, 6 | useColorModeValue, 7 | useDisclosure, 8 | } from "@chakra-ui/react"; 9 | import "moment/locale/es"; 10 | import React from "react"; 11 | import "react-big-calendar/lib/css/react-big-calendar.css"; 12 | import { useHotkeys } from "react-hotkeys-hook"; 13 | import { DataContext } from "../DataContext"; 14 | import useWindowSize from "../useWindowSize"; 15 | import Calendar from "./Calendar"; 16 | import ManualUploadModal from "./ManualUploadModal"; 17 | import MateriasDrawer from "./MateriasDrawer"; 18 | 19 | const Body = () => { 20 | const { events, horariosSIU, setSkipSIU, skipSIU } = React.useContext(DataContext); 21 | const [useAgenda, setUseAgenda] = React.useState(false); 22 | const { width } = useWindowSize(); 23 | const { 24 | isOpen: isOpenDrawer, 25 | onToggle: onToggleDrawer, 26 | onClose: onCloseDrawer, 27 | } = useDisclosure(); 28 | const { 29 | isOpen: isOpenModal, 30 | onToggle: onToggleModal, 31 | onClose: onCloseModal, 32 | } = useDisclosure(); 33 | 34 | useHotkeys("esc", (horariosSIU || skipSIU) ? onToggleDrawer : onToggleModal, { 35 | enableOnFormTags: true, 36 | }); 37 | 38 | React.useEffect(() => { 39 | setUseAgenda(width < 1000); 40 | }, [width]); 41 | 42 | return ( 43 | 44 | 52 | setSkipSIU(true)} 56 | setSkipSIU={setSkipSIU} 57 | /> 58 | 59 | 60 | 61 | 62 | {(!horariosSIU && !skipSIU) ? ( 63 | 64 | 69 | 70 | 78 | 82 | 88 | 89 | 90 | 91 | } 92 | onClick={onToggleModal} 93 | colorScheme="primary" 94 | aria-label="Cargar horarios del SIU" 95 | /> 96 | 97 | ) : ( 98 | 99 | } 103 | onClick={onToggleDrawer} 104 | colorScheme="primary" 105 | aria-label="Agregar Materias" 106 | /> 107 | 108 | )} 109 | 110 | 111 | ); 112 | }; 113 | 114 | export default Body; 115 | -------------------------------------------------------------------------------- /src/components/Calendar.js: -------------------------------------------------------------------------------- 1 | import { Box, Text, CloseButton } from "@chakra-ui/react"; 2 | import moment from "moment"; 3 | import "moment/locale/es"; 4 | import React from "react"; 5 | import { Calendar, momentLocalizer } from "react-big-calendar"; 6 | import "react-big-calendar/lib/css/react-big-calendar.css"; 7 | import { DataContext } from "../DataContext"; 8 | import useWindowSize from "../useWindowSize"; 9 | import CalendarAgenda from "./CalendarAgenda"; 10 | import CalendarWeek from "./CalendarWeek"; 11 | import TabSystem from "./TabSystem"; 12 | import { getColor } from "../utils"; 13 | 14 | const localizer = momentLocalizer(moment); 15 | const min = new Date().setHours(7, 0, 0); 16 | const max = new Date().setHours(23, 30, 0); 17 | 18 | const MateriaEvent = (props) => { 19 | const { removeExtraFromTab } = React.useContext(DataContext); 20 | return ( 21 | <> 22 | {!props.event.curso && ( 23 | { 28 | ev.stopPropagation(); 29 | removeExtraFromTab(props.event.id); 30 | }} 31 | /> 32 | )} 33 | 34 | 35 | 36 | {props.event.title} 37 | 38 | 39 | {props.event.subtitle} 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | const MateriaEventAgenda = (props) => { 47 | return ( 48 | 49 | 55 | 56 | {props.event.title} 57 | 58 | 59 | 60 | {props.event.subtitle} 61 | 62 | 63 | ); 64 | }; 65 | 66 | const MyCalendar = (props) => { 67 | const { events, useAgenda } = props; 68 | const { width } = useWindowSize(); 69 | const { addExtra } = React.useContext(DataContext); 70 | 71 | const eventPropsGetter = React.useCallback( 72 | (event) => { 73 | let color = event.id ? getColor(event) : "inherit"; 74 | const style = { 75 | borderWidth: "thin thin thin thick", 76 | borderRightColor: "#d2adf4", //primary.300 77 | borderBottomColor: "#d2adf4", //primary.300 78 | borderTopColor: "#d2adf4", //primary.300 79 | borderLeftColor: color, 80 | color: "#1f1f1f", 81 | cursor: "default", 82 | }; 83 | const calendarWeekStyle = { 84 | textAlign: "right", 85 | backgroundColor: "#FFF", 86 | borderRightColor: "#0000", 87 | borderBottomColor: "#0000", 88 | borderTopColor: "#0000", 89 | boxShadow: "inset 0 0 0 1000px " + color + "44", 90 | }; 91 | return { 92 | style: useAgenda ? style : { ...style, ...calendarWeekStyle }, 93 | }; 94 | }, 95 | [useAgenda], 96 | ); 97 | 98 | const coveredDays = events.map((e) => e.start.getDay()); 99 | const notCoveredDays = [1, 2, 3, 4, 5].filter( 100 | (d) => !coveredDays.includes(d), 101 | ); 102 | const dummyEvents = notCoveredDays.map((i) => ({ 103 | start: new Date(2018, 0, i, 7), 104 | end: new Date(2018, 0, i, 23, 30), 105 | title: "", 106 | })); 107 | 108 | const formats = { 109 | dayFormat: (d) => { 110 | const f = d 111 | .toLocaleString("es-AR", { 112 | weekday: "long", 113 | }) 114 | .toUpperCase(); 115 | return width > 1180 ? f : f[0]; 116 | }, 117 | agendaDateFormat: (d) => { 118 | return d 119 | .toLocaleString("es-AR", { 120 | weekday: "long", 121 | }) 122 | .toUpperCase(); 123 | }, 124 | timeGutterFormat: "HH:mm", 125 | }; 126 | 127 | return ( 128 | {}} 132 | view={useAgenda ? "calendarAgenda" : "calendarWeek"} 133 | views={{ calendarAgenda: CalendarAgenda, calendarWeek: CalendarWeek }} 134 | localizer={localizer} 135 | min={min} 136 | max={max} 137 | defaultDate={new Date(2018, 0, 1)} // Monday 138 | events={useAgenda ? [...events, ...dummyEvents] : events} 139 | eventPropGetter={eventPropsGetter} 140 | components={{ 141 | event: useAgenda ? MateriaEventAgenda : MateriaEvent, 142 | toolbar: TabSystem, 143 | }} 144 | onSelectSlot={addExtra} 145 | dayLayoutAlgorithm="no-overlap" 146 | tooltipAccessor="tooltip" 147 | /> 148 | ); 149 | }; 150 | 151 | export default MyCalendar; 152 | -------------------------------------------------------------------------------- /src/components/CalendarAgenda.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import * as dates from "react-big-calendar/lib/utils/dates"; 4 | import { navigate } from "react-big-calendar/lib/utils/constants"; 5 | import { inRange } from "react-big-calendar/lib/utils/eventLevels"; 6 | import { isSelected } from "react-big-calendar/lib/utils/selection"; 7 | 8 | function Agenda({ 9 | selected, 10 | getters, 11 | accessors, 12 | localizer, 13 | components, 14 | length, 15 | date, 16 | events, 17 | onSelectEvent, 18 | }) { 19 | const contentRef = React.useRef(null); 20 | const tbodyRef = React.useRef(null); 21 | 22 | const renderDay = (day, events, dayKey) => { 23 | const { event: Event, date: AgendaDate } = components; 24 | 25 | events = events.filter((e) => 26 | inRange( 27 | e, 28 | dates.startOf(day, "day"), 29 | dates.endOf(day, "day"), 30 | accessors, 31 | localizer, 32 | ), 33 | ); 34 | 35 | return events.map((event, idx) => { 36 | let title = accessors.title(event); 37 | let end = accessors.end(event); 38 | let start = accessors.start(event); 39 | 40 | const userProps = getters.eventProp( 41 | event, 42 | start, 43 | end, 44 | isSelected(event, selected), 45 | ); 46 | 47 | let dateLabel = idx === 0 && localizer.format(day, "agendaDateFormat"); 48 | let first = 49 | idx === 0 ? ( 50 | 51 | 56 | {AgendaDate ? ( 57 | 58 | ) : ( 59 | dateLabel 60 | )} 61 | 62 | 63 | ) : ( 64 | false 65 | ); 66 | 67 | return [ 68 | first, 69 | 70 | 71 | {timeRangeLabel(day, event)} 72 | 73 | onSelectEvent && onSelectEvent(event, e)} 78 | > 79 | {Event ? : title} 80 | 81 | , 82 | ]; 83 | }, []); 84 | }; 85 | 86 | const timeRangeLabel = (day, event) => { 87 | let labelClass = "", 88 | TimeComponent = components.time, 89 | label = localizer.messages.allDay; 90 | 91 | let end = accessors.end(event); 92 | let start = accessors.start(event); 93 | 94 | if (!accessors.allDay(event)) { 95 | if (dates.eq(start, end)) { 96 | label = localizer.format(start, "agendaTimeFormat"); 97 | } else if (dates.eq(start, end, "day")) { 98 | label = localizer.format({ start, end }, "agendaTimeRangeFormat"); 99 | } else if (dates.eq(day, start, "day")) { 100 | label = localizer.format(start, "agendaTimeFormat"); 101 | } else if (dates.eq(day, end, "day")) { 102 | label = localizer.format(end, "agendaTimeFormat"); 103 | } 104 | } 105 | 106 | if (dates.gt(day, start, "day")) labelClass = "rbc-continues-prior"; 107 | if (dates.lt(day, end, "day")) labelClass += " rbc-continues-after"; 108 | 109 | return ( 110 | 111 | {TimeComponent ? ( 112 | 113 | ) : ( 114 | label 115 | )} 116 | 117 | ); 118 | }; 119 | 120 | let { messages } = localizer; 121 | let end = dates.add(date, length, "day"); 122 | 123 | let range = dates.range(date, end, "day"); 124 | 125 | events = events.filter((event) => 126 | inRange(event, date, end, accessors, localizer), 127 | ); 128 | 129 | events.sort((a, b) => +accessors.start(a) - +accessors.start(b)); 130 | 131 | return ( 132 |
133 | {events.length !== 0 ? ( 134 | 135 |
136 | 137 | 138 | {range.map((day, idx) => renderDay(day, events, idx))} 139 | 140 |
141 |
142 |
143 | ) : ( 144 | {messages.noEventsInRange} 145 | )} 146 |
147 | ); 148 | } 149 | 150 | Agenda.propTypes = { 151 | events: PropTypes.array, 152 | date: PropTypes.instanceOf(Date), 153 | length: PropTypes.number.isRequired, 154 | 155 | selected: PropTypes.object, 156 | 157 | accessors: PropTypes.object.isRequired, 158 | components: PropTypes.object.isRequired, 159 | getters: PropTypes.object.isRequired, 160 | localizer: PropTypes.object.isRequired, 161 | }; 162 | 163 | Agenda.defaultProps = { 164 | length: 30, 165 | }; 166 | 167 | Agenda.range = (start, { length = Agenda.defaultProps.length }) => { 168 | let end = dates.add(start, length, "day"); 169 | return { start, end }; 170 | }; 171 | 172 | Agenda.navigate = (date, action, { length = Agenda.defaultProps.length }) => { 173 | switch (action) { 174 | case navigate.PREVIOUS: 175 | return dates.add(date, -length, "day"); 176 | 177 | case navigate.NEXT: 178 | return dates.add(date, length, "day"); 179 | 180 | default: 181 | return date; 182 | } 183 | }; 184 | 185 | Agenda.title = (start, { length = Agenda.defaultProps.length, localizer }) => { 186 | let end = dates.add(start, length, "day"); 187 | return localizer.format({ start, end }, "agendaHeaderFormat"); 188 | }; 189 | 190 | export default Agenda; 191 | -------------------------------------------------------------------------------- /src/components/ManualUploadModal.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Code, 4 | Kbd, 5 | Link, 6 | ListItem, 7 | Modal, 8 | ModalBody, 9 | ModalCloseButton, 10 | ModalContent, 11 | ModalHeader, 12 | ModalOverlay, 13 | OrderedList, 14 | Select, 15 | Text, 16 | Textarea, 17 | useToast, 18 | Flex, 19 | } from "@chakra-ui/react"; 20 | import React from "react"; 21 | import { DataContext } from "../DataContext"; 22 | 23 | const ManualUploadModal = ({ isOpen, onClose, onSkip, setSkipSIU }) => { 24 | const toast = useToast(); 25 | const [error, setError] = React.useState(""); 26 | const [siuData, setSiuData] = React.useState(""); 27 | const [periodosOptions, setPeriodosOptions] = React.useState([]); 28 | const [selectedPeriod, setSelectedPeriod] = React.useState(null); 29 | const { applyHorariosSIU, getPeriodosSIU } = React.useContext(DataContext); 30 | 31 | const handleSuccessfulUpload = () => { 32 | setSkipSIU(false); 33 | onClose(); 34 | toast({ 35 | title: "Horarios del SIU aplicados", 36 | status: "success", 37 | duration: 2000, 38 | isClosable: true, 39 | }); 40 | }; 41 | 42 | return ( 43 | { 48 | setError(""); 49 | setSiuData(""); 50 | setPeriodosOptions([]); 51 | setSelectedPeriod(null); 52 | }} 53 | > 54 | 55 | 56 | Importar horarios del SIU 57 | 58 | 59 | 60 | Lamentablemente, FIUBA ya no ofrece los horarios de 61 | las materias públicamente, por lo que cada usuario tiene que importar manualmente 62 | sus horarios desde el SIU. 63 | 64 | 65 | 66 | Para importar tu oferta horaria seguí estos pasos: 67 | 68 | 69 | 70 | Andá a{" "} 71 | 75 | 80 | Reportes > Oferta de comisiones 81 | 82 | 83 | 84 | 85 | Ahí seleccioná todo el contenido de la página (CTRL +{" "} 86 | A) 87 | 88 | 89 | Copia todo (CTRL + C) 90 | 91 | 92 | Pegalo en el siguiente cuadro de texto (CTRL +{" "} 93 | V). 94 | 95 | 96 |