├── .dockerignore ├── public ├── robots.txt └── assets │ ├── images │ ├── favicon.png │ ├── favicon-apple.png │ ├── favicon-apple.svg │ └── favicon.svg │ └── manifest.webmanifest ├── src ├── vite-env.d.ts ├── models │ ├── TernaryDarkMode.ts │ ├── Day.ts │ ├── ClassId.ts │ ├── Teacher.ts │ ├── School.ts │ ├── HourTime.ts │ ├── Group.ts │ ├── Hour.ts │ ├── Lesson.ts │ └── Timetable.ts ├── api │ ├── schools.ts │ └── timetable.ts ├── components │ ├── TeacherModeSettings.tsx │ ├── LessonProgressBar.tsx │ ├── ShownHoursCountSettings.tsx │ ├── ProgressBarSettings.tsx │ ├── WholeTimetableLink.tsx │ ├── Slider.tsx │ ├── LessonInfo.tsx │ ├── ColorSchemeSettings.tsx │ ├── SchoolSettings.tsx │ ├── ClassSettings.tsx │ ├── TeacherSettings.tsx │ ├── TimetableInfo.tsx │ ├── MainPage.tsx │ ├── TimeRemaining.tsx │ ├── SettingsPage.tsx │ ├── GroupSettings.tsx │ ├── Lessons.tsx │ └── App.tsx ├── assets │ ├── css │ │ ├── bootstrap-custom.scss │ │ └── style.css │ └── images │ │ └── delta-timetable-logo.svg └── main.tsx ├── .gitattributes ├── docker ├── web-server-prod │ ├── default.conf │ ├── nginx.conf │ └── Dockerfile └── web-server-dev │ └── Dockerfile ├── .idea ├── .gitignore ├── modules.xml ├── inspectionProfiles │ └── Project_Default.xml ├── runConfigurations │ └── dev.xml ├── rozvrh.iml ├── vcs.xml └── icon.svg ├── .gitignore ├── tsconfig.node.json ├── docker-compose-dev.yaml ├── vite.config.ts ├── docker-compose-prod.yaml ├── tsconfig.json ├── README.md ├── LICENSE.md ├── eslint.config.js ├── package.json └── index.html /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /public/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matous-volf/rozvrh/HEAD/public/assets/images/favicon.png -------------------------------------------------------------------------------- /public/assets/images/favicon-apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matous-volf/rozvrh/HEAD/public/assets/images/favicon-apple.png -------------------------------------------------------------------------------- /docker/web-server-prod/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | root /var/www/html/public; 3 | index index.html; 4 | try_files $uri $uri/ /index.html; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/TernaryDarkMode.ts: -------------------------------------------------------------------------------- 1 | import {useTernaryDarkMode} from "usehooks-ts"; 2 | 3 | type TernaryDarkMode = ReturnType["ternaryDarkMode"]; 4 | export default TernaryDarkMode; 5 | -------------------------------------------------------------------------------- /src/models/Day.ts: -------------------------------------------------------------------------------- 1 | import Hour from "./Hour.ts"; 2 | 3 | export default class Day { 4 | hours: Hour[]; 5 | 6 | constructor(hours: Hour[]) { 7 | this.hours = hours; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/models/ClassId.ts: -------------------------------------------------------------------------------- 1 | export default class ClassId { 2 | id: string; 3 | name: string; 4 | constructor(id: string, name: string) { 5 | this.id = id; 6 | this.name = name; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/models/Teacher.ts: -------------------------------------------------------------------------------- 1 | export default class Teacher { 2 | id: string; 3 | name: string; 4 | constructor(id: string, name: string) { 5 | this.id = id; 6 | this.name = name; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docker/web-server-dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.8.0-alpine3.20 2 | 3 | WORKDIR /srv/app/ 4 | 5 | COPY package.json package-lock.json ./ 6 | RUN npm install 7 | 8 | COPY . . 9 | 10 | CMD ["npm", "run", "dev"] 11 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | dist.zip 14 | *.local 15 | 16 | /docker-compose.yaml 17 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /docker-compose-dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web-server: 3 | build: 4 | dockerfile: ./docker/web-server-dev/Dockerfile 5 | context: ./ 6 | ports: 7 | - "8000:8000" 8 | volumes: 9 | - /srv/app/node_modules 10 | - ./:/srv/app 11 | tty: true 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vite" 2 | import react from "@vitejs/plugin-react" 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | host: true, 9 | port: 8000, 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /src/models/School.ts: -------------------------------------------------------------------------------- 1 | export default class School { 2 | id: string; 3 | name: string; 4 | url: string; 5 | 6 | constructor(id: string, name: string, url: string) { 7 | this.id = id; 8 | this.name = name; 9 | this.url = url; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/models/HourTime.ts: -------------------------------------------------------------------------------- 1 | import {DateTime} from "luxon"; 2 | 3 | 4 | export default class HourTime { 5 | start: DateTime; 6 | end: DateTime; 7 | 8 | constructor(start: DateTime, end: DateTime) { 9 | this.start = start; 10 | this.end = end; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docker-compose-prod.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web-server: 3 | build: 4 | dockerfile: ./docker/web-server-prod/Dockerfile 5 | context: ./ 6 | networks: 7 | - web-server-network 8 | restart: always 9 | 10 | networks: 11 | web-server-network: 12 | external: true 13 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /src/models/Group.ts: -------------------------------------------------------------------------------- 1 | export default class Group { 2 | id: string; 3 | name: string | null; 4 | isBlank: boolean; 5 | 6 | constructor(id: string, name: string | null, isBlank: boolean) { 7 | this.id = id; 8 | this.name = name; 9 | this.isBlank = isBlank; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/api/schools.ts: -------------------------------------------------------------------------------- 1 | import School from "../models/School.ts"; 2 | 3 | const schoolsUrl = "https://vitskalicky.gitlab.io/bakalari-schools-list/schoolsList.json"; 4 | 5 | export async function getSchools(): Promise { 6 | const response = await fetch(schoolsUrl); 7 | if (!response.ok) { 8 | throw new Error(); 9 | } 10 | 11 | return await response.json(); 12 | } 13 | -------------------------------------------------------------------------------- /src/models/Hour.ts: -------------------------------------------------------------------------------- 1 | import Lesson from "./Lesson.ts"; 2 | 3 | export default class Hour { 4 | lessons: Lesson[]; 5 | selectedLesson: Lesson | null; 6 | isSelected: boolean; 7 | 8 | constructor(lessons: Lesson[], selectedLesson: Lesson | null) { 9 | this.lessons = lessons; 10 | this.selectedLesson = selectedLesson; 11 | this.isSelected = selectedLesson !== null; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.idea/runConfigurations/dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/rozvrh.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/assets/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Rozvrh hodin", 3 | "short_name": "Rozvrh", 4 | "background_color": "#40a351", 5 | "description": "Zobrazuje odpočet doby zbývající do další hodiny školního rozvrhu spolu s nadcházejícími předměty. Podporuje školy používající systém Bakaláři.", 6 | "display": "standalone", 7 | "icons": [ 8 | { 9 | "src": "/assets/images/favicon.png", 10 | "sizes": "512x512", 11 | "type": "image/png" 12 | } 13 | ], 14 | "start_url": "/" 15 | } 16 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/TeacherModeSettings.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent} from "react"; 2 | import {FormCheck} from "react-bootstrap"; 3 | 4 | interface Props { 5 | teacherModeEnabled: boolean; 6 | setTeacherModeEnabledCallback: (teacherModeEnabled: boolean) => void; 7 | } 8 | 9 | export default function TeacherModeSettings(props: Props) { 10 | return ) => 12 | props.setTeacherModeEnabledCallback(event.target.checked)}/> 13 | } 14 | -------------------------------------------------------------------------------- /src/models/Lesson.ts: -------------------------------------------------------------------------------- 1 | import Group from "./Group.ts"; 2 | 3 | export default class Lesson { 4 | subject: string; 5 | group: Group | null; 6 | room: string; 7 | teacher: string; 8 | isNotEveryWeek: boolean; 9 | weekId: string | null; 10 | 11 | constructor(subject: string, group: Group | null, room: string, teacher: string, isNotEveryWeek: boolean, weekId: string | null) { 12 | this.subject = subject; 13 | this.group = group; 14 | this.room = room; 15 | this.teacher = teacher; 16 | this.isNotEveryWeek = isNotEveryWeek; 17 | this.weekId = weekId; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/LessonProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import {DateTime, Duration} from "luxon"; 2 | import {ProgressBar} from "react-bootstrap"; 3 | 4 | interface Props { 5 | currentTime: DateTime; 6 | previousAwaitedTime: DateTime; 7 | timeRemaining: Duration | null; 8 | } 9 | 10 | export default function LessonProgressBar({currentTime, previousAwaitedTime, timeRemaining}: Props) { 11 | return timeRemaining === null 12 | ? 13 | : 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/css/bootstrap-custom.scss: -------------------------------------------------------------------------------- 1 | $primary: #40A351; 2 | 3 | @import "../../../node_modules/bootstrap/scss/bootstrap"; 4 | 5 | // https://stackoverflow.com/a/67958494/13187910 6 | .btn-primary, .btn-primary:hover, .btn-primary:active, .btn-primary:focus, 7 | .btn-outline-primary:hover, btn-outline-primary:active, btn-outline-primary:focus, { 8 | color: white !important; 9 | } 10 | 11 | /* So that the bar does not slide back slowly at the end of each lesson / break and jumps immediately instead. Also, has 12 | a significant impact on the app's performance (https://github.com/matous-volf/rozvrh/pull/78). */ 13 | .progress { 14 | --bs-progress-bar-transition: none 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ShownHoursCountSettings.tsx: -------------------------------------------------------------------------------- 1 | import {useLocalStorage} from "usehooks-ts"; 2 | import Slider from "./Slider"; 3 | import Timetable from "../models/Timetable"; 4 | 5 | interface Props { 6 | timetable: Timetable | null; 7 | } 8 | 9 | export default function ShownHoursCountSettings(props: Props) { 10 | const [numberOfShownHours, setNumberOfShownHours] = useLocalStorage("shownHoursCount", 2); 11 | 12 | return <> 13 |

Počet zobrazených hodin

14 | 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/css/style.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@700&display=swap"); 2 | 3 | input.rbt-input-hint { 4 | color: transparent !important; 5 | } 6 | 7 | .delta-timetable-logo { 8 | height: 2rem; 9 | transition-duration: 0.15s; 10 | } 11 | 12 | .btn > .delta-timetable-logo { 13 | /* https://angel-rs.github.io/css-color-filter-generator */ 14 | filter: brightness(0) saturate(100%) invert(46%) sepia(89%) saturate(308%) hue-rotate(79deg) brightness(97%) contrast(93%); 15 | } 16 | 17 | .btn:hover > .delta-timetable-logo { 18 | filter: none; 19 | } 20 | 21 | .hide-indicate-scrollbar ~ span { 22 | pointer-events: none !important; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ProgressBarSettings.tsx: -------------------------------------------------------------------------------- 1 | import {FormCheck} from "react-bootstrap"; 2 | import {useLocalStorage} from "usehooks-ts"; 3 | 4 | export default function ProgressBarSettings() { 5 | const [progressBarEnabled, setProgressBarEnabled] = useLocalStorage("progressBarEnabled", true); 6 | 7 | return <> 8 |

Ukazatel průběhu

9 | 10 | setProgressBarEnabled(e.target.checked)} checked={progressBarEnabled}/> 12 | 13 | setProgressBarEnabled(!e.target.checked)} checked={!progressBarEnabled}/> 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/models/Timetable.ts: -------------------------------------------------------------------------------- 1 | import Day from "./Day.ts"; 2 | import HourTime from "./HourTime.ts"; 3 | import Group from "./Group.ts"; 4 | 5 | export default class Timetable { 6 | days: Day[]; 7 | groupGroups: Group[][]; 8 | hourTimes: HourTime[]; 9 | // https://napoveda.bakalari.cz/ro_konfigurace_konfzobrazeni.htm 10 | hourTimesMinutesGreatestCommonDivisor: 1 | 5; 11 | urlCurrent: string; 12 | 13 | constructor(days: Day[], groups: Group[][], hourTimes: HourTime[], hourTimesMinutesDivisibleByNumber: 1 | 5, urlCurrent: string) { 14 | this.days = days; 15 | this.groupGroups = groups; 16 | this.hourTimes = hourTimes; 17 | this.hourTimesMinutesGreatestCommonDivisor = hourTimesMinutesDivisibleByNumber; 18 | this.urlCurrent = urlCurrent; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/WholeTimetableLink.tsx: -------------------------------------------------------------------------------- 1 | import Timetable from "../models/Timetable.ts"; 2 | import deltaTimetableLogo from '../assets/images/delta-timetable-logo.svg'; 3 | import School from "../models/School.ts"; 4 | import {Link} from "react-router-dom"; 5 | 6 | const deltaSchoolId = "SYDATAAEVA"; 7 | 8 | interface Props { 9 | timetable: Timetable; 10 | selectedSchool: School; 11 | } 12 | 13 | export default function WholeTimetableLink(props: Props) { 14 | return 15 | {props.selectedSchool.id === deltaSchoolId 16 | ? logo Delta timetable 17 | : <> celý rozvrh} 18 | 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | "downlevelIteration": true, 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": [ 27 | "src" 28 | ], 29 | "references": [ 30 | { 31 | "path": "./tsconfig.node.json" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /docker/web-server-prod/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log notice; 5 | pid /var/run/nginx.pid; 6 | 7 | load_module modules/ngx_http_brotli_static_module.so; # for serving pre-compressed files 8 | 9 | events { 10 | worker_connections 1024; 11 | } 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | 23 | sendfile on; 24 | #tcp_nopush on; 25 | 26 | keepalive_timeout 65; 27 | 28 | brotli_static on; 29 | 30 | include /etc/nginx/conf.d/*.conf; 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Slider.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent, useState} from "react"; 2 | import {Form} from "react-bootstrap"; 3 | 4 | interface Props { 5 | min: number; 6 | max: number; 7 | step: number; 8 | defaultValue: number; 9 | onChange?: (value: number) => void; 10 | } 11 | 12 | export default function Slider({min, max, step, defaultValue, onChange}: Props) { 13 | const [value, setValue] = useState(defaultValue); 14 | 15 | const handleChange = (e: ChangeEvent) => { 16 | const newValue = Number(e.target.value); 17 | setValue(newValue); 18 | if (onChange) { 19 | onChange(newValue); 20 | } 21 | }; 22 | 23 | return
24 | 25 | {value} 26 |
27 | } 28 | -------------------------------------------------------------------------------- /src/components/LessonInfo.tsx: -------------------------------------------------------------------------------- 1 | import Lesson from "../models/Lesson.ts"; 2 | 3 | interface Props { 4 | teacherModeEnabled: boolean; 5 | lesson: Lesson | null; 6 | isBreak: boolean; 7 | isLongBreak: boolean; 8 | } 9 | 10 | export default function LessonInfo(props: Props) { 11 | let content; 12 | 13 | if (props.isLongBreak) { 14 | content = pauza; 15 | } else if (props.isBreak) { 16 | content = přestávka; 17 | } else if (props.lesson === null) { 18 | content = volno; 19 | } else { 20 | content = <> 21 | {props.lesson.subject} 22 | {props.teacherModeEnabled && props.lesson.group !== null && {props.lesson.group.name}} 23 | {props.lesson.room} 24 | 25 | } 26 | 27 | return
28 | {content} 29 |
30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rozvrh 2 | 3 | Zobrazuje odpočet doby zbývající do další hodiny školního rozvrhu spolu s nadcházejícími předměty. Podporuje školy 4 | používající systém Bakaláři. Inspirován 5 | [@czM1K3](https://github.com/czM1K3)/[DeltaTime](https://github.com/czM1K3/DeltaTime). 6 | 7 | Aplikace je nasazena na [rozvrh.matousvolf.cz](https://rozvrh.matousvolf.cz/). 8 | 9 | Seznam škol je načítán z [tohoto zdroje](https://gitlab.com/vitSkalicky/bakalari-schools-list). Aktuální rozvrh, třídy a 10 | skupiny jsou extrahovány z HTML veřejného rozvrhu (např. [tohoto](https://delta-skola.bakalari.cz/Timetable/Public)) na 11 | stránkách školy. 12 | 13 | ## Přispěvatelé 14 | 15 | - [Logo aplikace](/public/assets/images/favicon-apple.png) vytvořil [Štěpán D.](https://stepandudycha.cz) 16 | - [Logo Delta timetable](/src/assets/images/delta-timetable-logo.svg) 17 | vytvořila [@terilkaa](https://github.com/terilkaa). 18 | - [Různá vylepšení](https://github.com/matous-volf/rozvrh/graphs/contributors) 19 | přinesli [@AdamJedl](https://github.com/AdamJedl) a [@bekucera](https://github.com/bekucera). 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Matouš Volf 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 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom/client" 3 | import App from "./components/App.tsx" 4 | import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; 5 | import "./assets/css/style.css"; 6 | 7 | import "./assets/css/bootstrap-custom.scss"; 8 | import "bootstrap-icons/font/bootstrap-icons.css"; 9 | import {Settings} from "luxon"; 10 | import Helmet from "react-helmet"; 11 | 12 | Settings.defaultZone = "Europe/Prague"; 13 | const queryClient = new QueryClient({ 14 | defaultOptions: { 15 | queries: { 16 | staleTime: 15 * 60 * 1000, 17 | refetchInterval: 15 * 60 * 1000 18 | }, 19 | }, 20 | }); 21 | 22 | ReactDOM.createRoot(document.getElementById("root")!).render( 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | ) 34 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 3 | import tsParser from "@typescript-eslint/parser"; 4 | import reactRefreshPlugin from "eslint-plugin-react-refresh"; 5 | import { fixupPluginRules } from "@eslint/compat"; 6 | import reactHooksPlugin from "eslint-plugin-react-hooks"; 7 | import globals from "globals"; 8 | 9 | export default [ 10 | js.configs.recommended, 11 | { 12 | files: ["**/*.{js,jsx,ts,tsx}"], 13 | languageOptions: { 14 | ecmaVersion: 2020, 15 | sourceType: "module", 16 | parser: tsParser, 17 | parserOptions: { 18 | ecmaFeatures: { jsx: true }, 19 | }, 20 | globals: { 21 | ...globals.browser, 22 | }, 23 | }, 24 | plugins: { 25 | "@typescript-eslint": tsPlugin, 26 | "react-refresh": reactRefreshPlugin, 27 | "react-hooks": fixupPluginRules(reactHooksPlugin), 28 | }, 29 | rules: { 30 | ...tsPlugin.configs.recommended.rules, 31 | ...reactHooksPlugin.configs.recommended.rules, 32 | "react-refresh/only-export-components": [ 33 | "warn", 34 | { allowConstantExport: true }, 35 | ], 36 | }, 37 | }, 38 | { 39 | ignores: ["dist/**"], 40 | }, 41 | ]; 42 | -------------------------------------------------------------------------------- /docker/web-server-prod/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.8.0-alpine3.20 AS builder_app 2 | 3 | WORKDIR /srv/app/ 4 | 5 | RUN apk update \ 6 | && apk upgrade --no-cache \ 7 | && apk add --no-cache brotli \ 8 | && rm -rf /var/cache/apk/* 9 | 10 | COPY package.json package-lock.json ./ 11 | RUN npm install 12 | 13 | COPY . . 14 | 15 | RUN npm run build 16 | 17 | RUN find dist/ \( -name "*.js" -o -name "*.css" -o -name "*.html" \) -exec brotli -6 -o {}.br {} \; 18 | 19 | FROM alpine:3.20 AS builder_nginx 20 | 21 | RUN apk update \ 22 | && apk upgrade --no-cache \ 23 | && apk add --no-cache git gcc musl-dev pcre-dev zlib-dev make brotli-dev \ 24 | && rm -rf /var/cache/apk/* 25 | 26 | WORKDIR /app 27 | RUN wget https://nginx.org/download/nginx-1.27.2.tar.gz && tar -zxf nginx-1.27.2.tar.gz 28 | RUN git clone --recurse-submodules -j8 https://github.com/google/ngx_brotli 29 | RUN cd nginx-1.27.2 && ./configure --with-compat --add-dynamic-module=../ngx_brotli \ 30 | && make modules 31 | 32 | FROM nginx:1.27.2-alpine3.20-slim 33 | COPY --from=builder_nginx /app/nginx-1.27.2/objs/ngx_http_brotli_static_module.so /etc/nginx/modules/ 34 | 35 | COPY ./docker/web-server-prod/nginx.conf /etc/nginx/nginx.conf 36 | COPY ./docker/web-server-prod/default.conf /etc/nginx/conf.d/default.conf 37 | 38 | COPY --from=builder_app /srv/app/dist/ /var/www/html/public/ 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rozvrh", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint . --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@tanstack/react-query": "^5.59.0", 15 | "@types/luxon": "^3.4.2", 16 | "bootstrap": "^5.3.3", 17 | "bootstrap-icons": "^1.11.3", 18 | "cheerio": "^1.0.0", 19 | "indicate": "^6.3.0", 20 | "luxon": "^3.5.0", 21 | "nanoid": "^5.0.7", 22 | "nosleep.js": "^0.12.0", 23 | "react": "^18.3.1", 24 | "react-bootstrap": "^2.10.5", 25 | "react-bootstrap-typeahead": "^6.3.2", 26 | "react-dom": "^18.3.1", 27 | "react-helmet": "^6.1.0", 28 | "react-router-dom": "^6.26.2", 29 | "usehooks-ts": "^3.1.0" 30 | }, 31 | "devDependencies": { 32 | "@eslint/compat": "^1.1.1", 33 | "@types/react": "^18.3.11", 34 | "@types/react-dom": "^18.3.0", 35 | "@types/react-helmet": "^6.1.11", 36 | "@typescript-eslint/eslint-plugin": "^8.8.0", 37 | "@typescript-eslint/parser": "^8.8.0", 38 | "@vitejs/plugin-react": "^4.3.2", 39 | "eslint": "^9.11.1", 40 | "eslint-plugin-react-hooks": "^4.6.2", 41 | "eslint-plugin-react-refresh": "^0.4.12", 42 | "globals": "^15.10.0", 43 | "sass": "1.77.6", 44 | "typescript": "^5.6.2", 45 | "vite": "^5.4.8" 46 | }, 47 | "overrides": { 48 | "eslint-plugin-react-hooks": { 49 | "eslint": "$eslint" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/ColorSchemeSettings.tsx: -------------------------------------------------------------------------------- 1 | import {FormCheck} from "react-bootstrap"; 2 | import TernaryDarkMode from "../models/TernaryDarkMode.ts"; 3 | 4 | interface Props { 5 | ternaryDarkMode: TernaryDarkMode; 6 | setTernaryDarkModeCallback: (ternaryDarkMode: TernaryDarkMode) => void; 7 | } 8 | 9 | export default function ColorSchemeSettings(props: Props) { 10 | return <> 11 |

Barevné schéma

12 | { 17 | if (e.target.checked) { 18 | props.setTernaryDarkModeCallback("light"); 19 | } 20 | }} 21 | checked={props.ternaryDarkMode === "light"} 22 | /> 23 | 24 | { 29 | if (e.target.checked) { 30 | props.setTernaryDarkModeCallback("dark"); 31 | } 32 | }} 33 | checked={props.ternaryDarkMode === "dark"} 34 | /> 35 | 36 | { 41 | if (e.target.checked) { 42 | props.setTernaryDarkModeCallback("system"); 43 | } 44 | }} 45 | checked={props.ternaryDarkMode === "system"} 46 | /> 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/components/SchoolSettings.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState} from "react"; 2 | import {useQuery} from "@tanstack/react-query"; 3 | import School from "../models/School.ts"; 4 | import {getSchools} from "../api/schools.ts"; 5 | import {Typeahead} from "react-bootstrap-typeahead"; 6 | import {Option} from "react-bootstrap-typeahead/types/types"; 7 | 8 | interface Props { 9 | selectedSchool: School | null; 10 | setSelectedSchoolCallback: (school: School | null) => void; 11 | } 12 | 13 | export default function SchoolSettings(props: Props) { 14 | const [selectedSchool, setSelectedSchool] = useState(props.selectedSchool); 15 | 16 | const setSelectedSchoolCallback = props.setSelectedSchoolCallback; 17 | useEffect(() => { 18 | setSelectedSchoolCallback(selectedSchool); 19 | }, [setSelectedSchoolCallback, selectedSchool]); 20 | 21 | const {data, isLoading, isError} = useQuery({ 22 | queryKey: ["schools"], 23 | queryFn: getSchools 24 | }); 25 | 26 | const schools: School[] = useMemo(() => data === undefined ? [] : data, [data]) 27 | 28 | useEffect(() => { 29 | if (!isLoading && schools.length < 1) { 30 | setSelectedSchool(null); 31 | } 32 | }, [schools, isLoading]); 33 | 34 | return <> 35 |

Škola

36 | {isLoading ? (

Načítání...

37 | ) : isError ? (

Školy se nepodařilo načíst.

38 | ) : setSelectedSchool(selected.length < 1 ? null : selected[0] as School)} 40 | selected={selectedSchool === null ? [] : [selectedSchool as Option]} 41 | id="input-school" emptyLabel="Nebyla nalezena žádná škola." 42 | paginationText="zobrazit další výsledky" highlightOnlyResult={true} 43 | placeholder="Zadejte název školy."/> 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/components/ClassSettings.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState} from "react"; 2 | import {nanoid} from "nanoid"; 3 | import {useQuery} from "@tanstack/react-query"; 4 | import {getClassIds} from "../api/timetable.ts"; 5 | import ClassId from "../models/ClassId.ts"; 6 | import {FormSelect} from "react-bootstrap"; 7 | import School from "../models/School.ts"; 8 | 9 | interface Props { 10 | selectedSchool: School; 11 | selectedClassId: string | null; 12 | setSelectedClassIdCallback: (classId: string | null) => void; 13 | } 14 | 15 | export default function ClassSettings(props: Props) { 16 | const [selectedClassId, setSelectedClassId] = useState(props.selectedClassId); 17 | 18 | const setSelectedClassIdCallback = props.setSelectedClassIdCallback; 19 | useEffect(() => { 20 | setSelectedClassIdCallback(selectedClassId); 21 | }, [setSelectedClassIdCallback, selectedClassId]); 22 | 23 | const {data, isLoading, isError} = useQuery({ 24 | queryKey: ["classIds", props.selectedSchool], 25 | queryFn: () => getClassIds(props.selectedSchool), 26 | }); 27 | 28 | const classIds: ClassId[] = useMemo(() => data === undefined ? [] : data, [data]) 29 | 30 | useEffect(() => { 31 | if (!isLoading && (classIds === null || classIds.length < 1)) { 32 | setSelectedClassId(null); 33 | } 34 | }, [classIds, isLoading]); 35 | 36 | return <> 37 |

Třída

38 | {(isLoading ? (

Načítání...

39 | ) : isError ? (

Třídy se nepodařilo načíst. Škola pravděpodobně nepovolila veřejný rozvrh.

40 | ) : setSelectedClassId(e.target.value)} value={selectedClassId ?? ""} 41 | id="input-class" className="w-auto"> 42 | {selectedClassId === null && } 43 | {classIds.map((classId) => ( 44 | 45 | ))} 46 | ) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/components/TeacherSettings.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState} from "react"; 2 | import {nanoid} from "nanoid"; 3 | import {useQuery} from "@tanstack/react-query"; 4 | import {getTeachers} from "../api/timetable.ts"; 5 | import {FormSelect} from "react-bootstrap"; 6 | import School from "../models/School.ts"; 7 | import Teacher from "../models/Teacher.ts"; 8 | 9 | interface Props { 10 | selectedSchool: School; 11 | selectedTeacherId: string | null; 12 | setSelectedTeacherIdCallback: (teacherId: string | null) => void; 13 | } 14 | 15 | export default function TeacherSettings(props: Props) { 16 | const [selectedTeacherId, setSelectedTeacherId] = useState(props.selectedTeacherId); 17 | 18 | const setSelectedTeacherIdCallback = props.setSelectedTeacherIdCallback; 19 | useEffect(() => { 20 | setSelectedTeacherIdCallback(selectedTeacherId); 21 | }, [setSelectedTeacherIdCallback, selectedTeacherId]); 22 | 23 | const {data, isLoading, isError} = useQuery({ 24 | queryKey: ["teachers", props.selectedSchool], 25 | queryFn: () => getTeachers(props.selectedSchool), 26 | }); 27 | 28 | const teachers: Teacher[] = useMemo(() => data === undefined ? [] : data, [data]) 29 | 30 | useEffect(() => { 31 | if (!isLoading && (teachers === null || teachers.length < 1)) { 32 | setSelectedTeacherId(null); 33 | } 34 | }, [teachers, isLoading]); 35 | 36 | return <> 37 |

Učitel

38 | {(isLoading ? (

Načítání...

39 | ) : isError ? (

Učitele se nepodařilo načíst. Škola pravděpodobně nepovolila veřejný rozvrh.

40 | ) : setSelectedTeacherId(e.target.value)} value={selectedTeacherId ?? ""} 41 | id="input-teacher" className="w-auto"> 42 | {selectedTeacherId === null && } 43 | {teachers.map((teacher) => ( 44 | 45 | ))} 46 | ) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Rozvrh 35 | 36 | 37 | 38 |
39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/components/TimetableInfo.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState} from "react"; 2 | import {DateTime} from "luxon"; 3 | import Timetable from "../models/Timetable.ts"; 4 | import TimeRemaining from "./TimeRemaining.tsx"; 5 | import Lessons from "./Lessons.tsx"; 6 | 7 | interface Props { 8 | teacherModeEnabled: boolean; 9 | timetable: Timetable; 10 | } 11 | 12 | export default function TimetableInfo(props: Props) { 13 | const [currentTime, setCurrentTime] = useState(DateTime.now()); 14 | useEffect(() => { 15 | const intervalId = setInterval(() => { 16 | setCurrentTime(DateTime.now()); 17 | }, 100); 18 | 19 | return () => { 20 | clearInterval(intervalId); 21 | } 22 | }, []); 23 | 24 | const dayIndex: number = currentTime.weekday - 1; 25 | 26 | const hours = props.timetable.days[dayIndex]?.hours; 27 | const firstHourIndex = hours?.findIndex((hour) => hour.isSelected); 28 | const lastHourIndex = hours === undefined ? -1 : 29 | hours.length - [...hours].reverse().findIndex((hour) => hour.isSelected) - 1; // the last selected index 30 | 31 | const timeRemaining = useMemo( 32 | () => , 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | [currentTime.second] 36 | ) 37 | 38 | const lessons = useMemo( 39 | () => , 42 | // eslint-disable-next-line react-hooks/exhaustive-deps 43 | [Math.trunc(currentTime.minute / props.timetable.hourTimesMinutesGreatestCommonDivisor), currentTime.hour]); 44 | 45 | if (firstHourIndex === -1 || lastHourIndex === -1 || hours === undefined) { 46 | return

Dnes není žádné vyučování.

; 47 | } 48 | 49 | return
50 | {timeRemaining} 51 | {lessons} 52 |
53 | } 54 | -------------------------------------------------------------------------------- /src/components/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import {Button, InputGroup} from "react-bootstrap"; 2 | import TimetableInfo from "./TimetableInfo.tsx"; 3 | import Timetable from "../models/Timetable.ts"; 4 | import {useNavigate} from "react-router-dom"; 5 | import {useEffect} from "react"; 6 | import WholeTimetableLink from "./WholeTimetableLink.tsx"; 7 | import School from "../models/School.ts"; 8 | import NoSleep from "nosleep.js"; 9 | 10 | interface Props { 11 | teacherModeEnabled: boolean; 12 | timetable: Timetable | null; 13 | selectedSchool: School | null; 14 | isQueryLoading: boolean; 15 | isQueryError: boolean; 16 | } 17 | 18 | export default function MainPage(props: Props) { 19 | const navigate = useNavigate(); 20 | 21 | document.title = "Rozvrh"; 22 | 23 | useEffect(() => { 24 | const noSleep = new NoSleep(); 25 | document.addEventListener("click", function enableNoSleep() { 26 | document.removeEventListener("click", enableNoSleep, false); 27 | void noSleep.enable(); 28 | }, false); 29 | }, []); 30 | 31 | return
32 | 33 | 36 | 39 | 40 | 41 |
42 | { 43 | props.isQueryLoading ? (

Načítání...

44 | ) : props.isQueryError ? (

Rozvrh se nepodařilo načíst.

45 | ) : props.timetable === null ? (

Zvolte školu, třídu a skupiny nebo učitele v nastavení.

46 | ) : 47 | } 48 |
49 | 50 | {props.timetable !== null && 51 | } 52 |
53 | } 54 | -------------------------------------------------------------------------------- /src/components/TimeRemaining.tsx: -------------------------------------------------------------------------------- 1 | import HourTime from "../models/HourTime.ts"; 2 | import {DateTime, Duration} from "luxon"; 3 | import Hour from "../models/Hour.ts"; 4 | import {useEffect} from "react"; 5 | import {useLocalStorage} from "usehooks-ts"; 6 | import LessonProgressBar from "./LessonProgressBar.tsx"; 7 | 8 | interface Props { 9 | currentTime: DateTime; 10 | hourTimes: HourTime[]; 11 | hours: Hour[]; 12 | firstHourIndex: number; 13 | lastHourIndex: number; 14 | } 15 | 16 | export default function TimeRemaining(props: Props) { 17 | const [progressBarEnabled] = useLocalStorage("progressBarEnabled", true); 18 | 19 | let hourIndex: number | null = null; 20 | let previousAwaitedTime = DateTime.now().startOf("day"); 21 | for (let i = props.firstHourIndex; i <= props.lastHourIndex; i++) { 22 | if (props.currentTime > props.hourTimes[i].end) { 23 | if (props.hours[i].isSelected) { 24 | previousAwaitedTime = props.hourTimes[i].end; 25 | } 26 | continue; 27 | } 28 | 29 | hourIndex = i; 30 | break; 31 | } 32 | 33 | let timeRemaining: Duration | null = null; 34 | 35 | if (hourIndex !== null) { 36 | if (!props.hours[hourIndex].isSelected) { 37 | hourIndex = hourIndex + props.hours.slice(hourIndex).findIndex((hour) => hour.isSelected); 38 | } 39 | 40 | const hourTime = props.hourTimes[hourIndex]; 41 | let awaitedTime: DateTime; 42 | if (props.currentTime < hourTime.start) { 43 | awaitedTime = hourTime.start; 44 | } else { 45 | previousAwaitedTime = hourTime.start; 46 | awaitedTime = hourTime.end; 47 | } 48 | 49 | timeRemaining = awaitedTime.diff(props.currentTime); 50 | } 51 | 52 | useEffect(() => { 53 | document.title = timeRemaining === null ? "Rozvrh" : timeRemaining.toFormat("mm:ss"); 54 | }, [timeRemaining]); 55 | 56 | return <> 57 |

64 | {timeRemaining === null ? "00:00" : <>{timeRemaining.toFormat("mm:ss")}} 65 |

66 | 67 | {progressBarEnabled && } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/assets/images/delta-timetable-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/components/SettingsPage.tsx: -------------------------------------------------------------------------------- 1 | import ClassSettings from "./ClassSettings.tsx"; 2 | import Timetable from "../models/Timetable.ts"; 3 | import GroupSettings from "./GroupSettings.tsx"; 4 | import {Button} from "react-bootstrap"; 5 | import {useState} from "react"; 6 | import {Link} from "react-router-dom"; 7 | import School from "../models/School.ts"; 8 | import SchoolSettings from "./SchoolSettings.tsx"; 9 | import TeacherModeSettings from "./TeacherModeSettings.tsx"; 10 | import TeacherSettings from "./TeacherSettings.tsx"; 11 | import TernaryDarkMode from "../models/TernaryDarkMode.ts"; 12 | import ColorSchemeSettings from "./ColorSchemeSettings.tsx"; 13 | import ProgressBarSettings from "./ProgressBarSettings.tsx"; 14 | import ShownHoursCountSettings from "./ShownHoursCountSettings.tsx"; 15 | 16 | interface Props { 17 | isTimetableQueryLoading: boolean; 18 | isTimetableQueryError: boolean; 19 | timetable: Timetable | null; 20 | ternaryDarkMode: TernaryDarkMode; 21 | selectedSchool: School | null; 22 | teacherModeEnabled: boolean; 23 | selectedClassId: string | null; 24 | selectedTeacherId: string | null; 25 | selectedGroupIds: string[]; 26 | setTernaryDarkModeCallback: (ternaryDarkMode: TernaryDarkMode) => void; 27 | setSelectedSchoolCallback: (school: School | null) => void; 28 | setTeacherModeEnabledCallback: (teacherModeEnabled: boolean) => void; 29 | setSelectedClassIdCallback: (classId: string | null) => void; 30 | setSelectedTeacherIdCallback: (teacherId: string | null) => void; 31 | setSelectedGroupIdsCallback: (groupIds: string[]) => void; 32 | } 33 | 34 | export default function SettingsPage(props: Props) { 35 | const [selectedGroupIds, setSelectedGroupIds] = useState(props.selectedGroupIds); 36 | 37 | const handleSave = () => { 38 | props.setSelectedGroupIdsCallback(selectedGroupIds); 39 | } 40 | 41 | document.title = "Nastavení"; 42 | 43 | return
44 |

Nastavení

45 |
46 | 47 |
48 |
49 | 50 |
51 |
52 | 53 |
54 |
55 | 56 |
57 |
58 | {props.selectedSchool !== null && 59 | } 60 |
61 |
62 | {props.selectedSchool !== null && (!props.teacherModeEnabled ? 63 | : 64 | )} 65 |
66 |
67 | {!props.teacherModeEnabled && props.selectedClassId !== null && 68 | } 70 |
71 | 72 | 75 | 76 |
77 | } 78 | -------------------------------------------------------------------------------- /.idea/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 17 | 30 | 49 | 63 | -------------------------------------------------------------------------------- /public/assets/images/favicon-apple.svg: -------------------------------------------------------------------------------- 1 | 3 | 17 | 30 | 49 | 63 | -------------------------------------------------------------------------------- /public/assets/images/favicon.svg: -------------------------------------------------------------------------------- 1 | 3 | 17 | 30 | 50 | 65 | -------------------------------------------------------------------------------- /src/components/GroupSettings.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent, useEffect, useState} from "react"; 2 | import {nanoid} from "nanoid"; 3 | import Timetable from "../models/Timetable.ts"; 4 | import {FormCheck} from "react-bootstrap"; 5 | 6 | interface Props { 7 | isTimetableQueryLoading: boolean; 8 | isTimetableQueryError: boolean; 9 | timetable: Timetable | null; 10 | selectedGroupIds: string[]; 11 | setSelectedGroupIdsCallback: (groupIds: string[]) => void; 12 | } 13 | 14 | export default function GroupSettings(props: Props) { 15 | const [selectedGroupIds, setSelectedGroupIds] = useState(props.selectedGroupIds); 16 | 17 | useEffect(() => { 18 | setSelectedGroupIds(props.selectedGroupIds); 19 | }, [props.selectedGroupIds]); 20 | 21 | const handleChange = (e: ChangeEvent) => { 22 | const selectedGroupId = e.target.dataset.groupId!; 23 | 24 | setSelectedGroupIds((prevSelectedGroupIds) => { 25 | if (props.timetable === null) { 26 | return []; 27 | } 28 | 29 | // This cleans up any previously selected groups that are no longer available. 30 | const newSelectedGroupIds: string[] = []; 31 | for (const groupGroup of props.timetable.groupGroups) { 32 | for (const group of groupGroup) { 33 | if (prevSelectedGroupIds.includes(group.id)) { 34 | newSelectedGroupIds.push(group.id); 35 | } 36 | } 37 | } 38 | 39 | if (!newSelectedGroupIds.includes(selectedGroupId)) { 40 | for (const groupGroup of props.timetable.groupGroups) { 41 | if (!groupGroup.some((group) => group.id == selectedGroupId)) { 42 | continue; 43 | } 44 | 45 | for (const group of groupGroup) { 46 | const index = newSelectedGroupIds.indexOf(group.id); 47 | if (index === -1) { 48 | continue; 49 | } 50 | 51 | newSelectedGroupIds.splice(index, 1); 52 | } 53 | } 54 | 55 | newSelectedGroupIds.push(selectedGroupId); 56 | } 57 | 58 | return newSelectedGroupIds; 59 | }); 60 | }; 61 | 62 | const setSelectedGroupIdsCallback = props.setSelectedGroupIdsCallback; 63 | useEffect(() => { 64 | setSelectedGroupIdsCallback(selectedGroupIds); 65 | }, [setSelectedGroupIdsCallback, selectedGroupIds]); 66 | 67 | return <> 68 |

Skupiny

69 |

Své skupiny najdete v rozvrhu aplikace Bakaláři.

70 | 71 | {(props.isTimetableQueryLoading ? (

Načítání...

72 | ) : props.isTimetableQueryError ? (

Skupiny se nepodařilo načíst.

73 | ) : props.timetable === null ? (<> 74 | ) : props.timetable.groupGroups.length < 1 ? (

Tato třída neobsahuje žádné skupiny.

75 | ) :
76 | {props.timetable.groupGroups.sort().map((groupGroup) => ( 77 |
78 | {groupGroup 79 | .sort((a, b) => a.isBlank ? 1 : b.isBlank ? -1 : a.id.localeCompare(b.id)) 80 | .map((group) => ( 81 | group.id)).toString()} 85 | type={"radio"} 86 | id={"input-group-" + group.id} 87 | onChange={handleChange} 88 | key={nanoid()} 89 | data-group-id={group.id} 90 | checked={selectedGroupIds.includes(group.id)} 91 | /> 92 | ))} 93 |
94 | ))} 95 |
) 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/components/Lessons.tsx: -------------------------------------------------------------------------------- 1 | import LessonInfo from "./LessonInfo.tsx"; 2 | import Lesson from "../models/Lesson.ts"; 3 | import {DateTime} from "luxon"; 4 | import HourTime from "../models/HourTime.ts"; 5 | import Hour from "../models/Hour.ts"; 6 | import {ReactElement} from "react"; 7 | import {useLocalStorage} from "usehooks-ts"; 8 | import {Indicate} from 'indicate' 9 | 10 | interface Props { 11 | teacherModeEnabled: boolean; 12 | currentTime: DateTime; 13 | hourTimes: HourTime[]; 14 | hours: Hour[]; 15 | firstHourIndex: number; 16 | lastHourIndex: number; 17 | } 18 | 19 | interface LessonInfoProps { 20 | lesson: Lesson | null; 21 | isBreak: boolean; 22 | } 23 | 24 | function generateFilteredLessonInfos( 25 | lessonInfoProps: LessonInfoProps[], 26 | isFirstSelectedLesson: boolean, 27 | teacherModeEnabled: boolean, 28 | shownHoursCount: number 29 | ) { 30 | // This is necessary to display "volno" after all lessons in case the last lesson is not null (not "volno"). 31 | if (lessonInfoProps[lessonInfoProps.length - 1].lesson !== null) { 32 | lessonInfoProps.push({lesson: null, isBreak: false}); 33 | } 34 | for (let index = lessonInfoProps.length - 1; index > 0; index--) { 35 | if (lessonInfoProps[index].lesson === null) { 36 | if (lessonInfoProps[index - 1].lesson === null) { 37 | lessonInfoProps.splice(index, 1); 38 | } else if (lessonInfoProps[index - 1].isBreak) { 39 | lessonInfoProps.splice(index - 1, 1); 40 | } 41 | } 42 | } 43 | let lessonInfos: ReactElement[] = []; 44 | for (let index = 0; index < Math.min(lessonInfoProps.length, shownHoursCount); index++) { 45 | lessonInfos.push( 46 | 57 | ); 58 | lessonInfos.push(
); 59 | } 60 | lessonInfos.pop(); 61 | return lessonInfos; 62 | } 63 | 64 | export default function Lessons(props: Props) { 65 | let currentHourIndex = -1; 66 | let isBreak = false; 67 | 68 | const [shownHoursCount] = useLocalStorage("shownHoursCount", 2); 69 | 70 | for (let i = props.firstHourIndex; i <= props.lastHourIndex; i++) { 71 | if (props.currentTime > props.hourTimes[i].end) { 72 | continue; 73 | } 74 | 75 | currentHourIndex = i; 76 | if (props.currentTime < props.hourTimes[i].start) { 77 | if (i > props.firstHourIndex) { 78 | isBreak = true; 79 | } 80 | currentHourIndex--; 81 | } 82 | 83 | break; 84 | } 85 | 86 | let lessonInfoProps: LessonInfoProps[] = []; 87 | for (let index = currentHourIndex; index < props.hours.length; index++) { 88 | let lesson: Lesson | null = null; 89 | 90 | if (index !== -1) { 91 | lesson = props.hours[index].selectedLesson; 92 | } 93 | 94 | lessonInfoProps.push({lesson, isBreak}); 95 | 96 | if (index === -1) { 97 | break; 98 | } 99 | 100 | isBreak = false; 101 | } 102 | 103 | return
104 | 106 |
107 | {generateFilteredLessonInfos( 108 | lessonInfoProps, 109 | currentHourIndex < props.firstHourIndex, 110 | props.teacherModeEnabled, 111 | shownHoursCount 112 | )} 113 |
114 |
115 |
116 | } 117 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback, useMemo} from "react"; 2 | import MainPage from "./MainPage.tsx"; 3 | import {createBrowserRouter, RouterProvider} from "react-router-dom"; 4 | import SettingsPage from "./SettingsPage.tsx"; 5 | import {useQuery} from "@tanstack/react-query"; 6 | import {getTimetableClass, getTimetableTeacher} from "../api/timetable.ts"; 7 | import School from "../models/School.ts"; 8 | import {useLocalStorage, useTernaryDarkMode} from "usehooks-ts"; 9 | import TernaryDarkMode from "../models/TernaryDarkMode.ts"; 10 | 11 | export default function App() { 12 | const {isDarkMode, ternaryDarkMode, setTernaryDarkMode} = useTernaryDarkMode({defaultValue: "system"}); 13 | const [selectedSchool, setSelectedSchool] = useLocalStorage("selectedSchool", null); 14 | const [teacherModeEnabled, setTeacherModeEnabled] = useLocalStorage("teacherModeEnabled", false); 15 | const [selectedClassId, setSelectedClassId] = useLocalStorage("selectedClassId", null); 16 | const [selectedTeacherId, setSelectedTeacherId] = useLocalStorage("selectedTeacherId", null); 17 | const [selectedGroupIds, setSelectedGroupIds] = useLocalStorage("selectedGroupIds", []); 18 | 19 | const handleDarkModeChange = useCallback((ternaryDarkMode: TernaryDarkMode) => { 20 | setTernaryDarkMode(ternaryDarkMode); 21 | }, [setTernaryDarkMode]); 22 | 23 | const handleSelectedSchoolChange = useCallback((school: School | null) => { 24 | if (school?.id !== selectedSchool?.id) { 25 | setSelectedClassId(null); 26 | setSelectedGroupIds([]); 27 | setSelectedSchool(school); 28 | } 29 | }, [selectedSchool?.id, setSelectedClassId, setSelectedGroupIds, setSelectedSchool]); 30 | 31 | const handleTeacherModeEnabledChange = useCallback(setTeacherModeEnabled, [setTeacherModeEnabled]); 32 | 33 | const handleSelectedClassIdChange = useCallback((classId: string | null) => { 34 | if (classId !== selectedClassId) { 35 | setSelectedGroupIds([]); 36 | setSelectedClassId(classId); 37 | } 38 | }, [selectedClassId, setSelectedClassId, setSelectedGroupIds]); 39 | 40 | const handleSelectedTeacherIdChange = useCallback((teacherId: string | null) => { 41 | if (teacherId !== selectedTeacherId) { 42 | setSelectedTeacherId(teacherId); 43 | } 44 | }, [selectedTeacherId, setSelectedTeacherId]); 45 | 46 | const handleSelectedGroupIdsChange = useCallback((groupIds: string[]) => { 47 | setSelectedGroupIds(groupIds); 48 | }, [setSelectedGroupIds]); 49 | 50 | const timetableQuery = useQuery({ 51 | queryKey: ["timetable", 52 | selectedSchool?.id, 53 | teacherModeEnabled, 54 | selectedClassId, 55 | selectedTeacherId, 56 | selectedGroupIds], 57 | queryFn: () => { 58 | if (selectedSchool === null || 59 | (teacherModeEnabled ? selectedTeacherId === null : selectedClassId === null)) { 60 | return null; 61 | } 62 | 63 | return teacherModeEnabled ? 64 | getTimetableTeacher(selectedSchool, selectedTeacherId!) : 65 | getTimetableClass(selectedSchool, selectedClassId!, selectedGroupIds); 66 | } 67 | }); 68 | const timetable = timetableQuery.data === undefined ? null : timetableQuery.data; 69 | 70 | const router = useMemo(() => createBrowserRouter([ 71 | { 72 | path: "/", 73 | element: , 78 | }, 79 | { 80 | path: "/nastaveni", 81 | element: , 96 | }, 97 | ]), [timetable, timetableQuery.isLoading, timetableQuery.isError, ternaryDarkMode, teacherModeEnabled, 98 | selectedSchool, selectedClassId, selectedTeacherId, selectedGroupIds, handleDarkModeChange, 99 | handleSelectedSchoolChange, handleTeacherModeEnabledChange, handleSelectedClassIdChange, 100 | handleSelectedTeacherIdChange, handleSelectedGroupIdsChange]); 101 | 102 | return
103 | 104 |
105 | } 106 | -------------------------------------------------------------------------------- /src/api/timetable.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | import Day from "../models/Day.ts"; 3 | import Hour from "../models/Hour.ts"; 4 | import Lesson from "../models/Lesson.ts"; 5 | import Timetable from "../models/Timetable.ts"; 6 | import ClassId from "../models/ClassId.ts"; 7 | import HourTime from "../models/HourTime.ts"; 8 | import {DateTime} from "luxon"; 9 | import Group from "../models/Group.ts"; 10 | import School from "../models/School.ts"; 11 | import Teacher from "../models/Teacher.ts"; 12 | 13 | const timetableBlankPermanentUrl = "Timetable/Public"; 14 | 15 | const timetableClassPermanentBaseUrl = "Timetable/Public/Permanent/Class/"; 16 | const timetableClassCurrentBaseUrl = "Timetable/Public/Actual/Class/"; 17 | 18 | const timetableTeacherPermanentBaseUrl = "Timetable/Public/Permanent/Teacher/"; 19 | const timetableTeacherCurrentBaseUrl = "Timetable/Public/Actual/Teacher/"; 20 | 21 | async function fetchHtml(url: string) { 22 | const response = await fetch(url); 23 | 24 | if (!response.ok) { 25 | throw new Error(); 26 | } 27 | 28 | return await response.text(); 29 | } 30 | 31 | export async function getClassIds(school: School): Promise { 32 | const html = await fetchHtml(new URL(timetableBlankPermanentUrl, school.url).toString()); 33 | const $ = cheerio.load(html); 34 | 35 | const classIds: ClassId[] = []; 36 | 37 | try { 38 | const classSelect = $("#selectedClass")[0]; 39 | for (const option of classSelect.children) { 40 | const id = $(option).attr("value"); 41 | const name = $(option).text(); 42 | 43 | if (id === undefined || name.trim() === "") { 44 | continue; 45 | } 46 | 47 | classIds.push(new ClassId(id, name)); 48 | } 49 | } catch { 50 | throw new Error(); 51 | } 52 | 53 | return classIds; 54 | } 55 | 56 | export async function getTeachers(school: School): Promise { 57 | const html = await fetchHtml(new URL(timetableBlankPermanentUrl, school.url).toString()); 58 | const $ = cheerio.load(html); 59 | 60 | const teachers: Teacher[] = []; 61 | 62 | try { 63 | const classSelect = $("#selectedTeacher")[0]; 64 | for (const option of classSelect.children) { 65 | const id = $(option).attr("value"); 66 | const name = $(option).text(); 67 | 68 | if (id === undefined || name.trim() === "") { 69 | continue; 70 | } 71 | 72 | teachers.push(new Teacher(id, name)); 73 | } 74 | } catch { 75 | throw new Error(); 76 | } 77 | 78 | return teachers; 79 | } 80 | 81 | export async function getTimetableClass(school: School, classId: string, selectedGroupIds: string[]) { 82 | const permanentUrl = new URL(timetableClassPermanentBaseUrl + classId, school.url).toString(); 83 | const currentUrl = new URL(timetableClassCurrentBaseUrl + classId, school.url).toString(); 84 | 85 | return await getTimetable(permanentUrl, currentUrl, selectedGroupIds); 86 | } 87 | 88 | export async function getTimetableTeacher(school: School, teacherId: string) { 89 | const permanentUrl = new URL(timetableTeacherPermanentBaseUrl + teacherId, school.url).toString(); 90 | const currentUrl = new URL(timetableTeacherCurrentBaseUrl + teacherId, school.url).toString(); 91 | 92 | return await getTimetable(permanentUrl, currentUrl, [], true); 93 | } 94 | 95 | async function getTimetable(permanentUrl: string, 96 | currentUrl: string, 97 | selectedGroupIds: string[], 98 | selectAllGroups: boolean = false): Promise { 99 | const permanentHtml = await fetchHtml(permanentUrl); 100 | const currentHtml = await fetchHtml(currentUrl); 101 | 102 | const daysPermanent = createDays(permanentHtml, selectedGroupIds, selectAllGroups); 103 | const daysCurrent = createDays(currentHtml, selectedGroupIds, selectAllGroups); 104 | 105 | const groupGroups: Group[][] = []; 106 | 107 | const hours: Hour[] = []; 108 | for (const day of daysPermanent) for (const hour of day.hours) hours.push(hour); 109 | 110 | const lessonGroups: Lesson[][] = []; 111 | 112 | for (const hour of hours) { 113 | const lessonsMap: { [key: string]: Lesson[] } = {}; 114 | 115 | const lessonsWithoutColon: Lesson[] = []; 116 | 117 | for (const lesson of hour.lessons) { 118 | if (lesson.isNotEveryWeek) { 119 | if (!lessonsMap[lesson.weekId!]) { 120 | lessonsMap[lesson.weekId!] = [lesson]; 121 | } else { 122 | lessonsMap[lesson.weekId!].push(lesson); 123 | } 124 | } else { 125 | lessonsWithoutColon.push(lesson); 126 | } 127 | } 128 | 129 | lessonGroups.push(...Object.values(lessonsMap)); 130 | if (lessonsWithoutColon.length > 0) { 131 | lessonGroups.push(lessonsWithoutColon); 132 | } 133 | } 134 | 135 | hoursLoop: 136 | for (const lessonGroup of lessonGroups 137 | .sort((a, b) => b.length - a.length) 138 | ) { 139 | const groupGroup: Group[] = []; 140 | for (const lesson of lessonGroup) { 141 | if (lesson.group === null) { 142 | continue hoursLoop; 143 | } 144 | 145 | if (groupGroups.some(groupGroup => groupGroup.some((group) => group.id === lesson.group!.id))) { 146 | continue; 147 | } 148 | 149 | groupGroup.push(lesson.group); 150 | } 151 | 152 | if (groupGroup.length > 0) { 153 | groupGroup.push(new Group(`blank-${groupGroup.map((group) => group.id)}`, null, true)); 154 | groupGroups.push(groupGroup); 155 | } 156 | } 157 | 158 | const hourTimes = createHourTimes(currentHtml); 159 | // https://napoveda.bakalari.cz/ro_konfigurace_konfzobrazeni.htm 160 | const hourTimesMinutesGreatestCommonDivisor = hourTimes.every( 161 | hourTime => hourTime.start.minute % 5 === 0 && hourTime.end.minute % 5 === 0 162 | ) ? 5 : 1; 163 | 164 | return new Timetable(daysCurrent, groupGroups, hourTimes, hourTimesMinutesGreatestCommonDivisor, currentUrl); 165 | } 166 | 167 | function createDays(html: string, selectedGroupIds: string[], selectAllGroups: boolean): Day[] { 168 | const $ = cheerio.load(html); 169 | 170 | const days: Day[] = []; 171 | const cellWrappers = $(".bk-timetable-row > .bk-cell-wrapper"); 172 | for (const cellWrapper of cellWrappers) { 173 | const hours: Hour[] = []; 174 | 175 | const cells = $(cellWrapper).find(".bk-timetable-cell"); 176 | for (const cell of cells) { 177 | const lessons: Lesson[] = []; 178 | 179 | const dayItemHovers = $(cell).find(".day-item > .day-item-hover"); 180 | for (const dayItemHover of dayItemHovers) { 181 | const dayItem = $(dayItemHover).find(".day-flex"); 182 | 183 | const middle = dayItem.find(".middle"); 184 | const subject = middle.text().trim(); 185 | 186 | if (subject === "" || middle.prop("style")?.visibility === "hidden") { 187 | continue; 188 | } 189 | 190 | const dataDetailGroup = JSON.parse($(dayItemHover).prop("data-detail")).group as string | undefined; 191 | const groupName = dataDetailGroup === undefined ? "" : dataDetailGroup.trim(); 192 | const group = new Group(groupName, groupName === "" ? null : groupName, false); 193 | const room = $(dayItem).find(".top > .right > div").text().trim(); 194 | const teacher = $(dayItem).find(".bottom > span").text().trim(); 195 | 196 | let isNotEveryWeek = false; 197 | let weekId = null; 198 | if (subject.includes(": ")) { 199 | isNotEveryWeek = true; 200 | weekId = subject.split(": ")[0]; 201 | } 202 | 203 | lessons.push(new Lesson(subject, group.name === null ? null : group, room, teacher, isNotEveryWeek, weekId)); 204 | } 205 | 206 | const selectedLesson = lessons.find((lesson) => 207 | selectAllGroups || lesson.group === null || selectedGroupIds.includes(lesson.group.id)); 208 | 209 | hours.push(new Hour(lessons, selectedLesson !== undefined ? selectedLesson : null)); 210 | } 211 | 212 | days.push(new Day(hours)); 213 | } 214 | 215 | return days; 216 | } 217 | 218 | function createHourTimes(html: string): HourTime[] { 219 | const $ = cheerio.load(html); 220 | 221 | const hourTimes = []; 222 | 223 | const hours = $(".bk-timetable-hours > .bk-hour-wrapper > .hour"); 224 | for (const hour of hours) { 225 | const from = $(hour).children().first().text(); 226 | const to = $(hour).children().last().text(); 227 | 228 | hourTimes.push(new HourTime(DateTime.fromFormat(from, "H:mm"), DateTime.fromFormat(to, "H:mm"))); 229 | } 230 | 231 | return hourTimes; 232 | } 233 | --------------------------------------------------------------------------------